Commit Graph
15 Commits
Author SHA1 Message Date
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck 65677c2112 fix(api): bound every JSON body read
Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
2026-06-10 11:36:51 +02:00
Marc-André Bombeck c6d2b212f2 fix(auth): self-hoster opt-out for session-cookie Secure flag (v1.5.1)
v1.5.1 patch. Fixes the silent login failure self-hosters hit on
plain-HTTP origins that aren't localhost — the bundled Docker image
runs NODE_ENV=production, so every cookie carried Secure, and modern
browsers drop those on http://10.x.x.x style origins. The session
cookie never made it back to the browser, /api/auth/me returned 401,
and the login page reloaded.

New SESSION_COOKIE_SECURE env var with three values:
- unset (default) — flag follows NODE_ENV === "production" (unchanged)
- "false" — never set (LAN / VPN / Tailscale-only opt-out)
- "true"  — always set (pnpm dev fronted by HTTPS)

Shared helper at src/lib/auth/secure-cookie.ts replaces four scattered
literals across session, codex device-start, and withings connect.
Six unit cases pin the decision matrix.

OpenAPI spec generator now reads info.version from package.json so
the drift gate stays meaningful instead of catching every release bump.

Default behaviour unchanged for HTTPS-fronted deployments.
2026-05-26 19:16:44 +02:00
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00
Marc-André Bombeck 66a7f8b6fa chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
2026-05-22 09:18:52 +02:00
Marc-André Bombeck 17b8d8d2c2 chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
2026-05-14 23:49:32 +02:00
Marc-André BombeckandClaude Opus 4.7 cd3b890067 refactor(v1.4.15): apply simplify-review safe suggestions
Drop dead exports + misleading comments + unreachable defaults flagged
by phase-D simplify review (autonomous-yes items only):

- channel-state: drop unused TEST_CONSTANTS re-export
- retry-policy: drop unused RejectKind type alias
- achievements: drop misleading "fallback" comment on exhaustive switch
- notification-status-card: drop unreachable default in stateBadgeFor
- withings: export isWithingsRefreshReauthFailure + dedupe inline
  classifier in /api/withings/status route
- codex-slug-cache: drop test-only inspectCodexSlugCache helper; tests
  read getCachedCodexSlug() instead

Findings 1, 2, 8, 9 deferred to v1.4.16 (require product call).
Finding 11 is an explicit anti-finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:14:43 +02:00
Marc-André BombeckandClaude Opus 4.7 b290a775d0 fix(integrations): refresh-token failure marks integration as needing re-auth
v1.4.15 Phase B2, criterion 2. The Phase B2 status helper (commit
4db72a8) defined a markReauthRequired / recordSyncFailure API but
nothing called it. A Withings refresh token that had revoked got the
same "transient error" treatment as a 503 from the upstream API,
scheduled syncs kept hammering the OAuth endpoint, and the Settings
UI showed "Connected" until the user manually triggered a sync and
read the inline error.

This commit wires every sync / OAuth path:

  - getValidToken() (Withings): refresh failures classified by
    Withings status code. 100/101/102 (auth failed / invalid token /
    user gone) and 200..299 (OAuth grant errors) -> error_reauth.
    Anything else (5xx, network) -> error_transient. Same logic
    inlined in /api/withings/status so the proactive refresh path
    parks the row too.
  - syncUserMeasurements() and syncMoodLogEntries() short-circuit
    immediately when isReauthRequired() returns true. Scheduled
    pg-boss jobs no longer burn API quota on a parked connection.
  - moodLog 401/403 from the upstream -> error_reauth. 5xx / 3xx
    redirect / network / invalid-JSON / bad-format -> error_transient.
    SSRF refusal also writes a status row so it surfaces in the UI.
  - /api/withings/callback calls markReconnected() after writing the
    new tokens, the next sync runs.
  - /api/withings/disconnect and DELETE /api/settings/moodlog call
    markDisconnected() to clear any prior error state. moodLog
    PUT /api/settings/moodlog (re-save credentials) markReconnected()s
    for the same reason.

