Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.
Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.
State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.
Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
Two second-factor gaps in the OIDC callback:
The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.
An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).
Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.
The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.
Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.
Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
Login only wiped the in-memory query cache; the IndexedDB query snapshot
and the service-worker data/page caches from the previous account stayed
on disk. Logout and the 401/expiry redirect already drop all three. Run
the same offline-cache wipe on a successful login too, before the new
user's dashboard prefetch warms, so a fresh sign-in on a shared browser
inherits nothing from the prior session. The per-account restore guard
still stands; this removes the stale data at the source.
Settings shell: move focus to the section heading when the active section
changes within the mounted shell, so keyboard and screen-reader users are
placed in the new content instead of being left on the nav link they
activated. The first mount is skipped to avoid an unexpected focus move on
a deep link. Raise the inactive sub-tab text from muted-foreground to
foreground/70 so it clears the WCAG AA contrast floor over the tinted
sub-tab strip.
The root QueryClient is created once and survives every client-side
navigation, so logout then login on the same browser left the previous
account's non-user-scoped health-data queries in memory and the next
account read them before any refetch landed. Wipe the entire in-memory
cache on logout, on session expiry, and at the start of a fresh login,
alongside the existing IndexedDB and service-worker cache wipe, so no
session ever inherits another account's data.
Add isError branches with a retry affordance to the mood, illness, and
vorsorge lists so a failed read no longer renders as "no entries yet"
and invites duplicate re-entry, matching the labs and cycle pattern.
Make the admin backups table reachable on a phone with a card fallback,
name the admin language and timezone selects and the AI raw-data switch
for screen readers, and replace the touch-unreachable title= help on the
glucose clinical panels with a tap-capable info popover.
Route the password-strength label through the AA-tuned semantic tokens,
underline the register login link so it is not distinguished by colour
alone, give the labs load error alert semantics, and explain why the
target editor Save button is disabled with aria-invalid and a live
alert.
When a password is accepted for an account with a second factor, the
login response carries a single-use ticket instead of a session. The
login page now renders a second step: a TOTP or recovery code, or a
WebAuthn security-key assertion scoped to the account's registered keys.
A WebAuthn-only account (no TOTP) is recognised at the password step and
offered the security-key path.
The security-key verify endpoints validate the open ticket, verify the
assertion against the user's second-factor credentials, and only then
mint the session through the shared login tail, so no session exists
before the factor passes. The login page also arms passkey conditional-UI
autofill on the username field.
The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
Icons inside buttons drop their manual margins in favour of the
button's built-in gap (two hundred call sites), form field stacks settle
on one spacing step, card titles on exactly two sizes, inline field
errors on one text size, and a dozen hand-rolled loading pulses move
onto the shared skeleton primitive while keeping their silhouettes. The
intake dialog wording joins the rest of the app on 'übersprungen', and
the UI guidelines now document the two deliberate footer and
touch-target systems.
Users can now tell the assistant about themselves in an encrypted
free-text profile that feeds the coach and briefing prompts as clearly
fenced context, with the coach allowed one targeted follow-up question
when the picture is incomplete. Operators on closed registrations can
mint expiring invite links with a QR code from the admin area; a valid
invite opens registration exactly as many times as it allows and records
who redeemed it. A nightly pass watches three deterministic signals —
low adherence, blood pressure above its target band, a falling recovery
trend — and sends at most one coach nudge per week through the existing
notification channels, gated behind the same opt-outs as every other
assistant surface.
Eight touch-disjoint waves landed on develop before this release:
- W-INSIGHTS-HOT + W-RECONCILE-INSIGHTS — extract the timeout-stub
persist helper and wire all seven *-status routes through it, closing
the recurring ~14 s warm response the iOS client sees on dashboard
open once the AI provider has stalled once that day.
- W-DELETED-2 — close the remaining three soft-delete reader-tier gaps
(export bundle, gamification achievements, doctor-report aggregator)
with a five-route integration-test pin.
- W-ORG — types consolidation into src/types/analytics.ts and
src/types/backups.ts; prompt directory unification.
- W-FRONTEND-FACTORY — queryKey factory expansion + ESLint rule lift.
- W-IOS-COORD — POST /api/auth/check-user four-branch onboarding
discovery, identifier matched verbatim, per-IP rate-limited.
- W-SIMPLIFIER — today-intake projection helper extract; unused tx?
params dropped; 13 knip-flagged dead exports trimmed.
- W-PERF-OPS — v1.4.39 W-SUM legacy-NULL UNION discovery arm retired.
- W-PROCESS-DOCS — pg.Pool sizing operator doc; ESLint rule
formalisation; knip CI gate staged for v1.4.42 flip.
No migration, no env-var change, no API contract break for iOS v0.5.4.
The new check-user endpoint is an additive surface. AP-2 .p8 key is
installed; SB-5 time-sensitive APNs is live as of this deploy.
pnpm test --run green at 4732 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip green.
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.
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.
Phase A5 / B-mobile MEDIUM: at 393px the login screen's two CTA
buttons ("Sign in with Passkey" / "Sign in with password" / "Login")
were rendered at default size — `h-9` (36px) — below the WCAG 2.5.5
44px minimum for touch targets. Login is the most-tapped flow on a
fresh install; this is the first screen a new user sees on mobile.
Fix: switch the three login buttons to `size="lg"` plus an explicit
`min-h-11` so they reach the 44px floor. The "lg" CVA variant gives
`h-10` natively; the `min-h-11` upgrade keeps us above the WCAG
threshold even if the variant is later tuned for desktop density.
Files: `src/app/auth/login/page.tsx` only.
Findings ID: A5/MEDIUM `/auth/login` — Sign-in button tap-target.
Verification: 853 / 853 unit tests pass; one pre-existing unrelated
failure in `notification-status-card.test.tsx` from B3's
in-progress channel-reliability work is excluded — see B3's report.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five real WCAG 2.1 AA violations the extended axe-core spec caught:
- top-bar.tsx: mobile user-menu DropdownMenuTrigger had no
accessible name on viewports < sm (the username text is
`hidden sm:inline`, leaving an icon-only button). Added
aria-label using the existing nav.userMenu key.
- password-input.tsx: show/hide eye toggle was icon-only, no
aria-label. Added common.showPassword / common.hidePassword
in en + de and bound to the visible state.
- auth/login/page.tsx: "Sign up" link relied on colour alone
(link-in-text-block). Switched hover:underline → underline so
the link is distinguishable without colour.
- page.tsx (dashboard): "Add" dropdown trigger was 32px tall
(size="sm"); on Pixel 5 below WCAG 2.5.5 44px threshold.
Added min-h-11 — desktop visual unchanged because the explicit
height only kicks in if it exceeds the size variant's h-8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
* 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>
HealthLog — personal health-tracking PWA with weight, blood pressure,
pulse, mood tracking, medication compliance, Withings integration,
doctor report PDF export, and Dracula-themed UI.