Commit Graph
19 Commits
Author SHA1 Message Date
Marc-André Bombeck 812a8f287e chore(lint): ignore .claude/** worktrees and align check-user mock with RateLimitResult
Two small follow-ups to the v1.4.41 inline-QA reconcile:

- eslint.config.mjs gains `.claude/**` in the globalIgnores list so a
  mid-marathon Claude Code worktree on an older base commit can no
  longer poison `pnpm lint` locally with pre-fix warnings. CI never
  walks that path; the change is purely for the dev environment.

- The check-user route test mock objects for `checkRateLimit` carried
  a stray `limit: 30` field that does not exist on `RateLimitResult`
  (the real type is `{ allowed, remaining, resetAt }`). The literal
  was harmless at runtime but flagged by `tsc --noEmit` on the
  test-file declarations. Drops the field at all three mock sites.
2026-05-21 18:15:59 +02:00
Marc-André Bombeck 2f52f66879 fix(auth-check-user): match identifier verbatim and gate behind per-IP rate-limit
Two correctness gaps on the v1.4.41 onboarding-discovery endpoint,
caught in the v1.4.41 QA round:

1. The route called `identifier.toLowerCase()` before the OR-match on
   `username` / `email`, but `registerSchema` applies no transform on
   write — both columns are stored exactly as typed. A user registered
   as `MixedCase@Example.com` would never resolve through this route,
   and iOS onboarding would route them to the sign-up branch despite
   an existing account.

2. The route header claimed an edge-level limit on `/api/auth/*`
   covered brute-force enumeration; no such middleware exists. Sibling
   `/api/auth/passkey/login-options` carries a per-route throttle. We
   mirror that shape — `checkRateLimit("auth:check-user:<ip>", 30,
   15 * 60 * 1000)` — and rewrite the header comment so the rationale
   matches reality.

Tests pin both invariants: a `MixedCase@Example.com` probe hits
`findFirst` with the case preserved, and a 429 short-circuits the
`findFirst` call entirely.
2026-05-21 18:09:35 +02:00
Marc-André Bombeck 99a801f993 feat(auth-check-user): four-branch discovery endpoint for iOS onboarding
Closes SB-7 follow-up. POST /api/auth/check-user accepts
{ identifier: string } (username or email, case-insensitive) and returns
{ branch, hasPasskey, hasPassword } where branch is one of:

- not_found       no account
- passkey_only    passkey present, no password
- email_fallback  password present (passkey optional)
- exists          neither credential (recovery path)

The booleans let the iOS client render a "Sign in with Passkey"
affordance alongside the password field without a second round-trip.

Five-case test pin covers every branch plus the 422 identifier-required
guard. The endpoint mirrors the envelope shape of /api/auth/passkey/
login-options and adds no new enumeration surface beyond that route.
2026-05-21 17:13:34 +02:00
Marc-André Bombeck b7da526cbb refactor(rollups): move measurement-read and rollup-coverage helpers
- src/lib/measurements/rollup-read.ts → src/lib/rollups/measurement-read.ts
- src/lib/measurements/rollup-coverage.ts → src/lib/rollups/measurement-coverage.ts
- co-located tests follow the source files

Importers updated:
- @/lib/measurements/rollup-read → @/lib/rollups/measurement-read (2 sites)
- @/lib/measurements/rollup-coverage → @/lib/rollups/measurement-coverage (10 sites)

Pure relocation.
2026-05-21 12:20:34 +02:00
Marc-André Bombeck 29bfcc674b refactor(rollups): move measurement-rollups under src/lib/rollups umbrella
Creates the new src/lib/rollups/ aggregator directory and relocates
the main measurement rollup module:

- src/lib/measurements/rollups.ts → src/lib/rollups/measurement-rollups.ts
- src/lib/measurements/__tests__/rollups.test.ts →
  src/lib/rollups/__tests__/measurement-rollups.test.ts

Every importer of @/lib/measurements/rollups is rewritten to
@/lib/rollups/measurement-rollups. The orphan sibling reference inside
src/lib/measurements/rollup-read.ts (still in its old home until the
next batch) is promoted to the @/lib alias so the chain keeps
typechecking through the in-flight move.

Pure relocation — no behaviour change, no exported-surface change. The
umbrella exists so the rollup contract is discoverable from a single
directory before the iOS sprint locks the boundary.
2026-05-21 12:19:45 +02:00
Marc-André Bombeck 317d56181b test(auth-registration-status): pin all four discovery branches
The route is read-only and its envelope shape (`registrationEnabled`
boolean) gates iOS sign-in + web onboarding. Four branches walked:
  1. singleton row exists with registrationEnabled: true → reports true
  2. singleton row exists with registrationEnabled: false → reports
     false (admin-disabled tenant, invite-only)
  3. singleton row missing → reports schema default true (fresh self-
     hosted instance, no-config baseline)
  4. findUnique throws → reports false (fail-closed; critical defense
     against a DB blip silently unlocking sign-up on a tenant that
     disabled registration)

No code change; the route's current behaviour is correct. Pin it
before someone refactors fail-closed → fail-open by accident.
2026-05-21 11:53:04 +02:00
Marc-André Bombeck 014593a2a1 test(aasa): pin payload shape and content-type
Five assertions on the route handler:
  - 200 on an unauthenticated GET.
  - Content-Type is exactly application/json with no charset parameter
    (Apple's swcd/aasa-validator refuse a charset and the failure mode
    is silent — Universal Links just stop firing).
  - Cache-Control allows public CDN mirroring.
  - JSON shape matches the SB-4 spec exactly.
  - applinks App ID matches webcredentials App ID so a rotated
    Team ID never half-lands.

Includes the v1.4.40 phase report.
2026-05-21 11:38:58 +02:00
Marc-André Bombeck aacfbf5b61 perf(analytics): widen live-fallback cap to 425 days to preserve year-ago summaries
QA Specialist-H2 (v1.4.39): the original 90-day cap on the per-type
live-fallback loop in `/api/analytics` silently nulled `summarize().avg30LastYear`
(year-ago tile) on every fall-through path because the window stopped at
day 90 and the year-ago aggregate reads days 365-395. Widened the cap to
425 days (365 + 30 for the year-ago bucket span + 30 buffer) so the
year-over-year comparison tile stays populated on cold mounts. The slim
slice and rollup-backed comprehensive aggregator already operate on
tighter windows so this only affects the legacy live-fallback path the
fast-path coverage gate misses. Adjusted the W-SINCE test that pinned
the 90-day cap and updated the route's `meta.analytics.bp_aggregate.live_since`
annotation rationale.
2026-05-18 12:55:32 +02:00
Marc-André Bombeck 823764a914 perf(analytics): cap live-fallback findMany at trailing 90 days
The per-type loop inside `buildAnalyticsResponse` calls
`fetchMeasurementSeriesChunked` without a `since` filter, so a single
default-slice request pulls every measurement ever for the user across
15 chunked rounds. The v1.4.38.8 per-type fast-path gate made this
unreachable in the common case, but a regression on those gates would
re-trigger Marc's 74.6 s cold full-slice mount.

- Add `ANALYTICS_LIVE_WINDOW_DAYS = 90` + `liveSince` constant.
- Thread `since: liveSince` into every per-type chunked read.
- Annotate `meta.analytics.bp_aggregate.live_since` (ISO) so the next
  perf-verify can prove the cap is in effect on a live request.

Defense-in-depth only — the rollup fast-path is unchanged because it
never reaches the per-type loop. Worst-case bound drops from ~347 k
rows to ~5 k rows on Marc's tenant (matching the
comprehensive-aggregator + summaries-slice 90-day windows).

Trade-off: `summarize().avg30LastYear` returns null on the fall-through
path; the dashboard's year-over-year overlay shows "no prior data"
until the rollup tier warms. Acceptable on a degraded path that
v1.4.38.8 already made rare.

Tests: new `src/__tests__/api/analytics/since-cap.test.ts` pins the
90-day floor on every per-type findMany, pins the `live_since`
annotate, and pins that the slim slice is untouched. Existing chunked
paging integration test reseeded with timestamps inside the 90-day
window so it still exercises the two-chunk boundary.
2026-05-18 12:22:11 +02:00
Marc-André Bombeck 6885703505 security: HSTS preload + Withings CSP gating + SW URL trust + manifest hygiene
HSTS gains `; preload` so the domain becomes eligible for the
Chromium preload list. Withings CSP host moves from global to a
path-gated entry (mirrors the existing AI-host gating) — closes a
DOM-XSS exfil shape on every non-Withings page. `getClientIp` warns
once per process when `TRUST_PROXY_HOPS` and the XFF chain length
disagree, and exposes a tagged `getClientIpOrTrustWarning` helper so
future callers can route to a tighter universal rate-limit bucket
when the chain looks tampered with.

Service worker now resolves push-notification payload URLs against
`self.location.origin` and rejects off-origin redirects (falls back
to `/`). The German-only offline fallback HTML body was replaced
with a language-neutral icon + 'Offline' wordmark so EN / FR / ES /
IT / PL users don't see German on a cold offline boot. Manifest
gained `"scope": "/"`, `"id": "/?source=pwa"`, and
`"display_override": ["standalone"]` so future sub-path deploys
don't accidentally inherit root scope and install-prompt heuristics
anchor on a stable id.

+16 new test cases across two new files covering HSTS preload, CSP
gating on/off, the once-per-process warning, the tagged helper's 4
shape branches, SW same-origin guard, offline-locale-neutrality, and
manifest hygiene. Existing proxy/SW suites stay green.
2026-05-17 00:33:34 +02:00
Marc-André Bombeck 2acb11f80e feat(auth): admit /.well-known/* without a session
Adds the `/.well-known/` prefix to the proxy's `PUBLIC_PATHS` allowlist so
RFC 8615 discovery endpoints reach a 200 response before the auth gate
runs. Apple in particular reads
`/.well-known/apple-app-site-association` without credentials to wire Web
Credentials (passkey sharing) and Universal Links to the iOS bundle, and
the previous 307 redirect to `/auth/login` broke that fetch on every host
the app serves (`healthlog.bombeck.io` and `demo.healthlog.dev` both).

The trailing-slash entry future-proofs the namespace for `/security.txt`,
`/openid-configuration`, and the other well-known endpoints the IETF
registry covers — adding a new file under `src/app/.well-known/` won't
need a second proxy edit.

A regression test pins the allowlist in place: unauthenticated requests
for the AASA file (and a synthetic `/.well-known/security.txt` peer) must
answer 200 with no location header, while a protected page like
`/insights` still 307s to the auth gate.
2026-05-16 18:57:42 +02:00
Marc-André Bombeck 8e5f71b1b9 chore(dashboard): retire the GLP-1 tile
Drop the standalone GLP-1 dashboard tile and its data-fetch route.
The /api/dashboard/glp1 endpoint has no consumer outside the retired
tile (no native client reads it), so the route, the tile component,
its test, the e2e spec, the locale keys, the query-key entry and the
drift-guard assertion all retire together.

The medication detail page and its bidirectional contracts stay
untouched. An opt-in dashboard widget that surfaces the same level
chart will land later in this cycle, anchored on the existing
dashboard-layout toggle list.
2026-05-16 01:00:09 +02:00
Marc-André Bombeck 53c9e43795 feat(geo): send a one-shot admin notification when the offline databases are missing
The runtime gate for the resilient GeoLite2 fallback. On the first
`lookupIpLocation` or `lookupIpAsn` call that has to leave the offline
tier, the resolver checks `offlineGeoReady()` — false if the geo asset
directory carries the `.empty` marker or the City MMDB is absent — and
fires `dispatchLocalisedNotification` to every admin user, exactly
once per process lifetime, with a body that points the maintainer at
the GitHub Actions secrets page where `MAXMIND_LICENSE_KEY` belongs.

Adds the two translation keys
`notifications.admin.offlineGeoUnavailableTitle` /
`offlineGeoUnavailableBody` to every locale bundle, locks them down in
the drift-guard, and covers the latch, the marker, the no-admin path
and the ASN fallback with 8 new vitest cases under
`geo-offline-detection.test.ts`.
2026-05-15 22:15:48 +02:00
Marc-André Bombeck 22b11427e8 feat(i18n): add notification, insights empty-state, and admin carrier keys plus drift-guard
Lands every new translation key referenced by the v1.4.27 buckets B1,
B3, B4 and B5 across all six locale bundles (38 keys per locale):

- dashboard.glp1.tabLevel / tabWeight / tabsAria / rangeStripLabel /
  levelUnavailable / weightUnavailable (B1 — drug-level vs weight tab
  strip on the GLP-1 dashboard tile).
- admin.carrier / admin.carrierUnknown (B3 — IP-address carrier chip
  on the admin login overview).
- insights.emptyState.{bloodPressure,weight,pulse,bmi,mood,medication,sleep}.{title,description,cta}
  (B4 — per-metric empty-state cards on the insights sub-pages).
- notifications.admin.{deployFailedTitle,deployFailedBody,testNotificationTitle,testNotificationBody,reminderCheckMissedTitle,reminderCheckMissedBody,reminderCheckOverdueTitle,reminderCheckOverdueBody}
  (B5 — admin notification messages routed through the localised
  dispatch helper).
- notifications.user.telegramTestBody (B5 — user-facing Telegram test
  message localised to User.locale).

Also retires the now-dead charts.moodLabel1..5 chart-axis label keys:
the mood chart and the mood list now consume the same MOOD_LABEL_KEYS
map from a new src/lib/mood/labels.ts so the chart-axis copy stays in
lockstep with the list (BL-P6-11). Three distinct copy sets for the
same five mood states drifted twice during v1.4.18 / v1.4.25
polishing; the shared resolver locks them together going forward.

New drift-guard test at src/__tests__/i18n-drift-guard.test.ts pins
the GLP-1 tab, admin carrier, insights empty-state, admin/user
notification, and personal-record key sets so a contributor who only
edits one bundle gets a precise failure instead of a 200-line parity
diff. The locale-integrity test continues to assert global parity;
the drift-guard layers on top with call-site-specific coverage.
2026-05-15 17:22:35 +02:00
Marc-André Bombeck d98e85cd03 chore(release): v1.4.26 — privacy policy for App Store submission
Public privacy-policy page at /privacy enumerates every Apple
HealthKit identifier the iOS app reads, names every third-party
sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks
Apple BLOCKER 2 of the iOS App Store Connect submission by giving
the registered Privacy URL a real page to point at.
2026-05-15 07:35:05 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

- Bearer auth fail-closed when a route declares no scope (api-handler)
- Public /api/send Umami proxy now rate-limits 120 req/min/IP
- Proxy nonce generation switched to btoa(...) for Edge portability
- Session-expiry delete race uses deleteMany + catch (no more 500s)
- POST /api/import returns the standard apiError envelope on 422
- Runtime Docker image pins Prisma 7.8 + worker deps to minors
- Notifications page renders a real load-error alert
- Accessibility: switch aria-labels, htmlFor pairing, mobile admin
  Overview link, deep-link anchors on notification settings
- Chart overlays: per-chart comparisonBaseline pref, BMI/bodyFat/
  sleep/steps overlay keys, TrendCard layout tolerates long labels

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00
Marc-André BombeckandClaude Opus 4.7 bbdc3fbd00 style: prettier on W5 reconcile touchpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:30:53 +02:00
Marc-André BombeckandClaude Opus 4.7 6c0c69cbce fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
Six MEDs grouped per the W5 reconcile brief:

- Sec-MED-1: `hl_onboarding` cookie now `SameSite=Strict` (no
  cross-site flow depends on it; sibling `healthlog_session` stays
  `Lax` for the Withings OAuth callback).

- Sec-MED-2: `/onboarding` PUBLIC_PATHS match tightened from loose
  `startsWith("/onboarding")` to exact path + subroute guard
  (`pathname === "/onboarding" || pathname.startsWith("/onboarding/")`).
  A future `/onboarding-export` route can no longer inherit the
  silent-pass-through. Same guard mirrored on the redirect short-
  circuit. Two new proxy tests pin the contract.

- Code-MED-3: lift `berlinDayKey()` from
  `src/app/api/analytics/route.ts` to a shared
  `src/lib/analytics/berlin-day.ts` and route the targets
  sparkline-bucketing through it. The targets route used to bucket
  by UTC `YYYY-MM-DD` so a 23:30-Berlin reading on Tuesday landed
  in Wednesday's bucket. Four new unit tests pin DST + UTC-midnight
  edge cases.

- Code-MED (streaming race): on slow connections SSE `done` fires
  before the invalidate-refetch resolves, producing a 200-500ms
  duplicate-bubble render. Add a 150ms grace window on the
  message-thread that suppresses the persisted twin while the
  streaming bubble is still in flight.

- Code-MED-2: drop dead i18n keys `insights.coach.settings` and
  `insights.coach.settingsTooltip` (W3 removed the cog; the keys
  had zero call sites and would carry confusing legacy if v1.4.23's
  prompt-tuning panel picks a different key shape).

- Code-LOW-5: centralise `["user", "dashboardWidgets"]` query key
  in `queryKeys.dashboardWidgets()` and migrate the three call
  sites (dashboard, insights, settings/dashboard-layout-section).
  Same query-key-collision discipline as `queryKeys.analytics()`.

Closes Sec-MED-1, Sec-MED-2, Code-MED-2, Code-MED-3, Code-MED-3
(streaming race), Code-LOW-5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:04 +02:00
Marc-André BombeckandClaude Opus 4.7 15796c148b fix(auth): move onboarding redirect from AuthShell useEffect to proxy
The post-hydration redirect for users with `onboardingCompletedAt ===
null` caused a brief dashboard flash before the client-side effect
could fire. The redirect now lives in `src/proxy.ts` so it lands on the
first server response.

The proxy runs in the Edge runtime and can't reach Prisma, so the auth
routes (login / passkey-verify / register / password / /api/auth/me)
mirror the DB `onboardingCompletedAt` column into a non-httpOnly
`hl_onboarding` cookie. The cookie is a UX hint, not a security
signal — a user editing it locally only skips the dashboard flash;
they still can't bypass any server check. `/api/onboarding/complete`
clears the cookie so the next navigation drops the redirect
immediately, and `destroySession` + expired-session purge wipe it
alongside the session cookie.

The e2e onboarding-flicker spec can return to testing the actual
incomplete-onboarding case: the proxy reads the cookie (absent for
the fully-onboarded storage-state user), not the mocked `/api/auth/me`
response, so the test now sets `onboardingCompletedAt: null` honestly
instead of pinning it to a non-null value as a workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:29:17 +02:00