The helper writes AuditLog action=integrations.sync.failed on every
failure (criterion 4) and dispatches a SYSTEM_ALERT notification to
admins after consecutiveFailures >= 3 with a 24h re-alert window
(criterion 3). Both paths are covered by the unit + integration test
suites that landed in 4db72a8 — this commit makes them live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:05:22 +02:00
Marc-André BombeckandClaude Opus 4.7 02b99553b9 chore(style): repo-wide prettier sweep before v1.4.6 tag
Long-overdue prettier sweep; prettier had drifted vs. checked-in code
across ~130 files (mostly long-argument line-break wrapping in TS and
table-padding in markdown). Pure formatting — no semantic changes —
and required to clear the v1.4.6 release gate's `pnpm format:check`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:27:17 +02:00
Marc-André Bombeck 6e74612efc test(coverage): real-behaviour tests for audit + webhook routes (~80% line coverage) (#142)
Adds vitest unit tests covering the four zero-coverage webhook /
monitoring surfaces external systems hit. Each test mocks at the module
boundary (per existing project pattern in src/app/api/auth/login/
__tests__/native-token.test.ts) and asserts on actual call arguments
— no test theatre.

Per-file line coverage:
- src/lib/auth/audit.ts                                    100.00%
- src/app/api/withings/webhook/route.ts                     98.11%
- src/app/api/integrations/moodlog/webhook/route.ts         95.23%
- src/app/api/telegram/webhook/route.ts                     95.12%

Test count: +61 (494 -> 555).
2026-05-08 14:30:03 +02:00
Marc-André BombeckandClaude Opus 4.7 3221f86eea feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account

Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
<SettingsShell> sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).

Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/<slug> instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.

Co-Authored-By: Marc-André Bombeck <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(settings): route every Codex OAuth callback redirect to /settings/integrations

Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.

After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.

All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:15:15 +02:00
Marc-André BombeckandClaude Opus 4.7 98a57eb786 audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds

User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in
the measurements filter, badge, mobile icon, and edit dialog (issue #109).
Server-rendered doctor report now includes body composition rows (audit
C-9). Body water + bone mass have proper effective-range thresholds so
severity logic doesn't silently return `nominal` (audit C-15).

Structural: extracted the per-type display maps from measurement-list.tsx
into measurement-list-meta.ts as a deliberate first step toward the
single-source manifest planned for phase P5. Coverage tests now fail-fast
if a future enum addition skips any of: list label/icon/color, doctor-PDF
label/unit/vital-types, chart label, threshold metric order/label/bounds.

Audit-2026-05-07 / phase P0:
- closes #109 (filter UI gap, root cause for the German raw-enum display)
- closes audit C-9 (server PDF used a stale type map)
- closes audit C-15 (no warn/critical bands for body composition)

Tests: 282 → 296 (14 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: align OpenAPI spec + migration comment with body-composition reality

- OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at
  v1.3.0 baseline; spec drifted past the v1.3 server release)
- Bump openapi version 1.3.0 → 1.3.2 to match package.json
- Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content),
  not percent of body weight. Validators (VALUE_RANGES) and the Withings
  client (type 77 → kg) treat it as kg already; the comment was the lone
  outlier and would mislead the next reviewer.

Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum)
and M-B2 (migration unit comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip)

C-3 (Withings webhook secret in URL):
Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=…
fallback retained for legacy Withings configurations and emits a Wide
Event warning so operators can spot still-using-the-old-flow integrators.
The query-string secret was leaking into Coolify access logs and any
GlitchTip event whose request.url got captured.

C-4 (idempotency dead for Bearer-token clients):
The default userIdResolver only consulted getSession(). Bearer-authed
clients (the iOS / n8n / headless-API fleet that PR #117 was built for)
fell through, so every retry created duplicate measurements. The default
now tries cookie session first, then Bearer token via
hashToken+apiToken.findUnique with revoked/expiry checks. All three
existing call sites (measurements, mood-entries, medications/intake) get
the fix automatically.

H-B7 (request.url with secrets shipped to GlitchTip):
reportToGlitchtip now strips the query string before forwarding the URL,
so Withings legacy ?secret=… and any OAuth ?code=… that errors during
the callback don't end up in someone else's incident UI.

Audit-2026-05-07 / phase P2.
Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback +
revoked/expired/null cases).
Deferred to a follow-up (needs DB migration / lock primitive):
  C-2  moodLog webhook → AES-GCM at rest + HMAC-over-body
  C-5  idempotency TOCTOU advisory_lock or in-flight sentinel row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3

Closes the OxygenSaturation feature request from #109.

- Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS)
- Zod validators: plausibility 50–100% (below 50 is incompatible with
  sustained life; above 100 is physically impossible)
- effective-range thresholds: green 95–100, orange 92–94, red <92.
  Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice
  (≥95% at rest is healthy), AHA/clinical literature (<92% = call
  provider, <88% = ER). Lower-only concern — upper orange wing
  collapses to greenMax since saturation cannot exceed 100%.
