mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
864566d882d982c06b59542fd01d669d060a78d0
55
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
864566d882 |
fix(mcp): let the consent screen return to the connecting client
After Allow on the MCP OAuth consent screen, Chromium-based browsers stayed on the page although the grant was issued. The page carried the app-wide CSP form-action 'self', and Chromium applies form-action to the redirect that follows the form post to the client's redirect_uri. The authorize route now owns the CSP of its responses and the proxy leaves that exact path alone. The consent page allows form-action for its own origin plus the origin of the redirect URI the route has just validated against the resolved client, and only https or http origins qualify. Every other response from the route keeps form-action 'self' with default-src 'none'. The redirect after the decision is now 303, which tells every browser to follow a form POST with a GET. Refs #983 |
||
|
|
cd44a85537 |
fix(pwa): let the service worker leave an origin HealthLog left
The worker is registered with scope / and serves /_next/static/* cache-first. A self-hoster who stopped HealthLog and started another app on the same port kept getting HealthLog's cached chunks until they unregistered the worker by hand. The proxy now marks every page response with a presence-only X-HealthLog-Page header. When a successful navigation arrives without it, the worker stops handling requests for the rest of its life, deletes its own caches and unregisters, and hands the page through untouched. A network failure keeps the offline fallback, and a redirect or error status is never read as another app, so a gateway in front of a stopped HealthLog does not drop the Web Push subscription. Refs #847 |
||
|
|
45599c527e |
fix(demo): the profile route writes only the baseline fields in the demo
Allowlisting `PUT /api/auth/profile` so the setup flow's baseline step could finish also handed every visitor the shared demo account's contact email, its display name, its full name and its insurer fields: the allowlist admits a path and a method, and every field the handler behind it writes comes with them. The justification beside the entry named three fields; the entry admitted eleven. The write is narrowed on the server, in `applyProfileUpdate`, to height, date of birth and sex while `DEMO_MODE` is set — not in the client, which is the party the allowlist exists to distrust. It happens ahead of the email conflict check, which would otherwise answer "is this address registered here?" to anybody who can open the demo. The display-name box goes read-only there, as the anamnesis card already is, so nothing accepts input it will discard. The entry's comment now lists what the handler writes and what is admitted. |
||
|
|
97174f470d |
fix(demo): the shared demo account takes no free text and no new record
The demo allowlist had grown two entries it should not carry. The demo is one published account every visitor signs into, so `PUT /api/coach/about-me` was an anonymous write of arbitrary text into the Coach prompt, persisted for every later visitor until the next reseed; it is gone, and the baseline step's anamnesis card renders read-only under `DEMO_MODE` so no visitor meets the refusal as a failed confirm. `POST /api/managed-profiles` bought nothing: the route opens with a fresh-MFA requirement the demo account cannot satisfy, so the guardian arm was limited to finishing without the profile either way. The baseline profile write stays, and the block's preamble now describes what is actually admitted and why the two refusals are refusals. |
||
|
|
3642bbe4de |
Merge wave C — the flow cannot strand, mislead or silently switch off
The getting-started checklist's data queries follow the same setup rule as its visibility, so a settled account stops being told to do what it has done; a failed completion leaves the confirm screen usable; an empty multi-select is no longer recorded as an answer; the confirm screen names the modules a run would take out of the navigation; the settings links it offers work before the first completion; and demo mode can fill the baseline it is shown. |
||
|
|
982c35287d |
fix(demo): the setup flow's baseline writes are allowlisted
A demo visitor who typed a height on the confirm screen got a 403 and a generic toast, and the "someone I look after" arm could only take "Finish without the profile": the profile PUT, the self-context PUT and the managed-profile POST were missing from the demo allowlist, so only the empty-form path completed. All three are added, pinned per method as the rest of the list is. None of them creates health data. |
||
|
|
cfca84a294 |
fix(auth): the invite landing redirects at the edge, no shell, no race
The proxy now answers `/invite/<token>` with a real 307 onto `/auth/register?invite=…` (or a bare `/auth/register` when the segment fails the `hlv_` shape gate), carrying the baseline transport headers plus the no-store / noindex / no-referrer hardening the token-in-path surface has had since v1.17.0. Before this the redirect came from the landing page, which Next delivers in the RSC flight payload rather than as a server 307 — the shell mounted first and raced it. At the edge there is no HTML and no hydration, so there is nothing to race. The page stays in place as the fallback for any path that bypasses the proxy matcher. |
||
|
|
753438a329 |
fix(auth): the invite landing is public in the client shell too
`/invite/<token>` has been public in `proxy.ts` since v1.17.0 and was never added to the client-side gate in `auth-shell.tsx`. Next streams the shell before the landing page's RSC redirect resolves, so the shell classified the route as protected, `/api/auth/me` answered 401, and `router.replace( "/auth/login")` won the race — the operator's invite link landed on the sign-in page it was meant to bypass. The shell now lists `/invite/`, and lists the `/auth/` family as the prefix the edge already admits rather than as two individual pages, so a future auth page cannot inherit the same defect. `PUBLIC_PATHS` is exported from `proxy.ts` so a new parameterised guard can render the shell at every page prefix the edge lets through and fail when the two lists drift apart again. |
||
|
|
6adbee581e |
Apply the review of the setup flow: the child's answers reach the child
"Someone I look after" derived the child's answers onto the guardian's own record. The completion now takes the managed record the profile step just created, checks the same access rule the guardian's modules route applies, and lands the same derivation and dashboard seed on that record through the same record-keyed write; the guardian's own flow is stamped complete and latched without touching its map. Three integration cases pin it, and the child journey reads the child's map through a real switch — it asserted the guardian's map before, which pinned the wrong record. Also from the review: the checklist's visit row read a meta the visits list never publishes and now counts its body; the first-result tile read the measurements list as an array and now shows the reading; the demo allowlist admits the disclaimer and tour writes, with a structural test over every onboarding route file; restart clears the old first result, and the screen trusts the ledger before painting a result as done; the headroom sweep's raw-key check matches the flow's own key shapes; the demo migration script stops copying the dropped column; the questions-to-pass rule and the shell's onboarding-path rule are pure and pinned; the baseline form composes the shared action row; the schema diff loses the unrelated formatting churn. |
||
|
|
021ea8f1ff |
feat(api): let a record answer the setup questions, and act on them
Three writes, all on the caller's own record. `PATCH /api/onboarding/answers` saves one step the moment it is answered, so leaving the flow and coming back resumes where it stopped. It is idempotent because the whole computation is a pure function of the row and the body, including the first-result instant, which is kept rather than re-taken. There is no ordering contract at all: a back button has to work. `POST /api/onboarding/complete` keeps everything it did and gains the confirm screen's job. For a record that answered the questions it derives the module map once and applies it, and the once-only latch is the point: a second confirm would re-apply a questionnaire over decisions taken in Settings since. Cycle is switched on through its own column and never off, because the gate already derives an unanswered cycle from recorded sex and an untick is not a request to retract that. `POST /api/onboarding/restart` puts the steps back and clears the latch. It writes no module state whatever: a re-run never turns off something switched on by hand, and the safest way to hold that is for this route to have no opinion about modules at all. It also leaves the first-run redirect's own stamp alone, so asking the questions again does not push anybody back through the old wizard. All three refuse outright while the session is acting on somebody else's record, at every grant level, so a delegate can never write another record's setup. The route into a managed profile's flow belongs with the rest of that record's configuration and is not smuggled in here. The demo allowlist gains the three: the flow has to be completable there with preset answers, and none of them creates health data. |
||
|
|
87929eb3b8 |
feat(api): answer a retired route with 410 rather than a bare 404
A route removed on purpose still leaves a client calling it. As reported in iOS #94: between 2026-08-08 and 2026-08-20 the native client asked `/api/auth/me/research-mode` on every GLP-1 detail screen, got a bare 404, and painted a permanent error card with a retry button aimed at the same removed path. Twelve days, on every reachable instance including the public demo, which was built after the removal. Deleting the route was right and the removing commit reasons it out in full; the answer said nothing. A 404 from a deployment is indistinguishable from a broken build, a reverse proxy pointed at the wrong upstream, or a typo in the caller's own path, so reading it as transient and retrying was the correct reading of the only evidence there was. One registry, `src/lib/http/retired-routes.ts`, carrying the path, the release that removed it, the replacement where there is one, and one sentence on why. Every other surface reads it. 410 over a 404 with an error code because it is the status whose entire meaning is "this existed here and is gone": a client that understands nothing else about this API still learns not to retry. The code is not dropped, it rides in the same envelope with `removedIn` and `replacedBy` for a client that reads further. The status is the floor, the code is the detail. Answered in the proxy, before routing, rather than by a catch-all route module: a catch-all under `/api` would intercept every unmatched path and make the ordinary 404 ours to manufacture, which is a far wider blast radius than the problem. The proxy already owns the exact-path decisions of this shape. It reads nothing off the request but the path, so an anonymous caller and an authenticated one get identical bytes and the tombstone cannot become an authentication oracle. Seeded from the removed-route set: every route path under `src/app` at every release tag, unioned and differenced against the current tree. Twenty-seven paths have been removed over the project's history; the five registered here are the ones that were published or client-facing, and each removal is pinned to its release by the route's presence at one tag and absence at the next. Whether any client still calls one of them is a separate question that evidence in this repository cannot answer, and the registry deliberately makes no claim about it. |
||
|
|
4543fa7192 |
Let the app CSP frame and render its own blob previews
Since the record fence, vault previews load through object URLs: the detail sheet frames a decrypted PDF from a blob: URL and thumbnails render from blob: images. The app CSP carried no frame-src, so the PDF iframe fell through to default-src 'self' and was blocked outright, while img-src 'self' data: silently degraded every thumbnail to its fallback icon. Add frame-src 'self' blob: and extend img-src with blob: in both the dev and prod policies. This admits exactly the app's own documents: a blob: URL is mintable only by same-origin script, script execution stays nonce-bound in production, and object-src 'none', frame-ancestors 'none' and default-src 'self' are untouched. The vault serve route keeps its stricter document CSP. A unit test pins the directives so a future CSP edit cannot silently regress the previews. Refs #787 |
||
|
|
d0b45e36b3 |
feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
|
||
|
|
0b3280c604 |
chore(release): v1.32.11 — native web-handoff login for self-hosted iOS
Self-hosters can sign in to the iOS app through their own domain, so the
password manager files credentials under the right site and passkeys run in
the correct origin. The app opens the instance's own web login inside an
ASWebAuthenticationSession; after an interactive login the server mints a
single-use, PKCE-locked handoff code on a compiled-in custom scheme, which the
app exchanges for the standard native token bundle. The token pair never rides
a URL — only the opaque code does.
Three endpoints mirror the OIDC native handoff and share its mint/consume core,
now extracted to native-handoff.ts (the OIDC leg keeps working unchanged, as
the default flow):
- GET /api/auth/native/login validates the PKCE challenge, sets a
short-lived encrypted state cookie, and
redirects to /auth/login?flow=native.
- GET /api/auth/native/complete validates the web session, enforces the
freshness binding, mints the code, and
redirects to healthlog://login-callback.
- POST /api/auth/native/token exchanges the code under the cookie-less
native transport gate for the native bundle.
The freshness binding refuses any session older than the flow start, so a
pre-existing browser session can never silently complete a handoff. The start
time is stamped from the database clock (SELECT now()) to stay comparable to
Session.createdAt with no app/DB skew, and createdAt is read through a
dedicated projection so the comparison cannot be silently dropped. A flow
discriminator on the handoff row (migration 0268) makes an OIDC code
non-redeemable at the web-login route and vice versa.
The callback scheme is a compile-time constant with no redirect_uri parameter,
requireAdmin and requireFreshMfa stay cookie-only, and the exchange mints no
wider scope than password login. The state cookie is AES-256-GCM with a fresh
IV. Adds the flow=native login-page banner in all six locales and the three
routes to the OpenAPI contract.
|
||
|
|
b1e36d9fb0 |
reconcile: fold in the outstanding hardening and data-integrity fixes
Merge the eight reviewed audit fixes that were cut a week ago and never landed. Main has moved on since, so each fix was re-checked against the current tree and each of the five conflicts resolved by understanding both sides. Kept, still open on main: - Proxy early exits now carry the transport security header subset via a shared helper. Reconciled with main's /api/health worker-only carve-out. - Batch measurement ingest bounds the entry instant so a future-dated row can no longer win "latest reading" or poison the rollup buckets. - Web Push dials through the pinned safeFetch dispatcher with requirePublicHost, closing the last user-controlled-host egress. - Mood bulk upsert and its tag/factor links run in one transaction, so a rejected rating rolls the mood row back instead of leaving a tagless row. - Full backup carries NutrientIntakeDay with its unit and source. Folded into main's portable-export / disaster-recovery split. - Live daily-series fallback restores the min/max spread band the tiered read already emitted. - Coach stream aborts its in-flight reader on unmount and resets send state on a conversation switch. - Manual water writes share the day-bound helper, nutrient add clamps after the increment, and the thiamin cap drops to a real supplement ceiling. - Compliance point window widens symmetrically instead of wrapping overnight. - Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx body as a handled transient error. - WebAuthn relying-party fallback to localhost is gated to development. - Chart-token strip list is derived from the allowlist and pinned by a test. - Regression tests pin the snapshot-cache tenant key, the GlitchTip path-secret redaction and the Withings credentials delete. Superseded by main, tests re-pointed at the current shape: - The intake CSV import was rewritten on main to run through a background job with a single-active-job admission gate. The future-date bound is re-applied at request validation and the per-user limiter is kept for parity with the bulk twin; the old synchronous-write test is replaced. |
||
|
|
9adeb8fc19 | fix(runtime): harden process startup and health | ||
|
|
8ead442d44 |
fix(proxy,auth,insights): close three drift gaps the audits left open
The proxy's seven early exits — worker-only 503, two legacy 301s, the demo-mode 403, and the login / onboarding / MFA redirects — returned before the security header block, so they carried no HSTS, no framing refusal and no nosniff. Factor the transport subset into a helper and apply it on every exit. The per-route CSP stays where it is; it is nonce-bound and only meaningful for a response with a body. The WebAuthn relying-party candidate list always carried http://localhost:3000, including in production. The signed rpIdHash still pinned the real domain, so this was never a reachable bypass, but a configured production origin has no reason to carry it. Gate the fallback on development, keeping it when nothing is configured so getRpId() still resolves. ORPHAN_ENUMS was a hand-maintained literal that drifted every time ALLOWED_CHART_TOKENS grew — eighteen allowlisted metrics had no strip entry, so a model writing 'Your PHQ9_SCORE is elevated' leaked the raw enum into rendered prose. Derive it from the allowlist and pin the coupling with a test. |
||
|
|
860cb61b0b |
fix(proxy): pin no-store on every session-gated page
The dashboard, Insights, the workouts list, the medications list and the Coach server-prefetch their data and serialise one account's health record into the HTML document through a TanStack HydrationBoundary. Those pages are dynamic today only as a side effect of reading the session cookie — a property of the page code, not a guarantee. A stray revalidate export, a force-static segment, or a caching proxy in front of the app would be enough to retain one account's record and hand it to the next caller. Stamp `Cache-Control: private, no-store` at the edge instead, driven by the same predicate that governs the auth redirect, so a page cannot be session-gated without also being uncacheable. Public legal, auth and locale-catalog surfaces keep their cacheable posture; API routes keep managing their own caching. Two guards: the proxy test pins the header on gated pages and its absence on the public ones, and a structural test walks the app tree and refuses a prefetching page that sits on a public path or opts into static / revalidated rendering. |
||
|
|
f815e4a83f |
feat(auth): add OIDC SSO login
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. |
||
|
|
75eb0637e6 |
feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so each self-hoster registers their own app and pastes the client id/secret into Settings (falling back to a shared env app). Strava is a workout source only — it exposes no daily metrics and no training-depth analytics are derived. - Schema (migration 0233): per-user encrypted client id/secret + access/refresh tokens, plaintext athlete id, incremental cursor, and backfill marker on `User`; `STRAVA` added to `MeasurementSource`. - Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on the stored refresh ciphertext, because Strava rotates its refresh token on every refresh. - Client over safeFetch: authorization-code + refresh, activity list + budget- aware per-activity detail (for calories/HR), honouring the 200/15min cap; each activity maps to one `Workout` row. Activity name/description stay in metadata as data, never surfaced or interpolated. - Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots into the workout ladder below the device-native wearables and above MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row. - Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the integration-sync registrar. Webhook deferred. - OAuth routes (connect/callback/credentials/disconnect/status/test) with signed state, cross-account re-auth rejection, and a surgical `www.strava.com` connect-src gated to the Strava surfaces. - Settings card + consolidated status envelope entry; env + compose whitelist; i18n across all six locales. - Garmin: no direct connector is possible (business-partner-only). Add docs/integrations/garmin.md documenting the Apple Health / Google Health Connect path and Garmin's withheld proprietary scores, cross-linked from both platform docs, plus a non-connector info note in the Connections panel. Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value. |
||
|
|
844dc8457d |
feat(share-links): attach documents and serve them through the share token
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded documentIds set; validate each id as the caller's own live document and create the membership rows in the same transaction (frozen write-once). Summary DTOs gain documentCount (never ids, never bytes). Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved (never requireAuth), passphrase-gated via the token-scoped unlock cookie, revocation/expiry re-checked before any decrypt, confined to the link's frozen document set (foreign/deleted id → flat 404), fail-closed decrypt, identical serving-class posture as the owner route (inline Class A / attachment Class B, nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate limit. Carve the serve path out of the proxy's blanket DENY posture with the same document CSP as the owner route (default-src 'none'; frame-ancestors 'self'), anchored to exactly two dynamic segments so no lookalike over-matches. |
||
|
|
03058b0210 | Merge branch 'feat/vault-w4-qa' into release/v1.27.17 | ||
|
|
f9ca7fef79 |
fix(proxy): allow same-origin framing of the document serve route
The blanket X-Frame-Options: DENY + frame-ancestors 'none' refused the detail preview's same-origin <iframe>, and the middleware page CSP clobbered whatever the serve route set — the inline PDF preview rendered as a permanent skeleton. Carve out exactly the /original path: SAMEORIGIN framing, frame-ancestors 'self', and a document CSP of default-src 'none'. Deliberately NO sandbox directive and no sandbox attribute on the preview iframe — Chromium force-downloads PDFs in sandboxed documents instead of rendering them (verified against a real Chromium channel; headless test builds ship no PDF viewer at all). The load-bearing boundaries for user-uploaded bytes are unchanged: magic-byte classification at upload, true Content-Type + nosniff at serve, HTML/SVG denied entirely, attachment-only posture for every non-passive format. Tests pin the carve-out and that every neighbouring path keeps the DENY posture. Plan-Task: W2-T3 (preview integration) |
||
|
|
e24db8a889 |
perf(i18n): stop shipping two full catalogs on every page load
Two independent every-load costs, one per layer: 1. The static EN import in load-locale.ts put the full en.json catalog (334 KB raw / ~106 KB gz) into the client JS of EVERY route, for every user in every language. No catalog is statically imported by client code anymore: the EN fallback floor of the t() chain loads lazily on the first actual key miss (the locale-integrity guard keeps every locale key-complete, so the miss path is drift-only), and the labs biomarker canonical-name lookup fetches EN on demand. 2. The root layout serialized the ENTIRE active-locale catalog into the RSC flight payload of every document (392 KB of a 505 KB dashboard HTML, re-downloaded on every hard load, uncacheable). The handoff is now a versioned, immutable-cacheable boot script (/i18n/<locale>?v=<build>, new route handler) that assigns self.__HL_I18N before the hydration scripts run, plus a server-side cache seed in load-locale.ts behind a typeof-window branch that Turbopack compiles out of the browser bundle (verified: zero catalog bytes in any client chunk, zero route manifests referencing one). SSR text stays correct and the first client render holds the same bundle the server rendered with — no EN flash, no hydration drift. The boot script is public (proxy allowlist: the login page needs it pre-auth; the body is the same catalog JSON that ships in the public repo) and cache-first in the service worker, so an offline relaunch finds it next to the cached shell. Measured (client-reference manifests, gzip): route JS baseline / 391->287 KB gz, /insights 377->273, /measurements 379->275, /insights/mood 394->290 — the ~104 KB gz EN catalog left every route. H2 |
||
|
|
fb986bc09d |
merge: bring the MCP server onto the v1.23 base
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read tools + deep-value reads + a confirmed, audience-bound write surface) with the v1.23 account-security line (MFA, known/trusted devices, free-text note encryption). - schema: keep every v1.23 User relation (MFA, known/trusted devices) alongside the McpOAuthConnection relation; the MeasurementSource enum carries the MCP value. - api-handler: the MCP audience guard (a token scoped to health:read/health:write is bound to /mcp and refused a REST write) and the step-up MFA helpers coexist. - maintenance queues: register the MCP token-cleanup sweep next to the note-encryption backfill and key-rotation queues; shift it to 04:00 so the two daily discovery ticks do not collide. - migrations: renumber the MCP migrations to 0206/0207 so they follow the v1.23 chain (0199-0205). - MCP mood writes route the note through the v1.23 at-rest cipher. |
||
|
|
c8bef61344 |
feat(security): let an operator require a second factor
Wire the previously-unused mfaEnforced flag into a real policy. An admin can require a second factor instance-wide (an AppSettings toggle in the General section) or per user (a toggle in user management); the effective requirement is the OR of the two. When required and the account has no active factor — no confirmed TOTP, no registered security key, and no primary passkey — the user is walled behind a forced-enrollment interstitial after sign-in, with only the security settings and sign-out reachable until a factor is enrolled. The gate is surfaced like the onboarding gate: a hint cookie the proxy reads without a DB round-trip, recomputed from the database on every sign-in and on /api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the enrollment surface stays reachable, and toggling the policy off (or the disable-mfa CLI) is the operator's recovery path. |
||
|
|
de33f1ad4c |
chore: remove the bug-report integration
Drop the in-app bug-report surface end to end: the user submission page, the feedback intake and admin inbox, the optional GitHub-issue escalation, the operator config, and the dedicated achievement badges. - delete the /bugreport page and the bugreport / feedback / admin-feedback API routes - drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205) - remove the admin Feedback section, the Bug Report integration card, and the system-status row - drop the bug-report nav entry and the app-settings provider that only carried its flag - remove the bug-hunter achievement badges and their metric - prune the related i18n keys across every locale and the encrypted-column and key-rotation registries |
||
|
|
7ec3bf0dad |
feat(mcp): add an OAuth 2.1 authorization-server bridge for remote connectors
Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_` Bearer model — no parallel identity store. - Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization Server Metadata at `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` (added to the proxy allowlist). - `/api/mcp/oauth/authorize` — consent gate backed by the existing session; a code is only issued on an authenticated POST decision, never on a GET. - `/api/mcp/oauth/token` — authorization-code and refresh-token grants with mandatory PKCE S256, single-use codes, and rotating refresh tokens. The access token is an ordinary short-lived `hlk_` token scoped `health:read`. - `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the preferred path and resolves client metadata through `safeFetch`. - Audience binding (RFC 8707): every token is bound to the canonical `/mcp` resource URI; a mismatched `resource` is rejected at authorize and token. - The grant artifacts (code, refresh, DCR client id) are HMAC-signed, self-describing, and domain-separated; the central log redactors scrub them plus the PKCE verifier. |
||
|
|
03d41bc5c0 |
feat(mcp): serve the read-only tools over remote Streamable HTTP at /mcp
Bridge the existing transport-agnostic MCP server factory to a Next.js route at /mcp using the SDK's Web-standard Streamable HTTP transport, so a remote client can connect with a manually-minted hlk_ Bearer token. The same tool/resource layer the local stdio transport serves is reused verbatim — no duplicated tool logic. The route owns four gates in front of the transport: - Auth: Authorization: Bearer hlk_… resolved by the same validator the HTTP edge uses, so revoked/expired/scope semantics never drift. A cookie is never consulted, so the cookie-only admin boundary stays unreachable here regardless of token scope. - Rate limit: the Postgres limiter keyed by the <userId>:<tokenId> binding (mcp:<binding>), so a single shared credential cannot drain the account. - Module gate: a new opt-in mcp module, OFF by default. With it off the endpoint answers 404 and reveals nothing — a self-hoster who never opts in gets zero new exposure. The Modules settings hub renders the toggle. - Transport: a fresh stateless server + transport per request. Read-only by construction (only read tools are registered) and same-origin by construction (no CORS headers). The proxy lets /mcp skip the page cookie redirect since it authenticates by header, mirroring the webhook surfaces. |
||
|
|
5783acacbb |
Gate the public share view behind the passphrase
A protected share link no longer renders the record until the passphrase is verified. The public page checks the gate state first (without bumping access counters), and for a link with a passphrase hash it serves a gate island unless a valid, token-scoped unlock cookie is present. A legacy link with a null passphrase hash renders exactly as before. The unlock route verifies the submitted passphrase with a constant-time compare against the stored hash, is rate-limited by share-unlock token and IP before any compare, and on success sets a short-lived (30 min) httpOnly, SameSite=Strict, Secure cookie scoped to that one token's view path. Every failure class answers one blunt 401; no-store and the flat 404 for unknown links are preserved. The gate island auto-submits the #k= fragment (scrubbing it from history first) or prompts for input. |
||
|
|
03bde8d40c |
Harden invite token-in-path against cache and index leaks
Extend the /c/ edge defence (no-store, noindex, no-referrer) to the /invite/<hlv_token> surface — the invite secret rides in the path, so no CDN may cache it and no crawler may index the URL. |
||
|
|
c1eb618dcc |
Add invite universal-link landing for app deep-link
Encode the admin invite QR as a clean /invite/<hlv_token> universal-link that the iOS app intercepts via the AASA wildcard matcher. Add the matching web route as a thin, shape-validated redirect onto the existing register flow, and open the path on the public allowlist so an unauthenticated scanner reaches it. A malformed token segment is indistinguishable from no invite — no database hit, no enumeration oracle. The token is never logged. |
||
|
|
ce3b4c70c8 |
chore(ops): signed images, a secret gate before commit, and a monthly restore drill
Published manifests are now cosign-signed keyless with a documented digest-pinning path, a lefthook pre-commit runs gitleaks over staged changes, responses opt into a credentialless embedder policy, the deploy webhook can require a fresh timestamp, and a monthly drill decrypts and parses the newest offhost backup so a broken key or format surfaces before an emergency does. |
||
|
|
bea6b9fed4 |
feat(demo): allow chart display-pref toggles under demo mode
The DEMO_MODE block 403s every non-GET /api/* request outside a narrow allowlist, which also caught the above-chart display toggles — the chart-overlay switches and the comparison-baseline selector — even though they only write the caller's own dashboard-prefs blob. Admit PUT /api/dashboard/chart-overlay-prefs and PUT /api/dashboard/widgets in demo mode. The allowlist now pins method per path, so the widgets PUT does not drag in the layout-reset DELETE. The block only runs under DEMO_MODE=true, so production is unaffected. |
||
|
|
dd1071158f |
feat(whoop): OAuth connect/callback + HMAC-signed webhook + CSP gating
Connect mints the state nonce into the WhoopOAuthState ledger and redirects to the WHOOP authorize page; callback verifies the nonce (atomic delete-first consume, four distinguished reason tags), exchanges the code, fetches the WHOOP profile for the user id, persists the encrypted WhoopConnection, and enqueues the self-converging history backfill. The webhook handler rate-limits per source first, verifies the WHOOP_WEBHOOK_SECRET path segment, then verifies the HMAC-SHA256 body signature over the raw bytes (timestamp + body, timingSafeEqual, stale timestamp reject) before any work. Valid *.updated enqueues the matching per-resource sync; *.deleted soft-deletes (workouts hard-delete, no soft-delete column). Unknown users return 200 to stop retries. CSP gates api.prod.whoop.com to the WHOOP settings surface + /api/whoop/*; the webhook path joins the public-path allowlist and the path-secret redact registry. Env wiring adds WHOOP_WEBHOOK_SECRET + WHOOP_REDIRECT_URI to the compose whitelist and .env.production.example. |
||
|
|
f4629a17d6 |
feat(share-links): scrub the share token from logs and harden the /c edge
Register the `/c/` prefix in `PATH_SECRET_PATHS` so the raw `hls_` clinician share token, which rides as the trailing path segment, is rewritten to [REDACTED] in `http.path` / `http.route` before any Wide Event or GlitchTip report leaves the process — the same posture as the Withings webhook path secret. The resolve path never logs, throws, or annotates the raw token. Defend the share surface at the edge irrespective of what the RSC emits: the proxy now sets `Cache-Control: no-store`, `X-Robots-Tag: noindex, nofollow`, and `Referrer-Policy: no-referrer` on `/c/` responses, so no downstream proxy caches a scoped record, no crawler indexes the token, and the token-bearing URL is not leaked in an outbound `Referer`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6aa3c75d27 |
feat(share-links): public clinician view at /c/[token]
A standalone read-only RSC that resolves the path token through the share-token resolver and renders an owner-scoped clinical summary: provenance header, clinical vitals and labs, medications with adherence, and a fenced muted card for the descriptive wellness scores carrying the not-a-clinical-assessment, not-a-diagnosis disclaimer. KVNR is default off. No app chrome, no coach, no AI, no session, no markdown — every value renders as escaped text. force-dynamic and noindex; the page returns a flat 404 for any null resolve. The route joins the proxy public-path allowlist and the AuthShell standalone list so it reaches the page bare without a sign-in redirect. New clinicianView strings land in all six locales. |
||
|
|
d880385635 |
refactor(targets): retire the standalone Targets page
The standalone /targets (Zielwerte) page is retired. Target editing moved inline onto the Insights metric pages in v1.8.6 — each metric's reference panel mounts the editor and writes the same PUT /api/user/thresholds, reading the same /api/insights/targets payload. The page carried a deprecation notice and had already dropped out of the navigation, so removing it is a UI-only change: no data model, API route, or inline editor moves. Remove the page and its tests, the components reachable only from it (target card, per-card coach button, summary header, target prompt / scope helpers, coach-handoff hook), the now-dead /zielwerte→/targets proxy redirect, and the orphaned targets.* i18n keys across all six locales. Repoint the handful of code comments that pointed readers at the page to the inline Insights panel, and prune the two coach gate-inventory allowlists plus the queryKey factory guard root that named the removed files. The reference panel keeps the range bar, consistency strip, status pill, edit sheet, and source-link helpers — those are the live editing surface now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 7f842c5620087127e980fe22a56133135b7c0531) |
||
|
|
ca1c52ffcb |
feat(avatar): self-hosted avatar storage replacing the Gravatar leak
The previous /me payload carried a gravatarUrl built from
SHA-256(email) and pointed every authenticated page-load at
www.gravatar.com — Automattic could correlate the digest against
its known-email table on each request. This release moves the
avatar in-house: the user uploads through POST /api/user/avatar
(multipart, JPEG/PNG/WebP, 2 MiB / 2048×2048 max), the bytes ride
on the User row alongside a content-type and updatedAt timestamp,
and GET /api/user/avatar/{id} serves them from same-origin with
the cache-busting timestamp on the profile response.
The image-header parsing is hand-rolled in src/lib/avatar.ts so
the standalone image does not gain a native dep just to read width
and height. Validation rejects oversize / wrong content-type at
the multipart parse boundary; DELETE clears the row. The bytes
ride pg_dump alongside the rest of the row so the existing weekly
encrypted backup path already covers them.
The sidebar, the coach panel, the use-auth hook, and the disable-
coach card stop reading gravatarUrl; src/lib/gravatar.ts retires.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4b4fb7aaf8 |
feat(security): emit COOP, CORP, and X-Permitted-Cross-Domain-Policies headers
Three additions to the production header set in `src/proxy.ts`: - `Cross-Origin-Opener-Policy: same-origin` isolates the BrowsingContextGroup from cross-origin popups. Closes the `window.opener` reference channel for Spectre-class side-channel attacks. The OAuth flows (Withings, Codex) are top-level redirects rather than popups, so they aren't affected. - `Cross-Origin-Resource-Policy: same-origin` narrows the page's resources to same-origin loaders, complementing the existing `frame-ancestors 'none'` CSP rule for non-document subresources. - `X-Permitted-Cross-Domain-Policies: none` shuts the Flash / Adobe Reader `crossdomain.xml` channel that some scanners still flag. All three land in every response; production-only HSTS stays in its existing `!isDev` block. No CSP changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f5962497d6 |
v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
|
||
|
|
7bef199039 |
v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path. Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container. Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app. The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces. |
||
|
|
4f09c3f862 |
chore(release): v1.4.27
v1.4.27 — Mobile capability and maintainer-finding cleanup. ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop) applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer (graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line release notes attached to the GitHub Release. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
7a47532231 |
feat(admin): redirect legacy section-anchor paths to dynamic routes
Adds a `LEGACY_ADMIN_ANCHORS` map in proxy.ts that 301-redirects any `/admin/section-<id>` path to its v1.5 dynamic-route equivalent. Pure URL fragments (`#foo`) never reach the server, so this only catches path-style legacy URLs (a referrer rewriter could promote a fragment to a path segment, or someone might bookmark a hand-crafted form). The in-app callsites that previously emitted `/admin#section-*` anchors have all been rewritten to use the new routes directly — see the status-card-grid commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d7778c1085 |
feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI
Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; legacy bare-base64 ciphertext still decrypts
under the synthetic "v1" id derived from the existing ENCRYPTION_KEY.
scripts/rotate-encryption-key.ts re-encrypts every encrypted column on
User / WithingsConnection / AppSettings with the active key. Idempotent —
rows already on the active id are skipped.
Why: enables zero-downtime rotation when a key is suspected leaked,
without forcing every encrypted row through a single big-bang rewrite.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens
Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they
explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS,
HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h
access token plus a 60d rotating refresh token in the login response.
New /api/auth/refresh endpoint atomically validates + rotates the refresh
token (one-time-use), revokes the previously paired access token, and
audit-logs every refresh + revoke. Reuse of an already-consumed refresh
token revokes the entire family for the user/device — defence against a
stolen refresh token.
Migration 0025_refresh_tokens adds the table; the only DDL is CREATE
TABLE so it is forward-compatible. Down migration documented at
docs/ops/migrations/0025-down.sql.
Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for
native callers when refresh-token rotation is now table-stakes for mobile
apps. Web flows are unchanged so existing browser sessions keep working.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE
Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image
can run as web-only, worker-only, or 'all' (default — single-container,
backwards compatible). assertSubsystemEnabled() refuses to boot the
reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental
double-cron when an operator splits the deployment.
docker-compose.yml ships the optional app-worker service block commented
out so 'docker compose up' keeps working out of the box; uncommenting it
+ flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale
HTTP horizontally. Healthchecks are isolated per service so neither
startup deadlocks the other; both wait on db: service_healthy.
docs/self-hosting/scaling.md walks through when + how to split.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage
New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps
each user's measurements + medications + intake events + mood entries,
encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated
from the application ENCRYPTION_KEY), and uploads to any S3-compatible
target via @aws-sdk/client-s3.
Wire format documented in docs/ops/backup-restore.md:
magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext
The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30);
operators are encouraged to also configure a bucket-level lifecycle rule.
Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET
roundtrip and returns endpoint + bucket + per-call latency. Credentials
are never returned.
scripts/restore-backup.ts downloads + decrypts a single object so
operators can verify the chain end-to-end without standing up a separate
restore harness.
Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy
the only line of defence against a Postgres volume loss is the in-DB
DataBackup model on the same host. Separate-key isolation means a leak of
the application encryption key does not also expose every user's archive.
Co-Authored-By: Marc-André Bombeck <noreply@anthropic.com>
* fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates
Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that
would have shipped silent data corruption or weakened the threat model:
Encryption + rotation
- scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted`
(column doesn't exist in the schema; the script crashed on the AppSettings
step). Add the three encrypted columns it was missing —
NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth
— so rotating to a new key actually re-encrypts every encrypted row.
Without this, dropping the old key id from ENCRYPTION_KEYS would leave
notification channels and web-push subscriptions permanently
undecryptable.
- src/lib/crypto.ts: refuse to silently fall back to the active key when a
legacy bare-base64 row is decrypted but no v1 entry is configured. Throws
a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS)
and run scripts/rotate-encryption-key.ts before removing it" so an
operator who pulled the v1 too early notices immediately instead of
hitting opaque GCM tag errors.
- docs/ops/encryption-key-rotation.md: full step-by-step + rollback note +
troubleshooting section. Spells out that rotation is a forward-only
operation and what to do if the rollback path is needed.
Web / worker split (G3)
- src/proxy.ts: refuse HTTP traffic with a 503 +
X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker.
The worker container (which still runs `node server.js` for now) no
longer accepts requests, so a misrouted client gets a clear hint instead
of a partially-served response.
- src/instrumentation.ts: short-circuit before importing the reminder
worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy
"[instrumentation] CRITICAL" stderr line we emitted on every web boot.
- /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't
silently 302-redirect through the auth gate (the prior wget treated the
login page as a 200 success and would never notice an unhealthy backend).
Refresh-token rotation (G4)
- src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active
refresh tokens for the user, not only those scoped to the same deviceId.
An attacker who rotated without an X-Device-Id header would otherwise
keep their family alive on the no-device branch. Defence-in-depth.
- revokeRefreshToken() also revokes the paired access token so a leaked
access token cannot outlive the refresh-token sibling that the caller
just killed (logout-on-device).
Off-host backup (G1)
- src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path.
Retention is the bucket's lifecycle-policy job; the worker keeps a
PutObject + GetObject grant only. A compromised worker can no longer
wipe the backup history. Per-user errors are now surfaced as Wide
Event warnings + a structured failures[] array on the report so an
operator can tell which user failed and why.
- scripts/restore-backup.ts: schema-validate the decrypted payload before
writing to disk, plus an optional --user-id=<id> flag that refuses to
proceed unless the dump's userId matches. Prevents silent
cross-tenant restores when an operator points the script at the wrong
S3 key.
Idempotency
- src/lib/idempotency.ts: the leak-guard already blocked `hlk_`
(access-token prefix); also block `hlr_` so a future caller wrapping
the new /api/auth/refresh endpoint with withIdempotency() can't
accidentally cache a freshly-issued refresh token.
Migration / schema
- prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single
transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a
partial-deploy retry is a no-op instead of "relation already exists";
guard the FK ADD with information_schema lookup for the same reason.
- Schema replaces the single-column `@@index([deviceId])` with a
composite `@@index([userId, deviceId])` (every lookup also filters on
userId; the old index would never have been used). Comment on
accessTokenHash corrected to HMAC-SHA-256.
Container / ops
- Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily
pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30
Berlin instead of silently falling back to UTC on the alpine base.
- docker-compose.yml: tighten the healthcheck to `wget --spider` (matches
the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through
BACKUP_RETENTION_DAYS so a custom retention env reaches the container.
- .env.example: document every new env var added in this PR
(HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID,
BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with
comments and the all-or-none semantics for the backup block.
491 tests still green, typecheck clean.
* chore: retrigger CI
---------
Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
|
||
|
|
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> |