Commit Graph
59 Commits
Author SHA1 Message Date
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck dd72634791 Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.

When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.

The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.

The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.

Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
2026-10-07 16:55:25 +02:00
Marc-André Bombeck 1b1da652d3 fix(monitoring): route Umami events to the endpoint the tracker was built with
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.

Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
2026-10-04 15:13:52 +02:00
Marc-André Bombeck 2139b49c47 fix(http): stop reading request bodies of up to 512 MB before authentication
Next reads the body of every non-GET request the proxy matches into
memory before the route runs. The ceiling was 512 MB so the Apple Health
importer could receive whole archives, which let any anonymous POST to any
API path make the server hold that much.

The ceiling is now 1 MB (proxyClientMaxBodySize). The fifteen routes whose
real bodies are larger are left out of the proxy matcher and read their
own body after authenticating; apiHandler gives them the proxy's refusals
(worker-only, demo mode), security headers and a CSP. safeJson now counts
bytes while it reads and stops at its cap instead of reading everything
first, the CSV and dose-history imports do the same, and a multipart
backup upload is counted while it is read. A test compiles the matcher the way
Next does and checks it skips exactly the listed routes and every route
with a larger body is listed.

Local lab OCR runs its WebAssembly engine in a worker served from
/tesseract/. Those responses, and the /labs and /documents pages that start
the OCR, now carry 'wasm-unsafe-eval' in script-src; no other route does.
2026-09-26 16:31:03 +02:00
Marc-André Bombeck 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
2026-09-15 14:06:27 +02:00
Marc-André Bombeck 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
2026-09-13 14:00:11 +02:00
Marc-André Bombeck 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.
2026-09-11 12:22:47 +02:00
Marc-André Bombeck 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.
2026-09-11 10:57:42 +02:00
Marc-André Bombeck 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.
2026-09-11 10:24:48 +02:00
Marc-André Bombeck 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.
2026-09-11 09:26:10 +02:00
Marc-André Bombeck 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.
2026-09-11 09:09:46 +02:00
Marc-André Bombeck 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.
2026-09-11 09:08:07 +02:00
Marc-André Bombeck 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.
2026-09-10 18:19:34 +02:00
Marc-André Bombeck 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.
2026-09-10 11:03:36 +02:00
Marc-André Bombeck 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.
2026-08-23 14:06:37 +02:00
Marc-André Bombeck 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
2026-08-13 12:08:59 +02:00
Marc-André Bombeck 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
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck 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.
2026-07-24 02:28:51 +02:00
Marc-André Bombeck 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.
2026-07-23 17:15:21 +02:00
Marc-André Bombeck 9adeb8fc19 fix(runtime): harden process startup and health 2026-07-21 00:05:03 +02:00
Marc-André Bombeck 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.
2026-07-19 13:31:29 +02:00
Marc-André Bombeck 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.
2026-07-19 10:59:40 +02:00
Balaji Vijayakumar 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.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck 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.
2026-07-09 21:25:25 +02:00
Marc-André Bombeck 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.
2026-07-07 18:43:13 +02:00
Marc-André Bombeck 03058b0210 Merge branch 'feat/vault-w4-qa' into release/v1.27.17 2026-07-07 15:10:45 +02:00
Marc-André Bombeck 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)
2026-07-07 12:57:12 +02:00
Marc-André Bombeck 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
2026-07-07 12:34:26 +02:00
Marc-André Bombeck 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.
2026-06-28 00:52:04 +02:00
Marc-André Bombeck 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.
2026-06-27 21:49:53 +02:00
Marc-André Bombeck 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
2026-06-27 20:37:35 +02:00
Marc-André Bombeck 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.
2026-06-27 14:27:09 +02:00
Marc-André Bombeck 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.
2026-06-27 13:47:24 +02:00
Marc-André Bombeck 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.
2026-06-19 22:03:46 +02:00
Marc-André Bombeck 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.
2026-06-14 19:34:14 +02:00
Marc-André Bombeck 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.
2026-06-14 19:32:19 +02:00
Marc-André Bombeck 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.
2026-06-11 00:10:01 +02:00
Marc-André Bombeck 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.
2026-06-07 23:33:19 +02:00
Marc-André Bombeck 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.
2026-06-04 01:40:27 +02:00
Marc-André BombeckandClaude Opus 4.8 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>
2026-06-04 01:39:58 +02:00
Marc-André Bombeck 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.
2026-06-04 01:21:42 +02:00
Marc-André Bombeck 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)
2026-06-02 17:01:14 +02:00
Marc-André BombeckandClaude Opus 4.7 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>
2026-05-28 22:39:20 +02:00
Marc-André BombeckandClaude Opus 4.7 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>
2026-05-27 00:04:54 +02:00
Marc-André Bombeck 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.
2026-05-17 00:39:58 +02:00
Marc-André Bombeck 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.
2026-05-16 19:46:43 +02:00
Marc-André Bombeck 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.
2026-05-15 22:59:10 +02:00
Marc-André Bombeck d98e85cd03 chore(release): v1.4.26 — privacy policy for App Store submission
Public privacy-policy page at /privacy enumerates every Apple
HealthKit identifier the iOS app reads, names every third-party
sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks
Apple BLOCKER 2 of the iOS App Store Connect submission by giving
the registered Privacy URL a real page to point at.
2026-05-15 07:35:05 +02:00
Marc-André Bombeck ca03c6099f chore(release): v1.4.24
Security + accessibility hardening release. Closes the highest-priority
findings from a focused security audit of the v1.4.23 release:

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

Verification: pnpm typecheck && pnpm lint && pnpm test (2244/2244)
&& pnpm format:check all green.
2026-05-12 22:33:35 +02:00
Marc-André BombeckandClaude Opus 4.7 6c0c69cbce fix: MED-cluster — SameSite=Strict, exact-match PUBLIC_PATHS, Berlin tz, streaming race
Six MEDs grouped per the W5 reconcile brief:

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

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

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

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

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

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

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

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