- Personalisation: existing thresholds-override UI lets COPD /
  chronic-respiratory users with a doctor-set 88–92 baseline tune the
  bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in
  thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>`
  forces TS to catch any missed surface.
- Withings ScanWatch type 54 mapping (best-effort — only fires for
  pulse-ox-capable devices).
- Wired through measurement-form, measurement-list-meta (icon: Wind),
  health-chart, both doctor-report-pdf renderers (core + browser-side
  near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in
  4 sections × 2 locales.
- iOS DTO already declared `OXYGEN_SATURATION` — server enum addition
  closes the long-standing drift flagged in audit V2.
- 4 new tests: numeric green band, upper-wing collapse,
  user-override (COPD baseline), METRIC_BOUNDS shape.

Tests: 301 → 305. Typecheck: clean.

Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analytics): truthfulness pass on medical citations + clamp override orangeMax

Doctor-agent + hallucination-hunter audit V3 found six medical citations
in effective-range.ts that the code did not actually implement. Plus a
latent bug in the user-override path that produced physically-impossible
orangeMax values (>100% saturation, <0% body-fat).

Citation rewrites (no numeric changes):

- ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020.
  WHO publishes activity in min/week, not steps. Step-count was a
  misattribution that also leaked into AI prompts.
- TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson
  formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance,
  it does not publish kg cut-offs.
- BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance-
  estimated; not DEXA-comparable". Withings BIA values typically run
  5–7% below DEXA — calling them DEXA-equivalent in a doctor-report
  PDF was a real legal-exposure misattribution.
- OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS
  clinical practice (≥95% at rest is healthy)" → "consumer pulse-
  oximeter consensus (≥95% normal at rest); slightly tighter than
  BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA
  pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100
  band — that's an ATS/consumer-consensus number, not BTS's literal
  94-98 target. Soften the citation to match the code, not vice versa.

Bug fix (1 latent, 1 real-today):

- effective-range override path now clamps orangeMin/orangeMax to
  METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting
  SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible
  saturation), and a BODY_FAT override of {2,80} emitted orangeMin
  = -9.7 (negative percent). Default-range case 210 already
  hard-coded orangeMax: 100 for the same reason — the override path
  was the only branch missing the invariant.

Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%).
Typecheck: clean.

Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure

Two CRITICAL fixes from audit V3.

NEW-V3-1: Bearer-scope bypass.
  apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent"
  scope (the iOS app receives this on login). The previous check
    permissions.includes(requiredPermission)
  treated "*" as a literal string — never matched. Today many sensitive
  routes call requireAuth() *without* a requiredPermission, so a leaked iOS
  token can act as a full-scope token (account delete, settings wipe). Once
  those routes adopt requireAuth("scope:name"), this wildcard handling keeps
  the iOS app working while narrower-scoped tokens (e.g. user-issued
  ["medication:ingest"]) get correctly 403'd.

  Same wildcard handling added to the standalone permission checks in
  src/app/api/ingest/medication/route.ts:65, 119.

NEW-V3-2: GDPR Art. 17 account-delete erasure completeness.
  Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which
  preserved PII (free-text symptom descriptions, IP addresses, login-city
  geolocation) after the user was deleted. We now explicitly
  prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the
  cascade-delete, making account deletion genuinely complete erasure.

Tests 306/306 still pass; typecheck clean. Audit V3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pdf): real text-content assertions for body composition + SpO2 rows

Replaces bytes-only "renders body composition rows" theatre test with
pdf-parse-driven text extraction that asserts the actual rendered labels
+ values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN).

Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified
"%PDF-" header + byte-count, so a regression that drops a row would
still pass. Now a missing label fails the test.

- adds dev dep: pdf-parse@^2.4.5
- 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums

V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays
silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective
domains. Adding a new measurement type required hunting these down.

Now all aggregation paths derive from `measurementTypeEnum.options`:
- /api/insights/comprehensive (AI insights)
- /api/dashboard/summary (iOS dashboard adapter)
- /api/analytics (analytics aggregator)
- /lib/insights/general-status (AI general-status fetch)
- /api/import (Zod schema — round-trip now covers all 11 types)

External-contract enums extended additively (no breakage for old clients):
- /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass
- /api/dashboard/widgets widgetIdEnum: + oxygenSaturation
- DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row
- formatMeasurementsForExport: glucoseContext now round-trips

Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the
canonical enum stays the source of truth and documents PDF exclusions
(BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only).

313/313 tests pass (was 308, +5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit)

- CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was
  blanket connect-src on every page including /auth/login → DOM-XSS exfil
  channel)
- web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl
  refinement; was z.url() only → blind SSRF probe via Push subscription
- isPublicUrl: also fixes a regression where DNS labels starting with
  "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6
  unique-local. Check is now gated on a colon being present in the host.
- /api/ai/test: stop returning provider err.message + bodyExcerpt to the
  client (leaked provider URLs / partial keys / internal headers). Now
  returns categorised generic message; full details land in the wide-event
  via annotate() for the operator.
- /lib/geo (auditLog IP geo lookup): switch default provider to
  ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes,
  add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs.
  Previously leaked IP+timestamp over plaintext HTTP every auth event
  (GDPR Art. 32 + Art. 44).
- /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector)

Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF
guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider
shapes, geo opt-out env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening

V3 audit follow-on bundle covering 4 HIGH findings.

(I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF
trust. Reads chain right-to-left so client-supplied leftmost entries
cannot rotate the per-IP rate-limit bucket. Default 1 matches typical
single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces
x-real-ip / null. New 7-test suite covers the rotation attack +
malformed entries + zero-hop fallback.

(F) Audit-log retention job — daily 03:15 cleanup of audit_logs older
than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e)
"storage limitation" gap; previously IPs + city + login events
accumulated forever. 6-test suite covers default + override +
misconfig guard (rejects <7 days).

(G) isCachableStatus — extracted the inline 4xx/5xx filter into a
named, exported, tested function. 7 cases cover the full do-not-cache
contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx
validation are still cached.

(H) Bearer-mock tightening — apiToken.findUnique mocks in
require-auth-bearer.test + idempotency.test now assert the
where.tokenHash shape uses the hashed value (not the raw bearer).
A regression that switched back to raw-token comparison would have
been silent without this assertion.

Tests: 345/345 pass (was 332, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2)

The mood_log_webhook_secret column previously stored a plaintext secret
that the webhook handler compared timing-safe against every enabled
user. A DB dump or backup snapshot leaked active webhook credentials.

New contract — no schema migration required:
- Writes go through encryptMoodLogSecret() (AES-256-GCM via existing
  crypto helper, ENCRYPTION_KEY).
- Reads go through readMoodLogSecret() which transparently decrypts
  envelopes and tolerates legacy plaintext rows during the transition
  (graceful upgrade path — no breaking change for self-hosted users).
- Webhook lookup decrypts each candidate before timing-safe compare.
- Status endpoint decrypts for the user's settings page.

One-shot startup migration in reminder-worker rotates any leftover
plaintext rows on next worker boot. Idempotent; encrypted rows are
skipped.

Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough,
isLegacyPlaintext semantics, rotateLegacy idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift)

V3 audit Doku Specialist findings — drift between code and meta-docs.

- README §Key Features now mentions pulse oximetry (SpO₂) + Withings
  ScanWatch sync; Tech Stack model count corrected 23 → 25.
- AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected
  `vitest.config.ts` → `vitest.config.mts`; migration range corrected
  `0001–0022` → `0001–0024`; model count + list updated to 25 (adds
  Device, IdempotencyKey).
- CLAUDE.md model count updated (2 occurrences).
- CHANGELOG.md v1.3.3 expanded with Security + Internal sections that
  were missing the audit-fix-marathon work (Bearer-scope, account-delete
  cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo
  HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF,
  audit-log retention, idempotency cachable filter, Bearer mock
  tightening, enum-drift cousins, PDF text-content tests).
- OpenAPI Bearer description corrected `SHA-256 hashes` →
  `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs
  match the actual server-side implementation. Wildcard scope contract
  documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass

