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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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`.
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.
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.
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>
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>