Multi-agent review of the audit-fix branch surfaced four HIGH issues
and a couple of MEDIUM hardenings. All addressed before merge.

HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk).
The dashboard-summary route now emits totalBodyWater/boneMass/
oxygenSaturation MetricCards, but the spec still listed only the
original 7 kinds. Generated iOS Codable would have crashed the dash
the moment a user logged SpO2. MetricKind extended with the 3 new
kinds + comment documenting unknown-value defensive decoding.

HIGH-2 — kindEnum naming consistency on /api/measurements/series.
The series adapter shipped "oxygen" while the dashboard summary used
"oxygenSaturation"; both routes now use "oxygenSaturation" so iOS
decoders can share a single MetricKind type across endpoints.

HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that
mock provider errors with sk-leaked-key + provider URLs in the err
fields and assert the response body never echoes them, plus the
3-status-categorised generic messages (401/429/5xx/other).

HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that
mock checkRateLimit allowed/disallowed and assert the 429 path uses
the right key shape (`import:<userId>`, 5, 1h) and that the within-
quota path proceeds.

MEDIUM-3 — getClientIp under-length XFF chain regression. With
TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp
returned the leftmost (attacker-controlled) entry, silently
re-introducing the rotation attack the helper was meant to close.
Now: when chain is shorter than configured hops, refuse XFF entirely
(fall back to x-real-ip / null). Added regression test.

LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of
silently degrading to hops=0. Added regression test.

Tests: 358/358 pass (was 350, +8 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:05:56 +02:00
9b59390224 fix: i18n completeness + UX polish (closes #73, #74) (#75)
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:43 +02:00
Marc-André Bombeck 0c0c345e82 i18n: migrate from German-first to English-first default locale
Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:

- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
  medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json

Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated

Bump version to 1.0.1.
2026-03-12 21:31:48 +01:00
Marc-André Bombeck 38309932f3 Initial release v1.0.0
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.
2026-03-10 20:14:33 +01:00