Compare commits

...
525 Commits
Author SHA1 Message Date
Marc-André Bombeck 4ed8c8f39b Merge pull request #634 from MBombeck/release/v1.32.31
release/v1.32.31 — the integrations page says what is actually happening
2026-07-25 10:14:50 +02:00
Marc-André Bombeck e657bf928e chore(release): v1.32.31 — the integrations page says what is actually happening 2026-07-25 09:41:58 +02:00
Marc-André Bombeck 10593a11a8 test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.

Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.

The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
2026-07-25 09:41:53 +02:00
Marc-André Bombeck ce74fd52a2 feat(settings): render the sync verdict and per-metric freshness
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.

Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.

Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.

The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.

Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
2026-07-25 09:41:37 +02:00
Marc-André Bombeck de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +02:00
Marc-André Bombeck a1a978f5e6 Merge pull request #633 from MBombeck/release/v1.32.30
release/v1.32.30 — height is entered and shown in the unit you use
2026-07-25 08:38:00 +02:00
Marc-André Bombeck 41c2213fe6 chore(release): v1.32.30 — height is entered and shown in the unit you use 2026-07-25 08:21:57 +02:00
Marc-André Bombeck 7a0e2cb5b2 test(profile): cover the height round-trip, the converted bounds, and both entry surfaces
Sweep every feet + inches pair the fields can offer and every whole-inch
height across the canonical range, asserting the value comes back
unchanged and lands inside the server's window. Assert the metric branch
with toBe so a re-derivation of a stored height cannot slip through.

Render both entry surfaces under each preference: the metric case keeps
the single centimetres box with the canonical guardrails, the imperial
case shows the feet + inches pair seeded from the stored value. Extend
the structural guard so a surface that drops the adapter, hardcodes a
centimetre guardrail, or hardcodes a unit label trips a tripwire.
2026-07-25 08:21:51 +02:00
Marc-André Bombeck 1b41126fb3 feat(profile): enter and show height in the selected unit
Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.

With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.

Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.

The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
2026-07-25 08:21:42 +02:00
Marc-André Bombeck d0d880d4ba docs(changelog): credit the reporter behind the unit-preference fix by name 2026-07-25 08:03:28 +02:00
Marc-André Bombeck 5b32f6ce4c Merge pull request #632 from MBombeck/release/v1.32.29
release/v1.32.29 — a re-scored Fitbit night stops double counting
2026-07-25 06:32:45 +02:00
Marc-André Bombeck fb74b686e3 chore(release): v1.32.29 — a re-scored Fitbit night stops double counting
Stable per-segment sleep keys plus the replace-by-window sweep, a one-shot
repair over the past year, Retry-After handling, and the unchanged-value write
skip. Migration 0270 records which connections the repair has been through.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a08c759763 test(fitbit): cover the stable sleep key, the sweep, and the one-shot repair
Mirrors the Google sleep suites name for name: the mapper keeps its externalIds
across a re-score that re-classifies and renumbers segments, the sweep stays
bounded to the night's window and never touches the fresh keep-set, a null
window issues no delete at all, and a cleanup failure never fails the sync.

The end-to-end fixture runs one night stored under the old keys through
`syncUserSleep` and pins the ordering the heal depends on — sweep first, then
the rescue re-keying the tombstoned rows in place, no parallel insert — plus a
second pass that writes nothing at all.

Also covers the repair's verdict gates (bounded 365-day window, marker stamped
only on a clean run, watermark untouched, dead token and reauth park returning
without a stamp), the unchanged-value skip including the re-classified segment
it must not swallow, and both Retry-After paths.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck ce41d5bf17 fix(fitbit): key sleep segments on their start instant and sweep re-score leftovers
Fitbit re-scores a night after the fact, and the per-segment externalId carried
a positional index plus the stage label. Both moved on a re-score: renumbered
segments minted fresh keys, the previous scoring's rows stayed live because
nothing swept them, and the night total counted both copies from then on.

Key the segment on the stable session anchor plus its own start instant
(`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every
other segment-writing provider already has. The two ship together with the
natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep
tombstones the old-keyed rows first, so the rescue re-keys those very rows in
place instead of inserting a parallel copy or losing the insert to
`skipDuplicates` against the natural unique index.

A one-shot repair queue re-reads the bounded sleep history once per connection
so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only
on a clean run, never touches the sync watermark, and reports the rows it
removed. Nights older than the 365-day backfill horizon keep their old keys and
whatever duplicates they already hold — they can no longer gain new ones.

Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting
once and retrying once, and fails fast above it so a long hold cannot starve the
rest of the poll cohort or outlive a job's expiration. The measurement upsert
skips a write whose payload is unchanged, which takes the hourly overlap off the
delta feed for every Fitbit resource.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a11717e65a Merge pull request #631 from MBombeck/release/v1.32.28
release/v1.32.28 — Oura catches up after a gap, and the last four providers get manual sync
2026-07-25 05:03:46 +02:00
Marc-André Bombeck 40a746b2b7 chore(release): v1.32.28 — Oura catches up after a gap; manual sync for the last four providers 2026-07-25 04:43:18 +02:00
Marc-André Bombeck 0bcbb88db1 feat(settings): put a sync-now button on the four cards that lacked one
A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
2026-07-25 04:43:12 +02:00
Marc-André Bombeck a9f1629f82 feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.

The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.

Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.

The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
2026-07-25 04:43:06 +02:00
Marc-André Bombeck 9bbbeb805c fix(oura): derive the sync window from the newest imported reading
The lookback was pinned at seven days, so a sync that stayed broken for
longer than a week came back with a window too narrow to reach the gap.
The quieter case did the same damage with every signal green: a ring left
unsynced holds its nights in Oura's cloud until the app is opened, every
hourly tick succeeds with empty arrays, and when the days finally arrive
they sit outside the window.

Anchor the window on the newest live Oura measurement instead, clamped
between the existing seven-day floor and a new thirty-day ceiling, plus a
day of overlap. Both failure modes leave the same fingerprint, so one
anchor closes both, and the watermark cannot drift out of step with the
data because it is the data. Tombstones are excluded: the sleep sweep
soft-deletes rows a revised hypnogram orphaned, and one of those must not
pass for a live reading.

The ceiling is thirty rather than the ninety days Oura's guidance would
allow, because the whole batch still rides one transaction. With data
flowing normally the clamp floors at seven and the tick is unchanged.
A connection with no rows at all takes the full month.
2026-07-25 04:42:52 +02:00
Marc-André Bombeck 8af5c6a0eb Merge pull request #630 from MBombeck/release/v1.32.27
release/v1.32.27 — target and threshold panels follow the unit preference
2026-07-25 03:43:29 +02:00
Marc-André Bombeck 7a4e819679 chore(release): v1.32.27 — target and threshold panels follow the unit preference 2026-07-25 03:24:24 +02:00
Marc-André Bombeck c86de7d317 test(targets): pin the target unit round-trip and extend the display guard
Cover the adapter end to end: an imperial seed, a guardrail projected
into the display unit, a save inverted back to canonical, and a reopen
that shows the number the user typed, swept across the whole editable
window at the input's own step. Temperature carries the affine case,
where a difference must take the factor without the offset.

Assert the metric path is the exact identity rather than merely close,
so a stored value cannot drift by a rounding pass.

Extend the structural guard to sweep the three target and threshold
surfaces, and add two tripwires: each must call the preference hook and
convert through the shared adapter. Reintroducing a hardcoded unit or
swapping the hook for a literal now fails the guard.
2026-07-25 03:24:19 +02:00
Marc-André Bombeck bf73755899 fix(targets): follow the unit preference in target and threshold panels
Target ranges stayed canonical while every reading surface moved to the
metric/imperial preference in the previous release, so an imperial
account saw a weight chart in pounds above a target reference in
kilograms. The panel is a coupled display and edit unit: converting only
its display would have corrupted the edit seed and the save round-trip,
which is why it was held back.

Add one adapter that owns every direction of the conversion and route
all three surfaces through it. The reference panel converts the
canonical payload once and hands the converted pair to the range bar,
the status pill, and the editor seed. The editor seeds from the stored
value converted to the display unit, validates against guardrails
projected into that same unit, and inverts the typed number back to
canonical before the PUT.

Guardrails round inward, so the 30 kg floor projects to 66.2 lb rather
than 66.1 lb, which would have inverted below the floor the server
enforces. Absolute values carry the affine offset, differences take the
factor alone. Input step comes from the transform's own precision.

Storage is untouched: thresholds stay canonical SI, and for a metric
account the adapter is the exact identity in both directions, so a
stored value is unchanged bit for bit.
2026-07-25 03:24:10 +02:00
Marc-André Bombeck cfd5cf0e0c Merge pull request #629 from MBombeck/release/v1.32.26
release/v1.32.26 — apply the metric/imperial preference across every reading surface
2026-07-25 00:48:54 +02:00
Marc-André Bombeck 2e5841c337 docs(changelog): date v1.32.26 and note the target-panel unit gap 2026-07-25 00:37:28 +02:00
Marc-André Bombeck a9b2ab2d2b chore(release): v1.32.26 — apply the metric/imperial preference across every reading surface
Surface a self-hoster's report that the unit preference was stored but read by
almost no display. Weight, body composition, waist, and temperature now follow
metric or imperial at entry and everywhere they render, storage stays canonical
SI, and the MCP logging tool converts a known unit hint or refuses an unknown
one. OpenAPI diff is version-only; the wire stays canonical.
2026-07-25 00:35:07 +02:00
Marc-André Bombeck 6cf56ce456 test: mock the unit-display hook in the crosstab, vitals, and MCP suites
The crosstab, vitals, and cycle-summary renders are static server renders with
no QueryClient; the new preferred-unit hook reads through the auth query, so
mock it to a real metric-preference implementation (identity values, canonical
symbols) that matches the render those suites assert. Register the new unit
resolver on the MCP write-tools module mock so the preview path resolves.
2026-07-25 00:34:51 +02:00
Marc-André Bombeck ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00
Marc-André Bombeck 13b88e47f5 fix(insights): convert weight and temperature in the mood/cycle crosstabs and the plateau note
Route the weight and temperature displays in the mood-factor and cycle-phase
crosstabs and the GLP-1 plateau note through the unit transform: absolute phase
averages take the affine conversion, deltas stay factor-only, and the unit
symbol follows the preference. Thread a unit token into the plateau evidence
copy across the six locales so the sentence reads in the user's unit.
2026-07-25 00:16:41 +02:00
Marc-André Bombeck e1ca523e21 fix(insights): route every weight-class and temperature surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
2026-07-25 00:10:53 +02:00
Marc-André Bombeck dd7213db5c fix(measurements): honor the unit preference at entry and in the record list
Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the
temperature offset carried as an absolute-only shift), a single client hook
that reads the preference, and a shared inbound unit-alias resolver. Wire the
manual entry form to label, prefill, and convert at the boundary so an imperial
reading persists in canonical SI, and route the record list value cells and
edit dialog through the same choke point. Close the one write path that could
persist a non-canonical unit: the MCP log_measurement tool now resolves a unit
hint to canonical or refuses it, reusing the resolver the CSV importer already
relied on.
2026-07-24 23:58:45 +02:00
Marc-André Bombeck 14dcab786e Merge pull request #628 from MBombeck/release/v1.32.25
release/v1.32.25 — cap external medication-mirror growth and surface provenance
2026-07-24 23:32:51 +02:00
Marc-André Bombeck bb79d28911 chore(release): v1.32.25 — surface mirrored-medication provenance and cap external mirror growth 2026-07-24 23:18:26 +02:00
Marc-André Bombeck 40aae4b66d chore(scripts): apply prettier to the phantom-med cleanup script
Formatting only; the format:check gate flagged the file. No behaviour change.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck 1007ca3d1c test(medications): cover the mirror growth guard and provenance echo
A 31st new mirror create at the cap returns 422 with the limit_exceeded
errorCode; an idempotent re-post of an existing mirror at the cap still returns
200; a native create at the cap is unaffected and never runs the count. The
list read exposes externalSource as APPLE_HEALTH for a mirror and null for a
native row.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck dbe3c3dc26 fix(medications): cap externally mirrored rows and surface their provenance
A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.

Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
2026-07-24 23:18:14 +02:00
Marc-André Bombeck 6c8e921faa chore(scripts): add read-only diagnose + guarded cleanup for phantom Apple-Health mirror meds
The diagnose script reports the provenance, external-id values, and intake/schedule
counts of medications by name. The cleanup script deletes only name-matched
Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default,
replicating the delete route (category cleanup, token revoke, audit, cache bust).
2026-07-24 23:02:03 +02:00
Marc-André Bombeck 816f517d1b Merge pull request #626 from MBombeck/release/v1.32.24
release/v1.32.24 — isolate Polar collections and log envelope drift
2026-07-24 22:10:37 +02:00
Marc-André Bombeck 4ac39a7052 chore(release): v1.32.24 — isolate Polar collections and log envelope drift
Per-collection isolation for the Polar vitals sync: one unavailable data type
no longer blanks the rest, a failing collection is named on the integration
status, and an unexpected response shape is logged instead of reading as no
data. Bump version, regenerate the OpenAPI version string, add the changelog
entry.
2026-07-24 21:26:35 +02:00
Marc-André Bombeck 1d31c51d01 fix(polar): log envelope-shape drift instead of silently reading it as no data
The collection fetchers degrade an unexpected 200 body to an empty list, so a
Polar-side envelope rename would read as "the user has no data" and stamp a
clean sync while importing nothing. Emit a fixed-shape polar.envelope.drift
breadcrumb (verb, HTTP status, top-level key names only, never values) at each
silent-empty site in the collection fetcher and the exercises fetcher, keeping
the log-and-degrade behavior so a live account stays alive while the gap becomes
visible in the event stream.

Truth up the client docstrings against the verified AccessLink contract: each
vitals list is a bare array or a named wrapper over a server-fixed 28-day window
with no pagination, and exercises is a bare array over the last 30 days of
uploads. Every hourly tick re-reads the full window, so a late-synced device or
a short outage self-heals with no cursor or watermark; history beyond the window
is an upstream cap. No request parameters added.
2026-07-24 21:19:58 +02:00
Marc-André Bombeck 4574155ce6 fix(polar): isolate each vitals collection so one dead read no longer blanks the rest
Restructure the Polar vitals sync from a single Promise.all over the five
collections into a Promise.allSettled over a per-collection spec, each closure
fetching and mapping so a throwing mapper is contained exactly like a failing
fetch. The healthy collections import; a failing one is recorded once as a
partial failure that names it, without stamping success and without rethrowing,
so a single unavailable data type — SpO2 on a watch without the sensor, a
higher-tier cardio-load read an account lacks — stops blanking recharge, sleep,
and activity on every tick.

A 401 is now a per-collection failure classified reauth_required, not a
whole-leg throw; the write-path escape still surfaces unmarked for the poll
boundary to record once. Mirrors the shipped Oura isolation, minus the
token-refresh leg Polar has no path for.
2026-07-24 21:19:46 +02:00
Marc-André Bombeck 60ec39c2be Merge pull request #625 from MBombeck/release/v1.32.23
release/v1.32.23 — honest sync status across the non-sensitive providers
2026-07-24 20:35:23 +02:00
Marc-André Bombeck 43880e8cca docs(changelog): drop an em dash from the v1.32.23 note 2026-07-24 20:08:11 +02:00
Marc-André Bombeck bb631de7c8 chore(release): v1.32.23 — honest sync status across the non-sensitive providers
A failed sync no longer reads as success across WHOOP, Withings, Oura, Polar,
Nightscout, and Strava: a dead WHOOP token stays parked instead of flipping back
to connected, an interrupted history backfill is retried rather than marked
complete, Withings ECG syncs on its own hourly schedule, activity and sleep
write failures surface on the status card, poll-cohort sync errors that
previously vanished are recorded, and Strava history import starts at connect
time.
2026-07-24 20:06:06 +02:00
Marc-André Bombeck e47200505e fix(withings): sync ECG on a schedule and surface write failures
Add an hourly ECG catch-net cron so a watch-only account with no scale pulls its
recordings without a webhook: the queue was registered and bound but never
scheduled, and the handler now grows a cohort arm that walks every connection on
a payload-less tick, mirroring the activity and sleep handlers.

Make the activity and sleep write paths honest. A swallowed activity batch-write
fell through to the success stamp — a green pill over lost data, green forever on
a persistent write bug; it now records a transient failure and refuses the
stamp. A reconcile throw out of either sleep transaction propagated past the
stamp and was swallowed by both callers, leaving neither success nor failure
recorded; the source now records a transient failure and rethrows so the cron
and webhook paths stay honest without changing their warn-and-continue
behaviour.
2026-07-24 20:05:57 +02:00
Marc-André Bombeck 9a69e4e557 fix(whoop): a dead token no longer reports a successful sync
Port the hard-fail ledger the Fitbit transport already carries. A revoked WHOOP
grant makes every resource short-circuit and return zero rows without throwing,
so the cycle read as clean: it stamped success, un-parked the reconnect state,
reset the failure streak, and the hourly cohort hammered the dead refresh token
on every tick. Record the three dead-token null-return paths on the ledger and
fold that verdict into both drivers — the full orchestrator and the per-resource
cron path that runs most often — so a dead-token cycle refuses the success
stamp and the park holds.

Return an imported-plus-failed verdict from the orchestrator and gate the
history backfill on it: an interrupted full-history walk now throws for a
pg-boss retry instead of being stamped complete, and a connection parked at
reauth returns without burning the retry budget. Thread the same verdict into
the one-shot sleep-timeline backfill.

Also catch a write-path throw on both drivers so a persistent measurement-write
failure surfaces on the status ledger instead of leaving a stale-green pill, and
widen the cycle overlap to the seven-day re-score window the other collections
use so a late-revised day's strain and energy are re-fetched rather than
stepped past.
2026-07-24 20:05:41 +02:00
Marc-André Bombeck 8be7458c7a fix(sync): surface poll-cohort sync failures that previously vanished
Introduce a recorded-marker primitive on the integration ledger so a provider
that records-then-rethrows a sync failure is not recorded a second time at the
poll-cohort boundary, while an unrecorded escape (an Oura or Polar write-path
throw, or any future path) lands on the ledger exactly once, correctly
classified and redacted by the source that owns it. Without the marker a
boundary-blind recorder would double-count the failure streak and could persist
a Nightscout token-bearing URL into the stored error.

Give the poll-cohort factory a required per-provider failure recorder and wire
one for Oura, Polar, Nightscout, and Strava.

Nightscout now counts only freshly inserted readings instead of insert plus
re-confirm (the inflated count fired the reminder trigger every tick), records a
transient failure and skips the success stamp when some rows fail to write, and
emits the measurement-arrival spine for its inserts. Correct the docstring that
promised a far-history backfill that does not exist: the hourly window imports
about 48 hours and nothing deeper.

Strava now enqueues its history backfill at connect time, mirroring the WHOOP
and Fitbit callbacks, so deep history imports within the hour instead of waiting
for the next worker restart.
2026-07-24 20:05:23 +02:00
Marc-André Bombeck cf755d32af Merge pull request #624 from MBombeck/release/v1.32.22
release/v1.32.22 — concurrent-edit guard for prefs records, plus the inventory and privacy races
2026-07-24 18:41:56 +02:00
Marc-André Bombeck 574367737f fix(coach-prefs): return the pure default with no token for a never-saved user
The GET spread the optimistic-concurrency `updatedAt` into the response even
when the user had never saved preferences, which broke the strict-equality
contract that a never-saved read is exactly `DEFAULT_COACH_PREFS`. Mirror the
insights-layout never-saved GET: a null `coachPrefsJson` returns the pure
default with no token (the first save is the tokenless unconditional write), and
only a saved row and the PUT response carry `updatedAt`. Update the integration
round-trip to expect the token on the saved GET and PUT while keeping the
`coachPrefsJson` column assertion pure.
2026-07-24 18:28:41 +02:00
Marc-André Bombeck e65390792d chore(release): v1.32.22 — extend the concurrent-edit guard to the prefs records; serialise the inventory and privacy races 2026-07-24 18:08:15 +02:00
Marc-André Bombeck f9afa794e9 test(concurrency): cover the prefs guards, the inventory lock, and the privacy re-check
Add interleaved-writer, tokenless-compat, and malformed-token coverage for the
notification-prefs, modules, coach-prefs, and mood-tag-layout write guards,
including the notification-prefs case where a stale web write must not revert
the app-managed medication flag. Pin the inventory PATCH and DELETE routing
through the advisory lock, and cover the insight-generation privacy re-check on
both the main commit and the reroll arm.
2026-07-24 18:08:01 +02:00
Marc-André Bombeck 3f33a30415 feat(concurrency): echo the base token from the prefs write clients and document it
Wire the notification-prefs cards, the module toggles, the coach-preferences
save, and the two mood-tag manage cards to echo the optimistic-concurrency
token they last read and to reload on a 409 instead of resaving over a newer
change. Document the token and the 409 on the four write endpoints in the
OpenAPI registry via the shared base-token field and conflict-response
fragments.
2026-07-24 18:07:43 +02:00
Marc-André Bombeck 8849f2cecd feat(concurrency): guard the prefs writes and serialise inventory + privacy races
Apply the shared optimistic-concurrency guard (takeBaseToken + guardedUserUpdate)
to the notification-prefs, mood-tag-layout, modules, and coach-prefs write
endpoints so an interleaved edit can no longer silently overwrite a category a
fresher writer just set; a tokenless request keeps the prior unconditional
write. Route the inventory stock-correction PATCH and DELETE through the same
per-medication advisory lock intake consumption takes, so an absolute stock
write can no longer clobber a concurrent dose decrement. Guard every insight
cache commit on the privacy mode still being what the generation read, so a
privacy-mode change is respected by a generation that was already running.
2026-07-24 17:31:34 +02:00
Marc-André Bombeck 5c581e9e58 Merge pull request #623 from MBombeck/release/v1.32.21
release/v1.32.21 — optimistic-concurrency helper applied to insights, medications, and about-me
2026-07-24 16:57:22 +02:00
Marc-André Bombeck 572f5176bd test(concurrency): cover the layout and about-me write guards
Add per-endpoint unit tests for the insights layout, medications layout, and
coach about-me write guards: an interleaved stale-base writer gets a 409 and
changes nothing, a tokenless request still writes (older-client compatibility),
the fresh token is echoed, and a malformed token 422s. Cover the client token
echo, the two 409 dispositions, and the insights read that must keep the token
through the resolver. Refresh the CHANGELOG and the generated OpenAPI spec.
2026-07-24 16:39:39 +02:00
Marc-André Bombeck ab03bc96f1 feat(concurrency): guard layout and about-me writes against a concurrent overwrite
Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.

A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
2026-07-24 16:26:58 +02:00
Marc-André Bombeck df4425babf Merge pull request #622 from MBombeck/release/v1.32.20
release/v1.32.20 — screen the medication-adherence card and background lines like every other insight
2026-07-24 15:46:19 +02:00
Marc-André Bombeck 0317942f1c chore(release): v1.32.20 — screen the medication-adherence card and background lines like every other insight 2026-07-24 15:18:26 +02:00
Marc-André Bombeck f251ae13f8 test(insights): cover the adherence-card screen and the hero-line envelope guard
Compliance card: a dose-change imperative and a fabricated risk figure are
withheld to the deterministic fallback (no model text persisted, updatedAt
stays null), while a clean grounded summary still renders and persists.

Hero lines: a well-formed, truncated, or fenced JSON envelope resolves to the
deterministic lead/template for both the arrival reaction and the coach nudge;
a plain-text line still ships verbatim.
2026-07-24 15:10:24 +02:00
Marc-André Bombeck 0baffe04cd fix(insights): screen the medication-adherence card and background lines uniformly
Route the medication-compliance status card through finalizeStatusSummary so
it enforces the same dose / risk / causal outbound contracts its seven sibling
status cards do, not only the unparseable-envelope withhold it already had. A
dose-change imperative or a fabricated figure is now withheld to the controlled
fallback on this surface too.

Add the status cards' structured-envelope detection to the two background hero
lines (arrival reaction, coach nudge): a brace-led or fenced JSON reply, or a
truncated envelope under the char cap, now resolves to the deterministic lead
rather than rendering raw.

Fold in the residuals from the same sweep: strip a truncated SUGGEST-ACTION
marker from the Coach prose tail, drop an unreachable zero-rate branch in the
adherence fold, and document why the internal conversation summary stays off
the render-boundary screen.
2026-07-24 14:32:21 +02:00
Marc-André Bombeck 1d3963bebf Merge pull request #621 from MBombeck/release/v1.32.19
release/v1.32.19 — Today view refreshes immediately after a write, plus a guard so it cannot regress
2026-07-24 14:13:30 +02:00
Marc-André Bombeck 15b8df11bc chore(release): v1.32.19 — clear the Today view the instant a write lands
A dose logged from another screen marked the Today hero and the dashboard
stale without re-reading them, so returning to the home page kept showing the
old state until the 120 s poll or a hard reload. The daily reads run with
refetchOnMount off, so a bundle invalidation with the default active
refetch-type never touched them while they were unmounted.

Route every intake-affecting medication write through one blessed
invalidateMedicationReads (bundle invalidation plus a forced inactive refetch
of the digest and snapshot): Take all due, the intake edit dialog, the history
bulk/row delete, the dose-history ledger and its add dialog, the lifecycle and
danger-zone actions, and the intake import. Give the Vorsorge reminder
mutations the same daily-rail refresh, bust the biomarker assessment and the
labs-changes read after a lab write, and refetch the daily reads after a
briefing regenerate so the Today lead updates without a reload.

Restore the dashboard-layout concurrency belt: the settings layout query now
always loads its own tokened copy, so a home-page visit can no longer strand a
save without its base token and silently drop the v1.32.16 conflict guard.

Add a structural guard that fails CI when a new site invalidates a daily-reads
bundle without the paired inactive refetch, so this class cannot recur a
fourth time, plus behavioural coverage for the batch take and the Vorsorge
refresh.

Refs #581
2026-07-24 13:56:22 +02:00
Marc-André Bombeck 5c36a9a776 Merge pull request #620 from MBombeck/release/v1.32.18
release/v1.32.18 — a dead Fitbit token no longer stamps a successful sync
2026-07-24 13:19:44 +02:00
Marc-André Bombeck 115c3e3dcd chore(release): v1.32.18 — a dead Fitbit token no longer stamps a successful sync
Hold the Fitbit sync verdict when the token is dead or a write fails, and
verdict-gate the history backfill so a partial first-time import retries
instead of marking itself complete. No schema or contract change; the OpenAPI
version string tracks the bump.
2026-07-24 13:00:27 +02:00
Marc-André Bombeck da8dfe4256 test(fitbit): pin the dead-token verdict and the verdict-gated backfill
New sync-dead-token suite mirrors the Google Health parity test: the three
getValidToken null-return paths each register on the hard-fail ledger, a
successful refresh leaves it empty, and a dead-token cycle returns
{ imported: 0, failed: true } without stamping markSynced or recordSyncSuccess.
Extends the upsert suite with the two write-catch labels (measurements:create,
measurements:update) and the sync orchestrator suite with a write-label
watermark hold. Rewrites the backfill suite to mirror google-health-backfill:
a clean run stamps, a failed verdict throws without stamping, a parked
connection returns without running the sync.
2026-07-24 12:52:17 +02:00
Marc-André Bombeck 25b7ada8bf fix(fitbit): hold the sync verdict when a token is dead or a write fails
A revoked or expired Fitbit refresh token used to fail the cycle's status
ledger and then stamp the same cycle a success: the connection flipped back
to connected, the watermark advanced, and after a reconnect the 24h overlap
could no longer reach the missed days. getValidToken's three null-return
paths (credentials missing, refresh failure, vanished connection) now register
on the per-cycle hard-fail ledger, so the cycle's verdict fails, the park at
error_reauth holds, and lastSyncedAt does not move. The two measurement write
catches do the same, so fetched-but-unwritten rows are re-fetched next tick
instead of aging out of the overlap.

syncUserFitbit now returns { imported, failed }; the manual-sync route, the
poll cohort adapter, and the backfill consume the new shape. The backfill
stamps backfillCompletedAt only after a clean full-history walk: a parked
connection returns without stamping, and a failed verdict throws so pg-boss
retries on the already-configured retry budget.
2026-07-24 12:47:27 +02:00
Marc-André Bombeck 6a0bec7553 Merge pull request #619 from MBombeck/release/v1.32.17
release/v1.32.17 — pair mood correlations on the local logging day (v1.32.12 regression)
2026-07-24 12:31:59 +02:00
Marc-André Bombeck 490963dfd8 chore(release): v1.32.17 — pair mood correlations on the local logging day 2026-07-24 12:17:20 +02:00
Marc-André Bombeck 0ee5081766 test(insights): cover local-day mood correlation pairing
Berlin boundary pairs (previously dropped), the gone New York lag-1
offset, all three metric partners moving, UTC-user invariance, and a
metric-by-metric guard proving the weight x BP and BP-medication joins
stay byte-identical across timezones. Update the mocked aggregates for
the additive weightRawRows field and pin the widened raw pull in the
aggregator test.
2026-07-24 12:11:10 +02:00
Marc-André Bombeck 1a08083436 fix(insights): pair mood correlations on the local logging day
The mood series has been local-day-keyed since v1.32.12, but the metric
partners it joins against (blood pressure, weight, resting pulse) were
still keyed on the UTC calendar day. The join is an exact day-string
match, so for anyone outside UTC an evening reading landed on the next
UTC day and paired with the wrong mood entry — a one-day misalignment
sold as a same-day correlation.

Derive the three mood pairing partners as exact local-day means, keyed in
the user's own timezone via userDayKey (Intl-based, DST-safe). Blood
pressure and resting pulse reuse raw rows already on the request path;
weight rides one additive column on the existing raw-rows query. The
UTC-consistent weight x BP and BP-medication joins are left untouched —
both their sides share one UTC key space and they migrate with the
rollup tier later.
2026-07-24 12:04:40 +02:00
Marc-André Bombeck 1e72d0aea0 Merge pull request #618 from MBombeck/release/v1.32.16
release/v1.32.16 — dashboard layout Save stays authoritative against a concurrent ring write
2026-07-24 11:25:39 +02:00
Marc-André Bombeck ed260f1ed8 chore(release): v1.32.16 — keep a saved dashboard layout from a concurrent ring save
Refs #581
2026-07-24 11:09:44 +02:00
Marc-André Bombeck cd344047dd test(dashboard): cover optimistic-concurrency + the serialised ring/Save writes
Server: a write with the current base token succeeds and returns the advanced
token; a stale base returns 409 and writes nothing (no unconditional fallback);
a malformed base 422s; an omitted base keeps the unconditional write. Client:
the ring payload never carries widgets and threads the base token, the shared
busy gate covers either write, and the Save/ring wiring is source-pinned.

Refs #581
2026-07-24 11:09:30 +02:00
Marc-André Bombeck 69359a609a fix(dashboard): keep a saved layout authoritative against a concurrent ring save
A dashboard layout change could be silently reverted when a hero score-ring
toggle was made around the same moment. Both writes PUT the whole layout to
/api/dashboard/widgets, and whichever reached the server last won, so a ring
write built from a pre-save snapshot overwrote a fresher Save.

Guard the write with optimistic concurrency. The client sends the updatedAt it
based its edit on; the route writes conditionally on the stored row still
carrying that token (updateMany where updatedAt = base) and returns 409 with a
typed body on a stale base, writing nothing. The advanced token rides back on
every GET/PUT response so the client can rebase. No migration: the User row
already has an @updatedAt column.

On the client, the ring PUT carries only its own fields plus the base token,
the Save and ring writes share one mutation scope so they serialise, the Save
button and ring controls disable while either write is in flight, and a 409
refetches the winning layout and asks the user to redo the change.

Refs #581
2026-07-24 11:07:09 +02:00
Marc-André Bombeck 872fe047e3 Merge pull request #612 from MBombeck/release/v1.32.15
release/v1.32.15 — sync Polar training sessions into workouts
2026-07-24 10:31:52 +02:00
Marc-André Bombeck 73594d35f7 chore(release): v1.32.15 — Polar training sessions sync into workouts
Refs #568
2026-07-24 10:18:13 +02:00
Marc-André Bombeck aa850acab8 test(polar): cover exercise sync, mapper parity, and the shared-ledger legs
Parity suite proves a Polar exercise and an equivalent Strava activity map to a
downstream-indistinguishable Workout row (sport bucket, start instant, duration,
distance, calories, HR, null-vs-zero). Regression guard asserts the exercises
request hits GET /v3/exercises with no userId path segment. Dedup ladder tests
mirror the Strava suite (overwrite-in-place, Apple+Polar twin collapses, Polar
above Strava). Composite test proves a failure in one Polar leg leaves the
other leg's result intact on the shared ledger. Defensive-shape tests cover the
four flagged assumptions: numeric-vs-string id, UTC-offset composition (positive
and negative), bare-array-vs-paged body, and a 403 scope error as a soft error.

Refs #568
2026-07-24 10:10:05 +02:00
Marc-André Bombeck 8bb0719c3a feat(polar): sync Polar exercises into Workout rows
Pull the athlete's exercises from the stateless GET /v3/exercises list (last
30 days of uploads, no transaction, no userId in the path) and upsert each into
the Workout table as source POLAR, keyed on (userId, source, externalId) so a
re-fetch or Polar-side re-analysis overwrites in place. A new table-driven
sport map normalises the raw Polar sport labels into the canonical
WorkoutSportType bucket at ingest, mirroring the Strava/WHOOP mappers; the raw
labels and training load ride metadata for provenance.

The exercise leg rides the existing hourly polar-sync cohort tick alongside the
vitals leg, with independent attribution on the shared polar ledger: each leg
is attempted and records its own failure, and neither can mask the other's
result. Polar takes its place in the workout source ladder between Withings and
Strava. No migration and no OpenAPI change: MeasurementSource.POLAR and the
full workouts source enum already exist.

Refs #568
2026-07-24 10:05:18 +02:00
Marc-André Bombeck aeee3d657d Merge pull request #610 from MBombeck/release/v1.32.14
release/v1.32.14 — Coach withheld-figure elision mark and per-message notice, plus provenance reload fix
2026-07-24 09:44:22 +02:00
Marc-André Bombeck 5ad66af565 fix(coach): mark withheld figures with an elision mark plus a per-message notice
Replace the internal "[unverified]" token the grounding guard left in a Coach
reply with the editorial elision mark […], and add a quiet muted caption
under the bubble explaining that some figures could not be checked against the
user's data. The mark holds the slot grammar-safely mid-sentence, before a
surviving unit, and inside a range; the notice carries the meaning. The withheld
count rides the existing provenance envelope (unverifiedFigures) through the
stream, persistence, and reload, and the fenced document-chat path carries a
minimal envelope so its notice survives too. New i18n key across all six locales.

Also fix an adjacent reload gap: provenanceFromJson restored only a subset of the
serialised envelope, so a persisted suggestion card, confirm action card, and the
tool trace were dropped on conversation reload despite the render layer expecting
them to survive. Restore them defensively alongside the new field.
2026-07-24 09:29:20 +02:00
Marc-André Bombeck 0b828e2c5a Merge pull request #609 from MBombeck/release/v1.32.13
release/v1.32.13 — withhold malformed status assessment output instead of rendering raw JSON
2026-07-24 08:08:37 +02:00
Marc-André Bombeck f88546dc2b chore(release): v1.32.13 — withhold malformed status assessment output
Bump to 1.32.13, regenerate the OpenAPI version stamp, and record the
CHANGELOG entry for the truncated-assessment fix.

Refs #608
2026-07-24 07:54:47 +02:00
Marc-André Bombeck 93c0d616bb fix(insights): withhold malformed status output instead of rendering raw JSON
A truncated or malformed { "summary": "..." } envelope fell through the
status-card parser and rendered raw — braces, the summary property name,
quotes, and literal \n — in the Assessment card (resting pulse and the
other per-metric cards). parseSummaryFromContent returned the raw content
on any JSON parse failure, and a truncated envelope with no closing brace
also fell into that path, so partial structured output reached the reader.

Split the read into extractAssessmentSummary, which classifies a completion
as a parsed summary, genuine prose, or an unparseable structured envelope.
A payload that opened like a JSON object but failed to parse (or parsed
without a usable summary) resolves to the controlled fallback rather than
raw text; plain prose that merely contains a brace is still preserved.
finalizeStatusSummary withholds an unparseable envelope so every status
card serves its deterministic stub, and the two direct callers
(medication compliance, derived-score assessment) get the same guard.

Refs #608
2026-07-24 07:45:02 +02:00
Marc-André Bombeck 4f95afa33c Merge pull request #607 from MBombeck/release/v1.32.12
release/v1.32.12 — mood rollups key by local day, not UTC day
2026-07-24 04:04:18 +02:00
Marc-André Bombeck 1fca6c1d25 fix(mood): key mood rollups by the local day, not the UTC day
The persistent mood-rollup writer bucketed on the UTC truncation of
`mood_logged_at` and ignored the canonical per-row `MoodEntry.date`
label. Every coverage-miss fallback keys on `entry.date`, so the rollup
tier disagreed with the fallback and with the day the user logged the
mood: a late-evening or after-midnight local mood landed on the wrong
calendar day, and the label shifted the moment the boot backfill flipped
a user from the live fallback onto the rollup tier.

Key every bucket off the stored `date` label instead — DAY, WEEK, MONTH
and YEAR — with `bucket_start` set to that label's UTC-midnight instant.
The rollup is now byte-identical to the live `entry.date` fallback and
timezone-independent by construction: DST correctness stays where it
already lives, in `moodDateKey` at write time. No consumer changes, no
schema change; every rollup reader already formats `bucket_start` as a
UTC day label.

Migration 0269 empties `mood_entry_rollups` so the boot-time
full-backfill worker re-mints every user's buckets under the corrected
keying; the worker now deletes-then-refolds so it is self-cleaning
against any row left by an older instance. While the table is empty,
reads fall back to the live `date`-keyed walk, which was already
correct, so there is no window where a wrong day is shown.
2026-07-24 03:50:14 +02:00
Marc-André Bombeck 6cf9ad1273 Merge pull request #606 from MBombeck/release/v1.32.11
release/v1.32.11 — native web-handoff login for self-hosted iOS
2026-07-24 02:44:44 +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 d2d79b70a2 Merge pull request #605 from MBombeck/release/v1.32.9
release/v1.32.9 — Coach Guard II: grounding ledger, action ladder, six-locale risk screen
2026-07-23 23:20:16 +02:00
Marc-André Bombeck dece1f89d9 docs(changelog): restore the 1.32.8 heading above its entry 2026-07-23 22:52:53 +02:00
Marc-André Bombeck 6333d4d989 chore(release): v1.32.9 — Coach Guard II (grounding ledger + action ladder)
The Coach output guard now tracks the numbers it was shown across the whole
conversation, so a figure carried forward or grounded in a goal, a schedule,
or a reference band stops being marked unverified, while an invented one is
still caught and a fabricated risk number now blocks in six languages.
2026-07-23 22:50:03 +02:00
Marc-André Bombeck 9c95e713d2 feat(coach): typed grounding ledger + four-rung action ladder (Guard II)
Promote the per-turn magnitude bag to a per-conversation typed Grounding
Ledger built from every source the model was actually shown: this turn's
tool payloads, prior-turn tool figures, coach-memory goals, the medication
schedule, the reference-grounding block, and guided blocks. Assistant prose
is never a registration source, so a figure that slipped a rung cannot
launder into an authoritative value on the next turn.

Complete the action ladder: a reconciled number passes, a reference or
education sentence is exempt across all six locales, a positively-typed
benign number (date, time, year, ordinal, list marker) passes while an
untyped unreconciled one stays on the strip path, and an unreconciled
health figure is soft-stripped.

Gate the dose continuation exemption on the user's actual schedule: a
"keep taking your N mg" is trusted only when N is a dose the user is on;
an off-schedule maintenance dose is caught. Extend the risk and percent
classifiers to fr/es/it/pl to match the EN/DE depth.

No schema migration: the ledger is rebuilt per turn from persisted tool
traces and re-rendered prompt blocks.
2026-07-23 22:49:54 +02:00
Marc-André Bombeck 25393d93fe Merge pull request #604 from MBombeck/release/v1.32.8
release/v1.32.8 — Provenance: transport-derived intake source + diagnostic sync trigger
2026-07-23 21:52:22 +02:00
Marc-André Bombeck 7b765c7659 chore(release): v1.32.8 — carry write provenance on records made through the app
Bumps the version, regenerates the OpenAPI contract and the service-worker
version fallback, and records the release notes for the intake-source and
sync-trigger provenance surface plus the shared hhmm util and migration-number
guard.
2026-07-23 21:36:10 +02:00
Marc-André Bombeck c1bb997de7 test(auth): assert the cookie path returns the derived authMethod
The cookie arm of requireAuth now carries authMethod: "cookie" alongside the
session payload, so the exact-shape assertion expects it.
2026-07-23 21:36:01 +02:00
Marc-André Bombeck 6e46afc1fc test(prisma): guard against duplicate migration numeric prefixes
Two short-lived branches can each grab the next free migration number and
both merge, leaving the tree with two directories sharing a four-digit
prefix. This structural test fails the build the moment a prefix repeats, so
the collision surfaces at PR CI instead of after merge. One historical
collision (0025) predates the guard and is grandfathered — a migration
directory name is the primary key in the applied-migrations ledger, so it
cannot be renamed without orphaning the record on every deployed database.
2026-07-23 21:26:54 +02:00
Marc-André Bombeck 9e6453c51d refactor(medications): consolidate the hhmm-to-minutes helpers into one shared util
Seven near-identical copies of the HH:mm to minutes-since-midnight parser
had accumulated across the scheduling surfaces in two flavours: a strict one
that trusts an already-validated literal and a null-returning one for
unvalidated input. Both now live in src/lib/medications/scheduling/hhmm.ts as
hhmmToMinutes and hhmmToMinutesOrNull, and every call site imports from there.
Behaviour is identical: the per-medication route keeps its malformed-to-zero
fallback as a thin adapter over the shared core, and dose-window re-exports
the strict variant so its consumers and unit suite keep their import site.
2026-07-23 21:25:15 +02:00
Marc-André Bombeck 61a1592665 test(medications,measurements): cover transport-derived intake source and the diagnostic syncTrigger
Route-level tests that run the real apiHandler + requireAuth so the
cookie-vs-Bearer derivation is exercised end to end, not papered over by a
unit mock:

- a cookie POST records WEB, a Bearer POST records API, and a body that
  forges `source` is ignored;
- the dose-history ledger selects and returns `source` per row, null on a
  legacy row;
- a batch carrying `syncTrigger:"background"` rides the ingest annotation,
  a batch without it annotates null, an invalid value is 422, and the field
  leaves the per-row insert/update outcomes byte-identical.
2026-07-23 21:21:21 +02:00
Marc-André Bombeck 27b548ab13 feat(medications,measurements): derive write provenance from transport and surface it
Root-cause the intake source attribution and give the HealthKit batch a
diagnostic sync-trigger, one attribution surface for both.

AuthContext now carries a read-only authMethod ("cookie" | "bearer"),
derived from the same branch that sets the wide event's auth_method. The
medication-intake route maps it to the stored IntakeSource: a Bearer
(native) call records API, a cookie (browser) call records WEB. The other
producer values are untouched — a converge onto a pending reminder row
updates through applyCanonicalSlotWrite, which never rewrites source, so
REMINDER / IMPORT / APPLE_HEALTH provenance is preserved. The value is
never client-asserted, the same posture as userId being narrowed from auth.

The dose-history ledger now selects and returns source per intake row so a
client can label how each dose was recorded. The endpoint gains its first
OpenAPI entry (path plus response schema) so the field lands in the
iOS-consumed contract.

The measurements batch accepts an optional per-request syncTrigger
(foreground | background | push). It is diagnostic-only: recorded on the
measurement.batch.ingest wide event and nothing else — never persisted,
never part of the dedup key, never an input to attribution or how a sample
is stored. Sending it or omitting it produces byte-identical row outcomes.

Regenerated docs/api/openapi.yaml.
2026-07-23 21:15:17 +02:00
Marc-André Bombeck d321391024 Merge pull request #603 from MBombeck/release/v1.32.7
release/v1.32.7 — Coach Guard I: typed grounding, narrowed risk and dose banks
2026-07-23 20:40:32 +02:00
Marc-André Bombeck 9d584806c7 chore(release): v1.32.7 — Coach Guard I (typed grounding + narrowed risk/dose banks) 2026-07-23 20:25:08 +02:00
Marc-André Bombeck a69d0493f8 feat(coach): typed grounding tokenizer + normal-form reconciler + narrowed risk/dose banks
Replace the untyped magnitude bag in the Coach numeric verifier with a typed
tokenizer and a normal-form reconciler, and narrow the outbound risk and dose
banks so correct figures and correct refusals stop being blocked while every
fabrication shape still blocks.

Grounding verifier (coach-prose-grounding.ts):
- Classify each prose number before grading — ISO/written dates, clock times,
  bare years, ordinals, list markers, hyphenated time spans, ranges, percents,
  thousands-separated integers, unit-suffixed measurements. Benign types are
  never graded or stripped; ranges decompose into per-endpoint magnitudes.
- Grade against typed authoritative entries (value + kind + aggregation).
  Sign-insensitive, canonical rounding, ratio to percent in percent space, and
  minutes to hours widen the match only for an aggregate, kinded entry; kind
  scoping stops a weight value grounding a blood-pressure claim.
- Thousands/decimal separators follow the reply language.
- Boundary-safe strip so a flagged token never clips a larger grounded number.

Outbound screen (outbound-screen.ts):
- Risk bank requires an asserted figure or a categorical engine result; a bare
  engine or horizon mention (education/refusal) passes. Spelled-out percents
  count as figures. No refusal-context exemption. EN + DE.
- Dose bank keeps a maintenance-continuation restatement, voided by any change
  stem. Taper suffix fixed.

Route + fenced-chat thread the reply locale into the verifier.
2026-07-23 20:07:03 +02:00
Marc-André Bombeck eed66ae350 Merge pull request #602 from MBombeck/work/audit-foldin-reconcile
release/v1.32.6 — fold in the outstanding hardening and data-integrity fixes
2026-07-23 17:42:15 +02:00
Marc-André Bombeck e5cdd0d483 chore(release): v1.32.6 — fold in the outstanding hardening and data-integrity fixes 2026-07-23 17:24:53 +02:00
Marc-André Bombeck e986b788a2 Merge remote-tracking branch 'origin/main' into release/v1.32.6 2026-07-23 17:24:03 +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 1c82dbc401 docs(changelog): plainer wording in the v1.32.1 to v1.32.5 notes 2026-07-23 16:41:15 +02:00
Marc-André Bombeck 4895726a48 docs(changelog): credit the reporter behind the v1.32.5 OIDC fix 2026-07-23 16:14:03 +02:00
Marc-André Bombeck 3b3df3a1ac Merge pull request #601 from MBombeck/fix/oidc-double-callback
release/v1.32.5 — OIDC sign-in survives a duplicated service-worker callback
2026-07-23 15:36:02 +02:00
Marc-André Bombeck 56b3e66686 chore(release): v1.32.5 — OIDC sign-in survives a duplicated service-worker callback 2026-07-23 15:18:59 +02:00
Marc-André Bombeck 7779dabfed test(oidc): cover the session resolver behind the duplicate-callback path
Integration coverage for `validateSessionFromCookieValue` against real
Postgres: a live secret-backed session resolves to its user, an expired one
and an unknown/forged/empty value return null, and a legacy cuid cookie
resolves only while its row still carries no hashed secret. This is the
hashing + row lookup + expiry the callback's duplicate detection turns on,
which the unit suite stubs out.

Refs #600
2026-07-23 15:16:48 +02:00
Marc-André Bombeck 60d5416cf8 fix(oidc): sign-in no longer fails on a duplicated service-worker callback
A browser SSO sign-in landed on /auth/login?error=oidc_failed even though it
had succeeded. Two layers conspired:

- The service worker enables navigation preload, but its fetch handler returned
  early for /api/ paths without calling event.respondWith(). For the OIDC
  login/callback navigations that left the preload unconsumed, so the browser
  also ran its own default fetch and the callback was requested twice. The
  first request redeemed the single-use authorization code and set the session;
  the second tried to redeem the now-consumed code, failed at the IdP token
  endpoint, and that 307 to the error page is what the user saw.

- Even with the double request gone, a legitimate retry (proxy, double
  navigation) hit the same wall on the server.

Fix both layers:

- The worker settles /api/auth/* navigations itself, consuming the preload
  response (or fetching once when there is none), so exactly one request
  reaches the server. These responses are never cached.

- The callback is idempotent. When the single-use state cookie is already
  consumed but the request carries a database-validated, unexpired session, the
  callback treats it as a duplicate of a completed login and redirects into the
  app instead of surfacing an error. The authorization code is never exchanged
  on this branch, and a consumed/forged/expired state from a caller with no
  valid session still fails closed with the unchanged oidc_state error.

Refs #600
2026-07-23 15:08:31 +02:00
Marc-André Bombeck e39161ad14 Merge pull request #599 from MBombeck/release/v1.32.4
release/v1.32.4 — Coach numeric verifier stops flagging grounded figures
2026-07-23 13:45:40 +02:00
Marc-André Bombeck 15f1d3a2b6 Merge remote-tracking branch 'origin/fix/coach-unverified-false-positives' into release/v1.32.4
# Conflicts:
#	CHANGELOG.md
2026-07-23 13:28:07 +02:00
Marc-André Bombeck 0908fac738 Merge pull request #598 from MBombeck/release/v1.32.3
release/v1.32.3 — native step-up elevations for iOS 2FA management
2026-07-23 13:02:13 +02:00
Marc-André Bombeck 22c92a7e9d chore(release): v1.32.3 — native step-up elevations for iOS 2FA management 2026-07-23 12:48:42 +02:00
Marc-André Bombeck b948f56fec Merge remote-tracking branch 'origin/work/native-stepup-rebase' into release/v1.32.3 2026-07-23 12:42:13 +02:00
Marc-André Bombeck edfe5290c9 docs(changelog): credit the reporters behind the v1.32.1 fixes 2026-07-23 12:35:07 +02:00
Marc-André Bombeck 7bb5e1947c Merge pull request #596 from MBombeck/sec/next-16.2.11
release/v1.32.2 — Next.js 16.2.11 security patch (4 high-severity App Router advisories)
2026-07-23 12:09:15 +02:00
Marc-André Bombeck 938e84a43a chore(release): v1.32.2 — Next.js 16.2.11, closing four high-severity App Router advisories 2026-07-23 11:41:19 +02:00
Marc-André Bombeck a9bb2f42ba Merge pull request #594 from MBombeck/release/v1.32.1
release/v1.32.1 — honest health score, Coach score questions, import & pulse fidelity, three data-loss races
2026-07-23 11:07:41 +02:00
Marc-André Bombeck f48f496c39 chore(release): v1.32.1 — honest health score, Coach score questions, import and pulse fidelity, three silent data-loss races 2026-07-23 10:37:26 +02:00
Marc-André Bombeck 87e690bdcb fix(coach): keep the numeric verifier dormant when no tool returned figures
The previous commit spliced the DATA INVENTORY manifest into the tool-mode
authoritative payload set unconditionally. That made the payload set
non-empty on a turn where the model answered WITHOUT calling any tool
(empty tool results), which flipped the verifier from dormant to active —
grading the reply against an inventory that carries per-domain sample counts
only, never the snapshot's pre-computed averages. A figure the model
correctly narrated from the snapshot (a 30-day systolic average) was then
stripped to "[unverified]" — the exact false positive this work exists to
prevent, and a regression from main's "no tool figures this turn → skip,
prompt-level grounding is the backstop" contract.

The inventory must only WIDEN an already-active authoritative set, never
ACTIVATE it on its own. Gate activation on a real present figure block
(a pinned workout-evidence block or a present tool result) exactly as main
did; when active, the inventory counts still join the set so a plain count
restatement stays grounded.

Adds a route-level regression test (fast suite) proving the verifier stays
dormant when no tool returned figures, plus companion unit tests documenting
that a real tool section grounds the average while a counts-only inventory
does not.
2026-07-23 10:31:38 +02:00
Marc-André Bombeck 0375fadd85 Merge remote-tracking branch 'origin/fix/apple-health-pulse-cluster' into release/v1.32.1 2026-07-23 10:27:20 +02:00
Marc-André Bombeck 0598c90af2 Merge remote-tracking branch 'origin/fix/silent-data-loss-batch' into release/v1.32.1 2026-07-23 10:27:10 +02:00
Marc-André Bombeck ecf2d1a7d5 style: run prettier over the import-worker reconcile addition 2026-07-23 10:01:09 +02:00
Marc-André Bombeck 6715c4a69a observability(intraday-pulse): surface per-shape row counts and HR-bucket write outcomes
Related to #585. Investigated the intraday pulse chart's read path
end to end: the ZIP-import raw-sample fold, the live-sync uploaded
10-min bucket fold, their merge, and the chart's render logic. All of
it treats the two ingestion shapes consistently — the chart has no
shape awareness at all, it paints whatever bucket series it receives.
No read-side or display-side defect explains a systematic density
difference between the two ingestion paths.

What was missing is visibility into the write and read sides that
would let a future report actually be diagnosed instead of
re-investigated from scratch. The batch endpoint's wide-event trace
never broke a skip down by reason, and never distinguished an
HR-bucket write from any other entry in the same request. The
intraday route's own trace surfaced only the final bucket count, with
no visibility into which shape (raw sample rows vs uploaded bucket
rows vs folded hourly rows) a day's data actually came from. Both
traces now carry that breakdown, so the next report of "the chart
looks sparser after a given date" is answerable by grep instead of by
re-deriving the whole read path again.
2026-07-23 09:55:23 +02:00
Marc-André Bombeck fbdeec9204 fix(coach): stop the numeric verifier from flagging correctly-grounded figures
Three confirmed false-positive shapes in findUnverifiedCoachNumbers/
stripUnverifiedNumbers were replacing legitimate prose with the literal
"[unverified]" placeholder even though the underlying data was grounded:

- A correctly-computed delta, midpoint average, or percent change between
  two of a payload's own headline aggregate figures (e.g. "down 3.5 kg from
  your 7-day average") was never itself a raw leaf in any tool result, so it
  was flagged as invented. The authoritative set now also carries the
  pairwise delta/average/percent-change between a SINGLE payload's own
  named aggregate fields (latest, avg7, avg30, avgSys30, allTimeAvg, ...),
  scoped per payload so a weight delta cannot ground itself against an
  unrelated metric's figure.
- The always-sent DATA INVENTORY manifest (tool-mode base context) carries a
  sample count per domain, but that count was never added to the
  authoritative set the way the pinned workout-evidence block already is —
  so a plain restatement of a domain's count ("you've logged 42 BP
  readings") was flagged whenever the model didn't also call that domain's
  retrieval tool in the same turn. The inventory entries now splice into
  toolResultPayloads exactly like workoutEvidence.
- A calendar-date ordinal ("July 21st") or a numbered-list marker ("1. Cut
  back on sodium") tokenised as a bare number and, when ungrounded, got
  soft-stripped mid-word into visibly broken text ("July [unverified]st").
  Prose is now masked for these two narrow, high-precision shapes before
  numeric extraction; the original text used for stripping is untouched.

A genuinely fabricated figure (unrelated to any grounded point, inventory
count, date, or list marker) is still caught and stripped — covered by new
adversarial cases alongside each fix.

Investigated and ruled out: multi-round tool-loop result accumulation (all
present results across every round already reach the verifier); unit
conversion (the app resolves and serves one consistent unit, so there is no
independent conversion step for the model to get wrong). The Daily
Briefing's separate withhold-a-whole-section grounding gate is unrelated to
the reported symptom (a different failure mode, whole-line omission vs.
inline replacement) and was not touched.
2026-07-23 09:43:54 +02:00
Marc-André Bombeck 32edb033e8 docs: note the three silent-data-loss fixes in the changelog 2026-07-23 09:39:46 +02:00
Marc-André Bombeck 9ef3207cb6 style: run prettier on the dashboard-layout-section test file 2026-07-23 09:38:24 +02:00
Marc-André Bombeck fb50335459 fix(insights): stop the pulse page from silently swapping to resting heart rate
Closes #584. `/insights/pulse` computed hasRestingHr from
RESTING_HEART_RATE presence and used it to swap the ENTIRE primary
chart and the Coach read strip to that series whenever any resting
row existed, while the title, stat strip, capture action, and
"show all values" link kept saying PULSE. A user could see a
resting-only chart under a page that claimed to be pulse throughout.

The chart now always includes PULSE as its first series; when resting
data exists, RESTING_HEART_RATE renders as a second, clearly-labelled
series (the same multi-series pattern the blood-pressure page uses for
systolic/diastolic) instead of replacing it. The Coach read strip
stays pinned to PULSE. The page also drops the resting-calibrated
target band it used to paint behind the chart — that band is
calibrated to steady-state resting readings and would misreport an
expected workout spike in raw PULSE data as "out of target"; the
personal target belongs on the dedicated resting-pulse page against
the clean resting series.
2026-07-23 09:38:03 +02:00
Marc-André Bombeck d750c1827e fix(settings): stop ntfy PUT from clearing the stored auth token on unrelated edits
PUT /api/settings/ntfy reconstructed the entire stored config from the
request body. GET never returns the write-only authToken (only
hasAuthToken), so clients correctly omit it on any edit that doesn't
touch the credential — but the route treated an omitted or empty token
as "clear it", silently destroying a working notification credential on
routine edits like toggling enabled.

An omitted or empty authToken now preserves the currently stored value;
only a non-empty value replaces it. Mirrors the identical headerValue
preserve-on-empty contract already shipped on the webhook channel route.
2026-07-23 09:28:39 +02:00
Marc-André Bombeck 1371bb1cf9 fix(measurement-reminders): recompute nextDueAt from the field actually set, not a nullish fallback
PATCH /api/measurement-reminders/{id} persisted an explicit null cadence
clear correctly, but the immediate nextDueAt recomputation merged fields
with `updateValue ?? existingValue` — a JSON null and an omitted field
are both nullish, so the merge silently fell back to the OLD cadence for
one cycle. This hit interval-to-RRULE and RRULE-to-interval transitions
and an explicit anchorDate clear alike.

The merge now keys off Object.hasOwn(updateData, field): updateData
already encodes exactly which fields are being persisted, including the
mutual-exclusivity auto-clear (setting one cadence nulls the other), so
recomputation can never diverge from what the row ends up holding.
2026-07-23 09:25:16 +02:00
Marc-André Bombeck 79843f8770 fix(import): stream the Apple Health ZIP extraction and sweep orphaned import jobs
Closes #588. The ZIP extractor decompressed export.xml synchronously
into one in-memory buffer, then wrote it with a blocking writeFileSync
— on the same event loop the web server runs in the default
single-container deployment. A real multi-year export could pin the
Node main thread for minutes, freezing every other request, while
briefly holding roughly 2x the decompressed size in heap. A process
that died mid-extraction never reached the failure path, so the
ImportJob row stayed stuck in "unpacking" with "0 rows imported"
forever and no error ever surfaced.

Extraction now streams through zlib's async inflate into a file write
stream, moving the decompression work off the event loop and bounding
memory to the chunk size. The zip-bomb ceiling moves from
inflateRawSync's maxOutputLength to an equivalent byte-counting
transform on the streamed output.

Orphan-ImportJob reconciliation used to run once, at worker boot.
docker-compose's restart:unless-stopped does bring a crashed worker
back, but a fast restart can land before the stuck row's heartbeat
goes stale or pg-boss stops reporting the job as live — and because
the boot-time pass never re-runs on a healthy worker, that row was
never revisited again. A 15-minute cron now re-runs the same reconcile
so a genuinely abandoned import converges to a visible failure instead
of staying silently stuck.
2026-07-23 09:23:28 +02:00
Marc-André Bombeck 8736f44442 fix(dashboard): stop the score-ring PUT from resending a stale layout
The Settings page's instant score-ring toggle PUT the full dashboard
layout, built from the client's cached `remote` snapshot. That snapshot
can go stale mid-flight: if a normal tile/chart Save committed a newer
layout first, the ring request's explicit stale `widgets` (and
`comparisonBaseline` / `chartOverlayPrefs`) still won on write and
silently reverted the just-saved layout whenever it resolved last.

The ring mutation now PUTs only the two ring fields
(`buildRingMutationPayload`). `widgets` joins the existing
preserve-when-absent merge (`LAYOUT_FIELD_MERGE_DISPOSITION`) so an
omitted field is carried forward from whatever is currently stored
instead of a client-held copy, closing the race regardless of request
order.
2026-07-23 09:16:13 +02:00
Marc-André Bombeck a2fc6ea243 fix(auth): reconcile step-up elevations with main at v1.32.0
Renumber the elevation migration from 0260 to 0267 — main claimed 0260
for an unrelated table while this branch sat unmerged, and 0266 is now
the highest committed number.

Two loose ends the merge itself did not touch: the schema comment on
StepUpElevation.method still called it "audit/telemetry only, never an
authorisation input" — the exact claim the fresh-factor fix (a prior
commit on this branch) made false, since the method is now precisely
what decides whether an elevation reaches the destructive MFA routes.
Reworded to match. And pruneExpiredStepUpElevations in step-up.ts was
dead code: the actual sweep runs in cleanup-handlers.ts against the
worker Prisma client for reasons that function could never satisfy
against the request-scoped one. Removed rather than left to drift.
2026-07-23 09:16:03 +02:00
Marc-André Bombeck 58fddeb0d6 style: run prettier on the health-score fast-path test file 2026-07-23 09:15:41 +02:00
Marc-André Bombeck c256d945b3 fix(ai): stop the outbound risk guard from blocking this app's own scores
The named-risk-engine pattern in the outbound safety screen used
score2? (the 2 optional), so it matched bare "score" too — any mention
of one of this app's own computed, documented scores (Sleep Score,
Readiness Score, Health Score, Recovery Score, Stress Score, Strain
Score) tripped the bank meant only to catch a fabricated named
clinical-risk calculator (SCORE2, Framingham, ASCVD, QRISK).

Require the literal "SCORE2" instead. The other risk patterns —
percentage-shaped risk claims and the other three named engines — are
untouched, so a genuinely fabricated risk figure or a named
risk-calculator claim still blocks.

Closes #587.
2026-07-23 09:10:07 +02:00
Marc-André Bombeck 35a3842bba fix(analytics): return null health score when no pillar clears its own bar
The empty-path guard in the score fast-path checked raw record presence
(any row at all) rather than each pillar's own eligibility threshold —
weight needs 2 readings, mood needs 5 entries, BP needs a paired
reading, compliance needs an active scheduled medication. An account
with some data but no eligible pillar (a single mood entry, a single
weight reading) fell through to computeHealthScore, which summed no
components and returned a raw 0/red score indistinguishable from a
genuinely poor result.

Check the computed result's own components instead of re-deriving the
thresholds, so the guard can never drift from computeHealthScore's
actual eligibility rules. A pillar legitimately computed as 0 keeps its
0 value and is never swept into the null branch.

Closes #582.
2026-07-23 09:08:18 +02:00
Marc-André Bombeck 8119cc6c82 Merge remote-tracking branch 'origin/main' into work/native-stepup-rebase 2026-07-23 09:04:43 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck b01822be48 Merge pull request #579 from MBombeck/release/v1.31.8
chore: release v1.31.8
2026-07-21 18:20:40 +02:00
Marc-André Bombeck 25a56983b3 fix(medications): preserve wizard state during URL cleanup 2026-07-21 18:09:23 +02:00
Marc-André Bombeck 7f55083c3c chore: release v1.31.8 2026-07-21 16:29:04 +02:00
Marc-André Bombeck 0d5d61bc36 fix(mcp): stabilize lab search pagination (#578) 2026-07-21 16:17:16 +02:00
Marc-André Bombeck b20f1b5c66 fix: harden imports and daily medication state (#577) 2026-07-21 13:08:28 +02:00
Marc-André Bombeck 403eabb888 fix: restore runtime Prisma migration loading (#574) 2026-07-21 06:36:13 +02:00
Marc-André Bombeck a30c31b25d chore: release v1.31.5 (#573)
* chore: prepare v1.31.5 release

* docs: sync OpenAPI version for v1.31.5
2026-07-21 05:22:45 +02:00
Marc-André Bombeck 085aa3f5c5 fix: harden pnpm supply chain and runtime image (#572) 2026-07-21 04:36:53 +02:00
Marc-André Bombeck e38179e854 Merge pull request #571 from MBombeck/release/v1.31.4
release: v1.31.4 operational hardening
2026-07-21 02:41:45 +02:00
Marc-André Bombeck 5fe952b63d fix(ci): harden release and e2e verification 2026-07-21 02:30:40 +02:00
Marc-André Bombeck b38f41b99d chore(release): prepare v1.31.4 2026-07-21 01:34:29 +02:00
Marc-André Bombeck 3ce6726646 fix(ci): harden test determinism and dependencies 2026-07-21 00:36:40 +02:00
Marc-André Bombeck 9adeb8fc19 fix(runtime): harden process startup and health 2026-07-21 00:05:03 +02:00
Marc-André Bombeck 914e7658db ci: harden image publishing 2026-07-20 23:47:19 +02:00
Marc-André Bombeck be1d0fb728 Merge pull request #570 from MBombeck/fix/document-summary-coach-mobile
fix: localize document summaries and refine mobile coach
2026-07-20 22:45:51 +02:00
Marc-André Bombeck 3aa8ccd541 fix: localize document summaries and refine mobile coach 2026-07-20 22:34:56 +02:00
Marc-André Bombeck f1a1887a01 Merge pull request #569 from MBombeck/release/v1.31.2-data-integrity
release: HealthLog v1.31.2 data integrity hardening
2026-07-20 15:26:16 +02:00
Marc-André Bombeck ad5806c53a chore: format Polar client fix 2026-07-20 14:43:54 +02:00
Marc-André Bombeck 00a8c81c8d docs: include Polar and dependency fixes 2026-07-20 14:40:09 +02:00
Marc-André Bombeck 1840779dd7 fix(polar): align collection response fields 2026-07-20 14:36:21 +02:00
Marc-André Bombeck 3c5afea912 test(documents): format PDF cleanup coverage 2026-07-20 14:30:44 +02:00
Marc-André Bombeck b9425de40e fix(polar): use AccessLink collection endpoints 2026-07-20 14:25:24 +02:00
Marc-André Bombeck 74a386fcfd chore(deps): update July dependency bundle 2026-07-20 14:18:48 +02:00
Marc-André Bombeck f36bbc51cd docs: finalize v1.31.2 recovery notes 2026-07-20 14:05:58 +02:00
Marc-André Bombeck d966355f83 chore: format data integrity fixes 2026-07-20 14:02:08 +02:00
Marc-André Bombeck a846bbc90d fix(import): make mood collision probing order independent 2026-07-20 13:55:22 +02:00
Marc-André Bombeck 4124c483eb test(import): type legacy mood retry mocks 2026-07-20 13:53:31 +02:00
Marc-André Bombeck ca1ba8b128 test(whoop): type morning refresh mock 2026-07-20 13:53:06 +02:00
Marc-André Bombeck 6ebc278c2c fix(import): preserve repeated legacy mood rows 2026-07-20 13:47:28 +02:00
Marc-André Bombeck e9507a0c93 fix(backups): address restore review findings 2026-07-20 13:42:38 +02:00
Marc-André Bombeck 7eb3b91925 fix(import): resolve legacy mood timestamp collisions 2026-07-20 13:35:37 +02:00
Marc-André Bombeck cd6b5f0cc6 fix(backups): preserve canonical restore identity 2026-07-20 13:25:37 +02:00
Marc-André Bombeck 8b7cbddd72 fix(import): derive stable legacy mood timestamps 2026-07-20 13:25:13 +02:00
Marc-André Bombeck 9484240688 fix(whoop): settle backfill side effects 2026-07-20 13:24:49 +02:00
Marc-André Bombeck 1b717b4ca0 fix(whoop): fold committed backfill chunks 2026-07-20 13:16:34 +02:00
Marc-André Bombeck 007ea76327 fix(import): stabilize legacy mood retries 2026-07-20 13:12:55 +02:00
Marc-André Bombeck 197f7bf5a1 fix(whoop): persist backfill chunks durably 2026-07-20 13:08:48 +02:00
Marc-André Bombeck e47d02702d fix(import): stabilize legacy mood retries 2026-07-20 13:03:36 +02:00
Marc-André Bombeck 5fc0b1eead docs(api): sync spec for v1.31.2 2026-07-20 12:32:03 +02:00
Marc-André Bombeck a87a144b1e chore(release): prepare v1.31.2 2026-07-20 12:29:21 +02:00
Marc-André Bombeck 2716f302c7 chore: align hardening tests and formatting 2026-07-20 12:24:15 +02:00
Marc-André Bombeck 5d784a639c fix(integrations): serialize refresh and cursor writes 2026-07-20 12:09:32 +02:00
Marc-André Bombeck a8ac376cf7 fix(measurements): reconcile dual identities atomically 2026-07-20 11:55:09 +02:00
Marc-André Bombeck f8b0a64d69 fix(backups): preserve canonical recovery integrity 2026-07-20 11:44:29 +02:00
Marc-André Bombeck 5f4017d039 fix(withings): queue ECG webhooks durably 2026-07-20 11:37:23 +02:00
Marc-André Bombeck 7b515f4f82 fix(devices): reconcile APNs registrations 2026-07-20 11:25:48 +02:00
Marc-André Bombeck 452d1a2ae3 fix(backups): restore canonical payloads completely 2026-07-20 11:23:57 +02:00
Marc-André Bombeck a6d4bb414c fix(moodlog): acknowledge durable source writes 2026-07-20 11:20:35 +02:00
Marc-André Bombeck f648c1748a fix(import): classify operational failures 2026-07-20 11:12:30 +02:00
Marc-André Bombeck 1f94249bd5 fix(mood): preserve rated factors during edits 2026-07-20 11:11:03 +02:00
Marc-André Bombeck d31a33e14c fix(strava): preserve cursors on workout write failure 2026-07-20 11:08:50 +02:00
Marc-André Bombeck 932bbc7955 chore: ignore isolated worktrees 2026-07-20 10:30:27 +02:00
Marc-André Bombeck 140b6954f0 Merge pull request #567 from MBombeck/fix/v1.31.1-arrival-contracts
fix: harden v1.31 arrival contracts
2026-07-20 00:53:14 +02:00
Marc-André Bombeck 4591899657 fix(release): harden v1.31 arrival contracts 2026-07-20 00:40:20 +02:00
Marc-André Bombeck 44add3bc1e docs(release): correct v1.31.0 migration count 2026-07-19 20:25:37 +02:00
Marc-André Bombeck c8529fc0bb Merge pull request #560 from MBombeck/release/v1.31.0
release/v1.31.0 — the record that reacts
2026-07-19 20:24:07 +02:00
Marc-André Bombeck 4cab5b3c63 test(arrivals): snapshot data-arrival jobs 2026-07-19 20:12:48 +02:00
Marc-André Bombeck c97ad3ed55 fix(release): harden Today arrival rendering 2026-07-19 19:55:40 +02:00
Marc-André Bombeck d40da09558 chore(release): v1.31.0 — the record that reacts 2026-07-19 17:04:40 +02:00
Marc-André Bombeck 29e8cd8c4f test(integration): truncate step_up_elevations between runs 2026-07-19 16:51:03 +02:00
Marc-André Bombeck 29673c1155 fix(insights): register the arrival reaction line with the tone harness
The harness landed in v1.30.35, after the slices that built this
release were already cut — so the reaction line's prompt module was
never registered, and its own docblock's warning against the
two-locale collapse was untested. Registering it surfaced a real gap:
the meaning-first instruction lived only in the system prompt's own
words, never in the user prompt in the family's shared phrasing, which
is where the rest of the surfaces carry it and where the tone contract
checks for it. The German body carried no such instruction at all.

Threads locale through the user-prompt builder so the fixed
instruction line matches the reader's own reply language, and wires
the per-user per-day opener-archetype rotation the milestone spec
called for and the first implementation had not connected.
2026-07-19 15:48:57 +02:00
Marc-André Bombeck 430cbb2ceb Merge remote-tracking branch 'origin/work/v131-slice3-activity' into release/v1.31.0
# Conflicts:
#	src/lib/jobs/reminder/register-status.ts
2026-07-19 15:42:11 +02:00
Marc-André Bombeck 7b51f9edaf Merge remote-tracking branch 'origin/work/v131-slice4-askwhy' into release/v1.31.0 2026-07-19 15:40:58 +02:00
Marc-André Bombeck c1f583b114 Merge remote-tracking branch 'origin/work/v131-slice2-today' into release/v1.31.0 2026-07-19 15:40:50 +02:00
Marc-André Bombeck db3e56ed08 Merge remote-tracking branch 'origin/work/v131-slice1-arrival-spine' into release/v1.31.0 2026-07-19 15:40:43 +02:00
Marc-André Bombeck 6e1945e8a0 docs(workouts): correct the seam comments now that the card can mount 2026-07-19 14:59:24 +02:00
Marc-André Bombeck e44cf8b804 test(workouts): guard the insight queue against silent provisioning loss
Nothing warms this queue and no read path triggers it, so an unregistered name
fails open in the worst way: every send resolves, every job vanishes, and the
symptom is indistinguishable from the surface's honest empty state.
2026-07-19 14:55:46 +02:00
Marc-André Bombeck 69b61770c4 test(workouts): pin the insight card's text-children rendering
The paragraph is model output. Rendering it as markup would be an XSS surface,
and this project ships no markdown library so the question stays closed — the
test is what keeps that from being treated as a formatting decision.
2026-07-19 14:48:08 +02:00
Marc-André Bombeck f1c977a7a7 test(auth): give the MFA fixtures a Bearer transport
The disable-route suite only ever supplied a cookie-shaped context, which is why
the token-id-as-session-id defect was invisible to it. It now covers both arms
and asserts the credential shape handed to the session revoker, plus that the
elevation is spent before the teardown and not spent when the factor code is
wrong.
2026-07-19 14:43:05 +02:00
Marc-André Bombeck ac9170945d test(workouts): prove the insight row's constraints against real Postgres
The one-paragraph-per-workout claim is a unique index, the cascade is an FK,
and the daily-cap window is a SQL fact — a mocked upsert reports success for
all three regardless.
2026-07-19 14:37:10 +02:00
Marc-André Bombeck 87cda9b7a7 test(arrivals): assert the workout dispatch, not its placeholder label 2026-07-19 14:33:30 +02:00
Marc-André Bombeck 83da7c57c8 fix(notifications): dial Web Push through the pinned dispatcher
The push endpoint host is user-controlled — the browser supplies it at
subscribe time. `isPublicUrl` guards it at subscribe time and again before each
dial, but it is a literal input-time check and cannot see a DNS rebind between
the accept and the connect; with a short-TTL record the dial reaches the pod's
private network.

`web-push`'s `sendNotification` dials with its own internal `https.request`,
which bypassed `safeFetch` and therefore the connect-time pin entirely. Both
senders now sign and encrypt through `generateRequestDetails` — identical VAPID
handling and payload encryption — and dial through `safeFetch` with
`requirePublicHost`. Non-2xx is re-shaped as the `{ statusCode }` rejection the
existing classification already understands, so the 410/404 expiry reap and the
transient/permanent split are unchanged.

This is the last user-controlled-host egress that was not behind the pinned
dispatcher (issue #217).
2026-07-19 14:31:25 +02:00
Marc-André Bombeck 4cd0adc46d docs(api): publish the factor-dependent reach of a step-up elevation
The contract now names which factors satisfy the fresh-factor routes and which
do not, carries `satisfiesFreshFactor` on the mint response so a client can pick
the ceremony before spending a proof, and describes the options endpoint's two
ceremony kinds. The second-factor status read is documented as plain
authentication — it carries no credential material, so it needs no elevation.
2026-07-19 14:29:34 +02:00
Marc-André Bombeck bec7538012 fix(workouts): enforce the numbers-only evidence claim in code
The projection's central claim was carried by a comment. It is now walked
before it is returned: any non-numeric leaf at any depth throws, and the only
two string leaves are allowlisted by shape — the sport type against the schema
enum itself, and the day key against its format.

This matters because sport_type is a TEXT column whose API write path is
enum-constrained but whose backup-restore path accepts any non-empty string, so
a restored row can carry arbitrary text. It is re-asserted at the projection
boundary rather than trusted from the column. The heart-rate provenance string
is dropped rather than allowlisted, and route geometry stays consumed-not-carried
— it yields one number, and location history may not ride a prompt.

Own-history rows now collapse cross-source twins through the canonical picker,
as the detail page's comparison already did; a session recorded by two paired
watches was inflating the sample size and biasing every median.
2026-07-19 14:27:59 +02:00
Marc-André Bombeck d103699099 fix(measurements,coach): restore the live-fallback spread band and the stream lifecycle
The tiered rollup read emits minValue/maxValue; readLiveDaily omitted them, so
on a rollup-cold account or a coverage miss the chart silently dropped the
min/max spread band for the same underlying data — contradicting the file's own
byte-for-byte parity claim. The live aggregate now selects MIN/MAX alongside the
average.

useSendCoachMessage never aborted its in-flight reader on unmount. The drawer
works around that through handleOpenChange, but the full-page /coach surface
passes no registerReset, so navigating away mid-stream left the SSE loop alive:
the provider kept generating and billing, and trailing setState fired on an
unmounted component.

Switching conversations reset every piece of local state except the send state,
so the previous thread's last assistant or error bubble rendered appended to the
newly selected thread until the next send.

The two lifecycle invariants are pinned structurally, not behaviourally — the
repo ships no renderHook. That is weaker, and the header says so; both defects
were a missing call, which is what a structural guard does catch.
2026-07-19 14:23:03 +02:00
Marc-André Bombeck 2bf450cf17 test(coach): pin the workout launch's tenancy, closure and snapshot-once
Four suites, each mutation-checked against a reverted protection:

- route-workout-scope: the workout row is read exactly once across a ten-turn
  conversation that keeps sending the id, the where clause always names
  userId, another user's id resolves to nothing and the chat proceeds, and
  the section sits inside the health-data fence.
- workout-evidence: the numbers-only closure. An instruction-shaped
  sport_type — reachable through the restore path, which types the column as
  free text — folds to "other" and never reaches the block; a free-text leaf
  at any depth throws.
- page-launch-params: an unknown ?scope= is ignored silently, and ?workout=
  yields to both ?c= and the fenced ?doc= path.
- composite-score-anatomy-coach: every score sheet carries an opener, a
  scope narrowed to the composite's inputs, and auto-send.

resolveLaunchState's shape assertion gains the workoutId field.
2026-07-19 14:19:33 +02:00
Marc-André Bombeck 048d7a4d84 Merge remote-tracking branch 'origin/main' into work/v131-slice3-activity 2026-07-19 14:16:29 +02:00
Marc-André Bombeck 9d03fe13f0 fix(mood): make the bulk upsert and its tag links one transaction
`createTagLinks` validates `ratedFactors` after the mood row is written. On the
bulk path the two ran as separate awaits on the base client, so an out-of-scale
rating threw once the row had already committed: the per-entry catch reported
"skipped" — the one status that promises nothing was saved — while a mood entry
with no tags or factors sat on disk. The single-entry POST has always wrapped
the pair in one transaction; the bulk twin now matches.

The test client for the transaction is a distinct object from the base client
on purpose. Sharing one identity made the atomicity assertion vacuous: a write
that escaped the transaction looked exactly like one that stayed inside it.
2026-07-19 14:16:02 +02:00
Marc-André Bombeck 663b3530dd test(daily): pin the just-in window, the degrade floor, and the hydration edge
Three properties, each with a mutation-verified guard:

The chip and the line run on DIFFERENT clocks and must keep doing so. The chip
expires after three hours; the line stands for the rest of the local day.
Collapsing them onto one clock reads like a simplification and silently deletes
the day's read at lunchtime.

Degradation is the headline. For every reason a line can fail to exist — no
provider, no consent, an exhausted budget, a dead provider, an unusable output
— the row stays line-less, the reservation is reconciled, and the chip and the
provisional-to-final flip still work. The degraded digest is built through the
real composer rather than a hand-shaped fixture, so the assertions cannot agree
with themselves.

The server pass carries no wall-clock time. The hero paints on the SSR pass
because it is the LCP element, so a locale-formatted time inside it would differ
from the browser's — React #418. The tests render through the server snapshot,
which is exactly the pass that must stay clock-free.

The seeded end-to-end spec lands a sleep batch through the real ingest route,
writes the arrival marker, and asserts the chip and the phase flip in place on
a refetch trigger the digest hook already declares — no reload, no new cadence.
2026-07-19 14:13:50 +02:00
Marc-André Bombeck d5e23ebee6 Merge pull request #559 from MBombeck/release/v1.30.35
release/v1.30.35 — the offline metric texts speak six languages
2026-07-19 14:07:38 +02:00
Marc-André Bombeck 6fee8ad091 test(workouts): pin each rung of the activity-insight gate stack
One test per gate, each asserting the refusal reason AND that the provider
entry was never reached — a gate that refuses but still resolves a provider
would pass a reason-only assertion while costing what the gate exists to
prevent. The cap and the hash are asserted independently of each other, since
the singleton queue key that normally collapses a burst is best-effort.

The backfill fixture walks the real chain: 750 historical sessions plus 40
re-synced ones plus one fresh, through the live classifier and the live spine
worker, yielding exactly one generation.

The peak detector changed under test: on a flat ride the ninetieth percentile
sits a beat above the mean, so a bpm of sampling noise read as two efforts the
rider did not do. Peaks now need an absolute prominence over the session mean
as well.
2026-07-19 14:03:52 +02:00
Marc-André Bombeck 1a469301b3 feat(workouts): generate the per-workout insight on arrival, never on view
The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.

The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
2026-07-19 13:56:50 +02:00
Marc-André Bombeck a0b7dae8eb chore(release): v1.30.35 — the offline metric texts speak six languages 2026-07-19 13:56:39 +02:00
Marc-André Bombeck ca170925dc feat(daily): react in place when new data lands
Today gains two additive-nullable digest fields. `justIn` names the day's
newest arrival while it is still news — under three hours old, after which the
record is simply current rather than new. `reactionLine` carries a single
generated sentence about that arrival and replaces the hero's standing lead for
the rest of the local day, never appending a second paragraph, so the hero stays
one lead line tall and nothing below it shifts.

Nothing here polls. The open dashboard picks the change up on the cadence it
already runs — a state change, not a notification.

The generated sentence is bounded before it is written: the arrival spine's
unique row per kind per local day IS the throttle, the worker re-checks the
same claim before spending, and the reservation is reconciled on every exit
including each failure path. Without a provider, without consent, or against an
exhausted budget the row stays line-less and the surface falls back to the
deterministic lead — the chip and the provisional-to-final flip are unaffected.

The chip's time string is formatted client-side after mount; `justIn.at`
crosses the wire as an ISO instant so the server never renders a local
wall-clock time the browser would disagree with.
2026-07-19 13:56:21 +02:00
Marc-André Bombeck 096c8c799b feat(coach): give every read-only assessment surface a way out
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.

- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
  auto-sent opener the metric pages use, each scoped to the inputs that drive
  the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
  gains an optional workoutId; on the FIRST turn the route narrows by
  { id, userId } and pins ONE bounded evidence section onto the snapshot.
  Later turns reuse the pinned snapshot, so per-turn work stays constant in
  conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.

The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.

Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
2026-07-19 13:55:37 +02:00
Marc-André Bombeck e53a8ce8a0 feat(workouts): add the activity-insight evidence projection and prompt
The evidence block is a numbers-only server projection: zone seconds, the
workout-window heart-rate shape, route climb and a same-sport 90-day own-history
median. Free-text metadata never enters it, and the sport string is narrowed
against the closed enum first, so the payload carries no text the user did not
generate through a measured field.

The prompt composes the shared assessment base and adds the session contract:
device-attributed, own-history-comparative, effort acknowledged where the
numbers show it, and no training prescriptions of any kind. Registered in the
tone harness beside the assessment cards.
2026-07-19 13:50:54 +02:00
Marc-André Bombeck ee6ae9c450 Merge remote-tracking branch 'origin/work/fallback-locales' into release/v1.30.35 2026-07-19 13:49:53 +02:00
Marc-André Bombeck 0991d754e4 fix(auth): make the re-proved factor decide what an elevation reaches
A step-up elevation minted from the account password satisfied the fresh-factor
routes. That is a second-factor bypass: a stolen token plus a known password
could rotate the recovery codes — which take no factor in the body — and then
spend one of them to disable the second factor outright. The web has never
allowed that chain, because a password login does not stamp mfaVerifiedAt.

The method is now an authorisation input. Only totp, webauthn, and passkey
satisfy a fresh-factor gate, which is exactly the set of ceremonies the web
stamps a session for; password reaches what a plain cookie session reaches and
stops. The mint gains TOTP and security-key arms so a TOTP-only account can
still manage its factor natively, and the response says which reach it bought.

The gate also returns a discriminated context. session.id meant a session row on
one arm and a token row on the other, and the disable route passed the token id
to a session-scoped query — excluding nothing, so it deleted every browser
session and revoked the caller's own device login. The transport is now in the
type and the compiler refuses the confusion.

Redemption splits into validate-then-commit so a 429, a 422, or a wrong code no
longer burns a proof against a five-per-fifteen-minute mint ceiling. The claim
is UPDATE ... RETURNING, so the statement that decides is the one that reports.
The mint is transactional behind a partial unique index. Sign-out-everywhere and
both password-reset paths clear elevations. The status read drops to plain
requireAuth — it carries no credential material — and every mutation stays gated.
2026-07-19 13:48:46 +02:00
Marc-André Bombeck 4b87b9e33b test: pin three invariants that were fixed but unguarded
Each of these was already correct in the tree and had nothing asserting it, so
a refactor would have regressed it silently.

The decrypted-snapshot LRU keys on userId, but only same-user memoisation and
scope-change recomputation were covered — dropping the userId segment would have
served one account's health snapshot to another with a green suite. The project
has shipped one cross-user cache-leak fix already; this is a repeat class.

The GlitchTip forwarder redacts path-segment secrets in the url it sends. The
query-string strip was tested; the path-segment pass was not, and that is the
one that reaches a durable external incident UI.

The Withings credentials DELETE narrows its connection-row delete to P2025 and
rethrows everything else, so a real failure can no longer null the client
credentials and report a successful disconnect while the encrypted OAuth tokens
survive. The route had no test file at all.
2026-07-19 13:47:30 +02:00
Marc-André Bombeck 0dba15e902 feat(workouts): add the per-workout insight model
Its own table, cascading from the workout row, because workout deletes are
hard deletes. The paragraph is AES-256-GCM at rest and registered for key
rotation; the input hash and prompt version make a re-post of the same
session a free no-op.
2026-07-19 13:47:25 +02:00
Marc-André Bombeck bfb4dbd7da Merge remote-tracking branch 'origin/work/v131-slice5-copy' into work/v131-slice3-activity 2026-07-19 13:44:20 +02:00
Marc-André Bombeck 8b13b46ac7 feat(insights): write the no-key fallbacks in every shipped locale
The deterministic fallbacks nine metric surfaces render when no provider is
reachable carried German and English bodies only. `getLocalizedText` returned
`locale === "de" ? de : en`, so French, Spanish, Italian and Polish readers got
English — on the no-key path, an outage, a spent budget or a timeout, which is
precisely when this text is the whole assessment.

Every string now has a body per locale: the verdict leads and value sentences
of the signal-grounded composer, the per-metric labels and pointers, and the
eight no-signal floors. Selection reads the reader's locale; English stays as
the structural floor for a locale that is ever genuinely missing a body.

The bodies are written per language, not translated word-for-word. fr/es/it
replace the comparative adjective ("higher than your usual average") with a
preposition — au-dessus de / por encima de / sopra — because a comparative
would have to agree in gender with the metric noun, and the labels do not share
one. Polish restructures further: the sentence takes `wynosi`, which is
invariant for the subject's gender, and the comparison uses powyżej / poniżej
with the genitive; the labels span all three genders, so nothing that inflects
can sit in a shared template.

The tone harness grades the four locales by the same rules rather than passing
them by default: false cheer, clinical-verdict and imperative-opener vocabulary
extended to all six, the no-assessment sentinel keyed per locale instead of a
de/en binary, and a byte-identity guard that fails if any locale silently falls
back to the English text again. The value-led-opener rule was rewritten as
well — it assumed the copula sits against the figure, which under-matched its
own second locale (German "liegt aktuell bei 24") and would have missed all
four new ones.
2026-07-19 13:42:09 +02:00
Marc-André Bombeck f9112925d2 test(workouts): count the source-priority read by shape, not by call total
The batch route now also resolves the user's timezone and module map through
the arrival seam, so a raw call count on user.findUnique no longer isolates
the read this guard is about. Filtering by the SELECT shape keeps the
once-per-batch contract pinned while letting the unrelated cached lookups
through.
2026-07-19 13:40:18 +02:00
Marc-André Bombeck 94157a50c7 fix(nutrients): bound the manual water writes and put nutrient totals in the backup
The water quick-add validated calendar realism but never the upper day bound the
batch route applies, so a far-future day was accepted and — every read filters
`day >= since` with no ceiling — became the permanent latest day on the settings
card and the dashboard tile. Hoist the bound into one shared helper both routes
use.

`mode: "add"` bounded a single request to the catalog's plausible daily max but
nothing across requests, so repeated quick-adds drove the stored total
arbitrarily high while the batch route enforced the same cap strictly. Clamp
after the increment so the write stays atomic.

Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a
microgram-read-as-milligram sample landed on precisely the cap and the `>`
comparison let it through. 500 still clears any real supplement dose.

NutrientIntakeDay was in no export path, which contradicted the schema's own
reason for denormalising the unit column. Carry it in the full backup with its
unit and source — source is part of the composite key, so without it a restore
cannot tell a manual entry from a synced day total.
2026-07-19 13:40: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 f5889affb7 fix(compliance,ingest): bound the point window and the two unbounded write paths
`classifyIntakeTiming` treated `windowStart === windowEnd` as an overnight
wrap, inflating a degenerate point window into a ~30h on-time band — a dose
taken many hours late still classified `on_time` and fed a perfect-compliance
streak. Widen the point window symmetrically instead, matching the treatment
`resolveLegacyWindowBounds` already applies.

`/api/measurements/batch` never applied the entry-instant plausibility bound
its single-entry twin has carried since v1.17 W1b, so a future-dated row landed
and permanently won 'latest reading' while poisoning the rollup buckets and the
canonical picker. Skip per entry, like the range guard. The past floor stays at
1900 — a historical backfill is a legitimate shape here.

The intake CSV import had neither an upper date bound nor a rate limiter, while
running up to 1000 sequential find + create + consume iterations per call. Add
both, on the bulk twin's bucket shape and budget.
2026-07-19 13:26:06 +02:00
Marc-André Bombeck 80e88cfb23 fix(insights): warm the deterministic fallbacks and re-fingerprint the cards
The no-signal floors were the coldest text the product renders. They run when
there is nothing to ground against, and they opened on a bare order — "Measure
blood pressure at rest and under comparable conditions", "Evaluate mood trends
over several weeks" — so the one moment the surface has nothing to say was also
the one moment it told the reader what to do.

They now open on what the surface can honestly say: no assessment is available
right now. That is the meaning-first opening for this case, and it is the only
one available, because a floor with no signal may not imply a read. What
follows describes how the measure behaves rather than ordering the reader
around. The signal-grounded lines above them are unchanged.

The prompt version bump re-fingerprints the assessments cached under the old
biomarker prompt; the per-card input hash folds the constant in, so the
regeneration is one-time and paced by the nightly warm.
2026-07-19 13:25:32 +02:00
Marc-André Bombeck 9ab8ed8545 Merge pull request #558 from MBombeck/release/v1.30.34
release/v1.30.34 — the layout keeps your choices; destructive controls ask first
2026-07-19 13:25:22 +02:00
Marc-André Bombeck ccd3c5e68f test(arrivals): pin the spine's guardrails against heavy fixtures
Backfill immunity is proved by replay, not by a hand-picked case: a ten-year
Apple Health export (12772 writes) and a 30-day provider re-sync (58 writes)
must both emit zero events, while a fresh sleep night, workout and first
weight of the day emit exactly one each.

The provider-isolation guard walks the worker's static import graph and fails
if any provider client or resolver becomes reachable. It carries two positive
controls, so a broken entry path cannot make it pass against an empty graph.

The integration test covers what unit mocks cannot: the unique index, the
ON CONFLICT DO NOTHING claim, the losing side of ten concurrent claims, the
forward-only marker, ciphertext at rest, the delete cascade, and the
retention window.
2026-07-19 13:24:22 +02:00
Marc-André Bombeck ef10ac5581 fix(coach,integrations): assert the German refusal path and classify empty token bodies
The German prompt-injection test asserted only inside `if (decision.refuse)`,
so it stayed green whether or not the filter fired. Un-guard it, drive it from a
case table, and widen the two German patterns to cover the determiners that
walked straight past them ("saemtliche", "die", "deine").

The Oura / Polar / Fitbit token helpers cast a possibly-null `json` to the token
type on a status-only success gate. A 2xx with an empty or non-JSON body handed
callers a null they dereferenced, surfacing an unclassified TypeError instead of
a handled integration error. Narrow explicitly and classify as transient.
2026-07-19 13:18:53 +02:00
Marc-André Bombeck 5d62236412 feat(arrivals): add the data-arrival event spine
Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.

The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.

The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.

Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).

The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.

Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
2026-07-19 13:17:34 +02:00
Marc-André Bombeck 1b50dc0909 test(insights): add the per-metric tone harness
A deterministic grader over the assembled assessment prompts and the
deterministic fallback text. No provider, no network, no database — it runs in
the normal suite in under half a second, which is the point: the live-judge
eval needs a key and can only advise, so the regressions that actually shipped
need a check that can block.

Three classes are blocking. A surface stops leading with meaning (it drops the
shared opening contract, or its user prompt loses the meaning-first
instruction). A locale collapses (fr/es/it/pl fall back to the German body or
lose the reply-language directive). A prompt reintroduces a value-first
instruction — matched with negation handling, since the shared prompts quote
that failure mode in order to ban it.

The deterministic fallbacks are graded on the same axis, including the
asymmetry that matters: a signal with no baseline supports no confident read,
so that one line honestly opens on the value. Pinning it stops a future
"always lead with meaning" edit from pushing the surface into claiming
something the data does not support.

A coverage guard fails when an assessment prompt module has no harness entry,
so the next surface cannot stay uncovered the way the biomarker card did.
2026-07-19 13:16:41 +02:00
Marc-André Bombeck ba45a25cb0 test(auth): retarget the MFA route mocks at the shared gate
The three unit suites that stubbed `requireCookieAuth` / `requireFreshMfa`
directly now stub `requireMfaManagementAuth`, and the password-change suite
stubs the elevation revocation the route gained. Adds the unit checks that need
no database: the elevation TTL is pinned to the cookie path's step-up window so
the two cannot drift apart, the minted value is a prefixed CSPRNG secret whose
plaintext never reaches the row, and a value carrying another credential's
prefix is refused without costing a query.
2026-07-19 13:14:38 +02:00
Marc-André Bombeck ed049661b7 feat(insights): bring the biomarker card onto the shared assessment contract
The per-biomarker assessment was the last surface in the family building its
prompt inline: a five-line scaffold that skipped the base system prompt, took
no opener hint, and instructed the model to "state the current value" — so the
one card where a bare number reads most like a verdict was value-first by
instruction while every sibling had moved to meaning-first.

It now composes exactly as the seven bespoke cards do. The new prompt module
carries a METRIC section that states the one thing this card needs and the
others do not: its snapshot has no signal block, so the read comes from the
pre-computed range placement plus the direction across draws.

Also point both archetype-driven cards at a registered budget. The generic
metric card and the biomarker card each carried temperature/maxTokens inline
at 0.45/1000, so the registry that documents every surface's ceiling was
silently wrong about the two widest assessment surfaces.

The single-marker no-key fallback replaces the multi-metric overview tip the
card used to render — text about watching several metrics at once, on a card
showing one lab value.
2026-07-19 13:14:23 +02:00
Marc-André Bombeck b2168df172 chore(release): v1.30.34 — the layout keeps your choices; destructive controls ask first 2026-07-19 13:01:45 +02:00
Marc-André Bombeck 433d983e60 test(auth): pin the step-up elevation boundary and publish its contract
Adds the structural guard that freezes the exact route set an elevation can
unlock, plus the behavioural suite that drives each threat case against a real
Postgres: a token alone mints nothing, an elevation minted for one token is
refused for another (same account included), a second redemption fails, a
concurrent redemption lets exactly one winner through, an expired one is
refused, an elevation never reaches requireAdmin, a wrong password is throttled
and audited without saying what was wrong, and the cookie flow is untouched.

The OpenAPI registry carries both new routes and names the accepting set in
prose, so the client implements against the contract rather than against a
description of it.
2026-07-19 12:47:04 +02:00
Marc-André Bombeck d68a7ca244 Merge remote-tracking branch 'origin/work/parity-contracts' into release/v1.30.34 2026-07-19 12:46:10 +02:00
Marc-André Bombeck 4c65949bac Merge remote-tracking branch 'origin/work/parity-coherence' into release/v1.30.34 2026-07-19 12:46:04 +02:00
Marc-André Bombeck 670e7097a2 fix(settings): destructive session and device controls ask before firing
Signing every other device out, revoking a single session, revoking a
trusted device and revoking them all each fired on a single tap. None is
undoable — the other devices simply have to sign in again, and a revoked
trusted device asks for its second factor next time — while every other
destructive surface in the app confirms first. On a shared screen a
mis-tap is a support call.

A sweep of the rest of the tree found two more hard deletes with neither
a confirmation nor an undo: a Coach reminder and a travel entry. Both now
confirm. Deleting a document deliberately does not: it soft-deletes and
offers an undo, which covers the mis-tap.

The four controls share one ConfirmButton rather than four copies of the
dialog, so the next destructive control gets the confirmation by using
it instead of by someone remembering. Copy is per control and states the
actual consequence — no claim of more finality than is true.
2026-07-19 12:46:04 +02:00
Marc-André Bombeck 30954901ac chore: reconcile the catalogue and metric-unit guards
The widget catalogue grew to 42 ids, so the snapshot round-trip count
follows. The mood card's unit key carries "/5" rather than an empty
string — the locale-integrity guard forbids empty values, and the scale
is the honest thing to print next to a 1-to-5 score. It joins the
identical-across-locales allowlist alongside the other unit notations.
2026-07-19 12:44:23 +02:00
Marc-André Bombeck e557d9e9e6 feat(auth): add token-bound step-up elevations for second-factor management
Second-factor management has been cookie-only since v1.23. The gate is right:
a Bearer token is a credential whose provenance the server cannot see, and
tearing down a second factor must not be reachable from the weaker of an
interactive login and a value pasted into a script. What was missing was never
Bearer access to those routes — it was a way to say, over a transport with no
session row, that the human holding this device just re-proved a factor.

An elevation is that proof. It is minted only against a re-proved account
password or primary passkey, bound to the exact token that re-proved, single-use
via a conditional UPDATE, five-minute-lived to match the cookie path's step-up
window, and stored hashed. A stolen token mints nothing; a second token cannot
redeem another's elevation; a password change drops every one the account holds.

requireMfaManagementAuth is the one gate the management routes share. On a
cookie session it delegates to the original helpers unchanged, so the web flow
runs the code it always did. requireAdmin stays cookie-only and out of reach.
2026-07-19 12:42:00 +02:00
Marc-André Bombeck 571e415eaa Merge pull request #557 from MBombeck/release/v1.30.33
release/v1.30.33 — the session cookie becomes a secret
2026-07-19 12:31:18 +02:00
Marc-André Bombeck 4c3cc84daf feat(medications): carry manufacturer and printed strength on an inventory container
The native pen list renders these two as its headline and subhead. The
server held neither, so a container registered on the web could not be
shown there without fabricating an empty card, and the client kept its
own copy locally with no back-fill possible.

Both columns are additive and nullable (migration 0255). Every existing
row is truthfully NULL — the values were never collected, and a plain
supply row such as a blister pack or a bottle has no use for either.

Strength stays free text rather than a parsed number + unit: pens state
it per dose, per ml, or per cartridge depending on the product, and
forcing a numeric split would lose which of the three was printed.

On PATCH an omitted field leaves the stored value untouched, the same
rule notes already followed, so a client that does not know the fields
cannot blank what another populated; an explicit null still clears. The
web add-container form collects both for a PEN, so the columns do not
ship empty forever.
2026-07-19 12:28:48 +02:00
Marc-André Bombeck e9d28b399e fix(copy): make delete confirmations, sleep-debt labels, and the glucose band honest
Three surfaces where the words no longer matched the code, plus the missing
web control for a preference the server already honours.

Delete confirmations. Four dialogs promised permanence on surfaces that
render an Undo: the measurements and mood bulk bodies, the lab-result body,
and the illness-episode body (which said "cannot be undone" sixty lines
below the code that fires the Undo toast). All four now use the same
sentence the single-row measurement and mood dialogs already used. A sweep
of every other confirmation in the tree found the rest accurate — the
biomarker cascade, the mood-tag purge, and the account purge keep their
warning because those deletes really are unrecoverable.

Sleep debt. v1.19.0 replaced the summed shortfall with a running balance
that credits catch-up sleep; the labels still described the old sum, so the
word contradicted the number for anyone who slept in after a short night.
Corrected in all six locales, plus the places the old semantics had
propagated: the Coach system prompt, the OpenAPI field descriptions the
native client reads, and four module docblocks.

Glucose reference band. `hasDiabetes` selects the tighter reference band and
grounds the Coach, but no web surface set it — a web-only account was pinned
to the non-diabetic scale with no way out. The control goes on Targets,
above the per-metric editor, because it answers the same question that page
answers and the two are ordered: it picks the default band, an override
below beats it. The copy says what it is — a reference band, not a
diagnosis, never inferred from a reading, no clinical action taken on it.

The passkey list was reported as always reading "never used". It does not
reproduce: the column is selected by the list route and stamped on every
verified assertion, both since v1.23.0. Pinned with tests on both halves so
the contract cannot quietly regress.
2026-07-19 12:28:05 +02:00
Marc-André Bombeck 4ddc08f784 feat(dashboard): emit mood and BMI on the summary payload
Both metrics exist in the record but the summary route emitted neither,
so a native client could render every home-screen tile except those two.

Mood reads the canonical daily series (`buildMoodDailySeries`) — the same
rollup-backed aggregation the web mood analytics and the dashboard
snapshot render — and emits the newest day's score with a trailing
seven-day sparkline. BMI comes from `computeBmi`, the one implementation
the web vitals dashboard already renders through the derived-metric
dispatch; the route resolves the figure so no client re-derives weight
over height squared.

Mood gates on the mood module through a synthetic `MOOD_ENTRY` key, the
same route `NUTRIENT_WATER` takes, because mood is not a measurement
type; without the join the card would ship past a disabled module. BMI
belongs to no module and rides through with the other core vitals.

The BMI card is omitted rather than emitted empty when the profile has no
height or no weight in the window: the card shape carries no honest
"unknown" state, and a null value would read as a logging gap rather than
a missing profile field.
2026-07-19 12:24:07 +02:00
Marc-André Bombeck 4bee263a59 test(sessions): revoke by the public handle, and prove the row id is not a key 2026-07-19 12:20:25 +02:00
Marc-André Bombeck 8168e60dfd fix(dashboard): stop the widgets PUT discarding a comparison baseline
The layout PUT preserved chartOverlayPrefs, selectedScoreRings and
heroRingOrder when a client omitted them, but the top-level
comparisonBaseline was not in that merge. serializeDashboardLayout clamps
a missing baseline to "none", so a layout save from a client that does
not know the field — the native client treats it as web-only and never
sends it — silently reset the comparison the user had picked on the web.
No error, no audit row.

Replace the hand-maintained three-field merge with an explicit
disposition map over every top-level layout field. The map is declared
`satisfies Record<keyof DashboardLayout, ...>`, so adding a layout field
without deciding replace-or-preserve fails typecheck; the preserve set
and the merge are derived from it rather than restated. The stored-layout
read is skipped when the client sent the full set.

Also catalogue the four clinical signals the native client pins — grip
strength, pain NRS, waist circumference, waist-to-height. They reached
the route as unknown ids, were filtered out ahead of Zod, and vanished
from the persisted layout, so a tile placement was lost on every save.
They join the iOS-only set because the web dashboard has no render path
for them; their charts live on the insights sub-pages. Raise the widget
array cap to 50 to clear the now-42-id catalogue.
2026-07-19 12:19:17 +02:00
Marc-André Bombeck b0021a8258 Merge pull request #556 from MBombeck/release/v1.30.32
release/v1.30.32 — consent that stays withdrawn; quotes that come from the document
2026-07-19 12:10:26 +02:00
Marc-André Bombeck fd9603a578 chore(release): v1.30.33 — the session cookie becomes a secret
Also closes the device list's exposure of the same value, refuses a
cross-device refresh rotation, replaces a vacuous expiry test with one
that fails when the check is removed, and asserts the Nightscout host
guard at save time rather than relying on the probe's placement.
2026-07-19 12:09:17 +02:00
Marc-André Bombeck 03fdcddb92 Merge remote-tracking branch 'origin/work/session-secret-and-nightscout' into release/v1.30.33 2026-07-19 11:59:44 +02:00
Marc-André Bombeck 2ab21bb433 chore(release): v1.30.32 — consent that stays withdrawn; quotes that come from the document 2026-07-19 11:59:28 +02:00
Marc-André Bombeck 0627648bea Merge remote-tracking branch 'origin/work/document-and-model-correctness' into release/v1.30.32 2026-07-19 11:52:41 +02:00
Marc-André Bombeck 306d1f7443 Merge remote-tracking branch 'origin/work/contract-and-prefetch' into release/v1.30.32 2026-07-19 11:52:41 +02:00
Marc-André Bombeck 1c2cfffdb8 fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw
The settings mount fires an idempotent consent heal so an account that
predates the gate does not fall into the no-key state. It looked only for
an active receipt, so a deliberate withdrawal — which sets revokedAt —
read the same as never having consented, and the next visit to the AI
settings granted again, writing an audit row for an act the user never
performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the
grant; one undone by a page load is not a withdrawal.

The mint now distinguishes the two callers. The mount heal grants only to
an account with no consent history at all and leaves a revocation
standing; switching document auto-read on is the consent act itself and
may supersede an earlier revocation. The revocation is re-checked inside
the transaction so a withdrawal landing mid-mint is not overwritten.

The revoke endpoint has existed since v1.4.40 but only the native client
called it: on the web, consent could be given and never taken back. The
AI settings now carry the standing decision and the control that changes
it, in all six languages.
2026-07-19 11:52:34 +02:00
Marc-André Bombeck 8bc1d75373 chore(db): renumber the session-token migration to 0255
0254 was taken by the document-summary state migration that landed on the
trunk first.
2026-07-19 11:41:12 +02:00
Marc-André Bombeck 12844a9206 test(auth): bind the rotated cookie through its hash after a password change
The assertion compared the cookie to the new session row's id, which only
held while the id WAS the cookie. Check the stored hash instead: it
proves the cookie authenticates the surviving row and that the row id
never reaches the client.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck a8b0365ca5 fix(auth): carry a generated secret in the session cookie, not the row id
The cookie was the Session row's cuid. cuid is built for collision-
resistant identity across distributed writers, not for unguessability —
it encodes a millisecond timestamp and a process-local counter and never
touches a CSPRNG. A session cookie is a bearer credential and has to be
unguessable, so it now carries 32 random bytes and only the HMAC of that
value is stored, matching how api_tokens and refresh_tokens already hold
their secrets.

Compatibility is passive, and deliberately so. A backfill cannot help:
the cookie already sitting in a browser is the cuid, and nothing we
generate server-side can be pushed into it — backfilling would sign
everyone out at deploy. Upgrading a row in place on first use loses to
the parallel requests a single page load fires: each mints its own
secret, one write wins, and the losers cannot re-emit the winner's
secret because only a hash is stored, so the browser can end up holding
a value the row does not honour. Instead, rows minted before the column
keep a NULL token_hash and stay resolvable by id, and are denied the
sliding-expiry extension so they drain within the 30-day session
lifetime. Every login from this release forward is on a secret at once,
and an id stops authenticating the moment its row holds one.

Note for operators: API_TOKEN_HMAC_KEY now also keys session cookies, so
rotating it signs browser sessions out as well as invalidating tokens.
It is already Core-required, so no deployment gains a new prerequisite.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck fd6a80a921 fix(nightscout): assert the public-host floor before persisting the URL
Egress was already correct — the client fetches through safeFetch with
requirePublicHost pinned off the user's opt-in, manual redirects and a
timeout — but the check on the value we STORE was only a side effect of
the live probe the connect route happens to run first. That makes the
guard contingent on the probe's placement rather than on the input.

Run isPublicUrl at input time, gated on the same self-hoster opt-in, so
the URL that gets encrypted onto the row is the URL that was validated.
Cover the alt-notation classes the helper knows about; each case asserts
the probe was never reached, which is what distinguishes the input-time
refusal from the fetch-time one.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck 0fca5ca207 fix(auth): bind a live refresh token to its issuing device
Rotation refused a replayed token under a spoofed device id (M-4) but
still rotated a *live* one presented from a different device, and then
inherited the caller's id onto the new row — putting the resulting family
outside the victim device's containment scope. Refuse the mismatch
outright when both ids are present; an absent id on either side stays
unattributable and rotates as before, so a client that sends no
X-Device-Id is not locked out.

The expiry refusal had no coverage at all: the standing test presented a
token that was never issued, so it passed on not_found and stayed green
with the expiry branch deleted. Replace it with the real secret plus a
just-inside-the-window companion, and cover the device binding.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck b00187e659 docs(api): correct the drifted request/response contracts
Field-by-field comparison of the handlers against the registered Zod
schemas across measurements, workouts, medications, daily and auth. The
spec was corrected to match the handlers; no runtime behaviour changed.

Measurements:
- GET /api/measurements returns `{ measurements, meta: { total, limit,
  offset, … } }` on all seven of its modes. The spec claimed a top-level
  `total` that no arm has ever emitted.
- The list rows are stored rows minus the notes ciphertext, or synthesised
  day-sum / sleep-segment rows, or pre-folded aggregate buckets. The
  resource declared seven fields; the rest are now on the wire contract.
- POST accepts a bare ARRAY body (the combined blood-pressure write) and
  answers with an array, and returns 409 on the dedup index.
- GET/PUT/DELETE /api/measurements/{id} carried no path entry at all.

Workouts: both GET operations run the module gate and answer 403
`module.disabled`; the reusable block existed but was never spread. The
batch ingest stays deliberately ungated and undocumented for it.

Medications: `nextDueAt` / `nextDueOverdue` are computed by the read paths
and absent from the create/update results, so they cannot be required on
the shared shape (the list row keeps them required). `stockUnitsRemaining`
sums a Decimal(12,4) and is fractional, not an integer. The cadence chips
object documented four fields that do not exist and missed the five that
do. `IntakeSource` admits APPLE_HEALTH.

Auth: the token bundle had its optionality inverted — `user` is always
returned, the token pair only for native callers. Adds the undocumented
403 `oidc_only`, the passkey 403/404, the TOTP enrolment 409s, the
attestation 400, the no-key-registered 409, the security-key 404s, the
refresh `revoke` response shape, and the path/query parameters three
operations read but never declared.

Daily was checked and is accurate; so are the batch ingest, series,
series-batch, bulk-delete, restore and by-external-ids routes.
2026-07-19 11:15:17 +02:00
Marc-André Bombeck eb5f81c84a fix(rollups): let a recompute replace every bucket in its window, not just the ones it recomputed
persistRollupRows scoped its delete to the [min, max] bucket range of the rows
the aggregate RETURNED. That made the repair self-limiting in exactly the case
that needs it: a recompute could only overwrite a bucket the live aggregate
still produces, so a bucket whose source rows had all gone was never in the
delete range and kept its stale aggregate.

Three shapes of the same hole. An empty aggregate skipped the write entirely
(the caller short-circuited on rows.length === 0). A returned range narrower
than the requested window left the edge buckets untouched. A type that lost
every reading in the window dropped out of the returned rows, so the per-type
delete filter could never reach it.

The DAY write hook masked it — it deletes its own bucket by hand when the day
empties — but nothing covered the WEEK / MONTH / YEAR worker, which recomputes
one bucket span at a time: deleting the last reading of a type in a week
dropped the DAY bucket and left the coarser ones stale forever. The coverage
probe still reported hasBuckets = true, so the read-swap served them.

A recompute now prunes the window it was asked for and writes its rows in the
same transaction. The single-bucket write hook keeps its own scope, which is
correct there because it already deletes its bucket when the aggregate empties.
Still one tier: the rollup replaces the legacy read and falls back to live SQL
only on a coverage miss.
2026-07-19 11:12:49 +02:00
Marc-André Bombeck 80258914f2 feat(documents): catch up unread documents when auto-read is switched on
The per-document summary job is enqueued at upload time and no-ops while the
documentsAutoAiRead opt-in is OFF. Nothing ever re-enqueued it, so the switch
only applied to documents uploaded AFTER the flip — a user who filled the vault
first and opted in later had documents that would never be read, and the
setting looked inert.

A genuine OFF->ON transition now schedules one bounded catch-up pass over the
user's un-summarised documents. It grants nothing of its own: it only enqueues
the ordinary summary job, which still re-reads the opt-in, re-asserts the
per-document egress consent and reserves the daily token budget. The pass
itself re-reads the opt-in first, so a toggle flipped back OFF before it runs
egresses nothing.

Bounded by an id-cursor walk capped at 200 documents per pass (a summary is a
provider call, unlike the local-compute thumbnail backfill). Idempotent at four
levels: the trigger fires only on a real transition, the per-user singleton
collapses a double toggle, the candidate set excludes summarised documents, and
the summary job skips one that already has a summary.
2026-07-19 11:09:57 +02:00
Marc-André Bombeck 851a82cc79 Merge pull request #555 from MBombeck/release/v1.30.31
release/v1.30.31 — Today shows a due dose; the briefing stops being discarded over figures it was given
2026-07-19 11:06:59 +02:00
Marc-André Bombeck 8e938a4f45 fix(documents): anchor extracted-fact provenance to the real document span
Every staged fact carried a sourceText the model echoed back, stored verbatim
as 'the exact verbatim span of the document the fact came from'. Nothing
checked that the span existed. A paraphrase, a normalised rendering, or an
outright invention was persisted and shown to the reviewer as a transcription
of their own clinical record — an anchor pointing at nothing, or at a span the
document never contained.

Treat the echo as a lookup key instead. anchorSourceText resolves it against
the extracted text (exact, then whitespace-collapsed and case-folded to absorb
OCR reflow) and stores the span the document actually carries, with its offset.
A quote that matches nothing is dropped and the fact is flagged unanchored;
vision mode has no extracted text at all, so its facts are unanchored by
construction. Rows written before this resolve to unanchored on read — their
quote is preserved but never presented as checked.
2026-07-19 11:06:14 +02:00
Marc-André Bombeck d40cb3eef3 fix(ai): align the corrective retry prompt with the validator that grades the reply
The retry suffix still described the strict insight wrapper that was removed
long ago: it demanded citations[] and warnings[] — fields insightResultSchema
does not have — required metricSource, rationale, id and severity on every
recommendation when all four are optional, and named none of the five fields
the live schema actually requires (classification, findings, correlations,
dataQuality, disclaimer).

So the one corrective pass steered the model away from the contract it was
being corrected towards. A reply that obeyed the correction could not
validate, fell through parseComprehensiveResult's raw-object escape hatch,
and the downstream surfaces read an unvalidated payload.

Describe insightResultSchema and nothing else, and carry the contract as data
in RETRY_CONTRACT_EXAMPLE so a test can assert prompt and validator agree.
2026-07-19 11:01:57 +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
Marc-André Bombeck f05e91e1ef test(e2e): keep the extraction-provenance line out of the document detail view 2026-07-19 10:55:16 +02:00
Marc-André Bombeck 02a56047a4 Merge branch 'main' into release/v1.30.31
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 10:49:00 +02:00
Marc-André Bombeck fadd21b3fd Merge pull request #554 from MBombeck/release/v1.30.30
release/v1.30.30 — four languages get their own words; a failed read stops claiming you have no data
2026-07-19 10:47:49 +02:00
Marc-André Bombeck 54634a4f12 Merge branch 'main' into release/v1.30.30
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 10:32:59 +02:00
Marc-André Bombeck 9cd0cc3d03 Merge pull request #553 from MBombeck/release/v1.30.29
release/v1.30.29 — connector write scope, fenced text, and the legacy budget helpers deleted
2026-07-19 10:16:32 +02:00
Marc-André Bombeck d85e7bf63c fix(documents): tell the summary outcomes apart instead of promising one forever
A summary is enqueued only when a document is uploaded while automatic
reading is already on, and nothing backfills the ones uploaded before
that. The detail view read a null summary as 'being generated' and said
so permanently, while the manual action row was hidden precisely when
automatic reading was on - so the promise could never be met and the
repair was out of reach.

Record what actually happened. Seven outcomes previously collapsed into
the same null: never enqueued, opted out, no vision provider, spent
budget, consent refused, unreadable file, provider error, plus the
withhold. Each now names itself and offers a manual generation, and what
that produces is kept.

The withhold rule is unchanged - refused prose is still never shown or
stored - but recording the refusal as a state makes it re-attemptable
instead of a permanent dead end.

Also removes the read-by-AI footer.
2026-07-19 10:14:35 +02:00
Marc-André Bombeck cd502d5aef Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31 2026-07-19 10:07:42 +02:00
Marc-André Bombeck 6f3d06c39b Cover the summary DTO's decrypt-failure degrade
A READY row whose ciphertext will not open reports UNAVAILABLE, so the view
does not render a heading over nothing. Nothing asserted that, and removing the
degrade left the suite green.
2026-07-19 10:06:33 +02:00
Marc-André Bombeck d773f460e5 Tell the four document-summary outcomes apart
Opening a document said "the summary is being prepared" and kept saying it.
The view had one fallback for a null summary column and used it for every
outcome: a document nobody ever enqueued, a missing vision provider, a spent
budget, a withdrawn consent, an unreadable file, a provider error, and a
summary the outbound screen withheld all rendered as work in progress.

The common case is not the screen. The background job is enqueued at exactly
one place — a fresh upload with the auto-read opt-in already on — and no
backfill reaches anything else. Every document uploaded before the toggle went
on has a null column no job will ever fill, and the view promised all of them a
summary that was never coming.

Record the outcome as `summary_state` (migration 0254, additive, defaults to
NONE which is truthful for every existing row) and branch on it. Only PENDING,
set when a job genuinely exists, may claim generation is running. NONE,
WITHHELD and UNAVAILABLE each say what happened and offer to generate one.

The withhold keeps its rule and loses its dead end. Blocked prose is still
never shown and never stored — only the fact of the refusal is kept, and it is
no longer terminal, so asking again re-runs the gauntlet. That was the original
intent; leaving the column null defeated it, because null read as "generating"
and the guard meant nothing would ever overwrite it.

The on-demand summary now persists what it produces. It is the only repair path
for the documents the job never ran for, and re-deriving the same paragraph on
every open costs a provider call for nothing. Generation is still only ever a
click — nothing runs because a view rendered. Transcription stays session-only.

Also drop the "read by AI" line from the detail footer.
2026-07-19 10:01:06 +02:00
Marc-André Bombeck e4f5aa0b09 fix(ui): drop the redundant Today briefing link, align the sheet close control
The Today hero carried a read-the-full-briefing link to /insights while
the health-score ring above it already opened the same page — two doors
to one room in a single card. The ring stays; the link and its six
locale strings are gone.

The document sheet's close control sat at the plain p-4 right edge, but
an icon button carries its own inner inset, so the glyph landed ~28px
from the frame against a title starting at 16px. Trim the right padding
by that inset when a consumer renders its own trailing control, leaving
the tap target reaching the frame.

Also rewrites the v1.30.15-v1.30.31 changelog entries: they had drifted
into summary lines, internal vocabulary and reasoning asides that do not
belong in a release note.
2026-07-19 09:46:57 +02:00
Marc-André Bombeck db86b4f76d chore(release): v1.30.31 — surface a due dose on Today; ground the briefing against what the model was actually given 2026-07-19 09:23:45 +02:00
Marc-André Bombeck cbb9528cb2 test(daily): cover the due-dose rail item and the briefing grounding disposal 2026-07-19 09:14:09 +02:00
Marc-André Bombeck f8bb8f4b9b fix(daily): surface a due dose on Today and keep the last grounded briefing
The Today hero stayed on its all-clear line while a dose was actually due:
the dose-window item only fired once the slot was already overdue, so a
dose the user could take right now produced no rail item at all. Emit the
item from the moment the dose becomes takeable — the on-time window's lead
plus the early grace, the same instant logging it starts crediting the
slot — and give the two faces distinct wording and status: a due dose reads
calm and informational, an overdue one keeps the warning. A settled day
still emits nothing, and a cached block whose anchor has since passed keeps
rendering neither face. Push reminders are untouched; they still fire only
on overdue.

The briefing grounding gate discarded the whole block over one number it
could not verify, and it could not verify plenty of legitimate ones: the
allow-set was a hand-maintained list of feature blocks that had already
drifted from the prompt three times, and still omitted medication
compliance, the context counters, the correlation coefficients, every
coverage object and the comparison snapshot — all of which the prompt hands
the model verbatim. Derive the allow-set by walking the prompt payload
instead, so grounded means exactly what it says: a figure the server gave
the model. A fabricated number still matches nothing.

Fix the disposal to match. A content-failed retry left a hole while a
transport-failed one fell back to the previous cached briefing, though in
both cases the rejected text is discarded and the previous briefing passed
this same gate when it was written. Fall back on both paths, and only
report the briefing as withheld when there is genuinely nothing to show.
The on-demand route had no fallback at all; it now mirrors the background
generator that writes the same cached payload.
2026-07-19 09:11:57 +02:00
Marc-André Bombeck 1679d24660 chore(release): v1.30.30 — locale completeness and honest failed-read states 2026-07-19 07:02:48 +02:00
Marc-André Bombeck d4c4e5d08b Merge branch 'worktree-agent-a9a623139a6af16d5' into release/v1.30.30 2026-07-19 06:58:47 +02:00
Marc-André Bombeck 943c6ad01f Bail the rhythm cards on a failed shared read
With the error notice hoisted to the page, the three cards fell through
to their skeleton on a failed read — a spinner that can never resolve.
Return null instead, so a card rendered outside the page's gate is still
honest about having nothing to show.
2026-07-19 06:54:35 +02:00
Marc-André Bombeck 59ffa02b0c Show one sleep-rhythm error, not three
The rhythm, average-sleep and chronotype cards on /insights/sleep share
a single ["sleep-rhythm"] read, and each rendered its own copy of the
same failure sentence — so one failed read painted the identical notice
three times, none of them offering a way to try again.

The page owns the shared state now: one notice with a retry in place of
the card row. The cards keep their loading and empty handling and drop
the duplicated error branch.
2026-07-19 06:49:28 +02:00
Marc-André Bombeck 323b111a24 Resolve plural tiers through the i18n context
Importing the plural helper directly from the dashboard trend tile cost
~78 KB gz across the route chunks: the module is tiny, but a new edge
from a widely-instantiated component gets duplicated into every chunk
that contains it rather than hoisted. The provider is already in every
chunk, so expose a plural-aware tCount() there and let components call
that instead. Total client JS goes back under budget.

Also fix the two tests the honest-loading-state change invalidated: the
mental-wellbeing landing tests seeded a never-resolving read and so now
render skeletons rather than instrument cards, and the achievements page
needs a router in scope for its module-gate redirect.
2026-07-19 06:39:29 +02:00
Marc-André Bombeck f31a12e174 Cover the duration and Intl formatters in the locale guard
The reminder, coach and plural fixes were pinned; the three formatter
fixes were not. Add the duration abbreviations across all six locales
and a structural check that the cycle calendar, device-score tile and
sleep-stage axis pass a resolved app locale to Intl rather than
undefined, which silently resolves to the browser locale.
2026-07-19 06:11:15 +02:00
Marc-André Bombeck c87ce161ba Never render a failed read as an absence of data
"No data yet" is a claim about the user's record. Making it when the
read failed states something false about their health history.

The mental-wellbeing surface was the one that mattered: the assessment
history read dropped isError entirely, so a failure left both
instrument cards showing "not taken yet" and the detail sheet showing
an empty trend. Someone with months of screening results was told, on
a mental-health surface, that the results did not exist. It now tells
loading, failed and genuinely empty apart, with a retry on the failure.

Also swept:
- the workouts insights page read isEmpty but not isError, so a failed
  list read fell through every branch to null and painted a titled
  shell with nothing in it;
- the micronutrients card (not in the original list) rendered its "no
  nutrient data" empty state on a failed read, while its sibling
  hydration card already used the shared error card;
- the medication detail page and the custom-metric values list showed
  an honest error with no way out. Both keep retry: false — right for
  a deleted row, wrong for a transient failure — and gain a
  user-initiated retry instead, which cannot storm;
- /achievements rendered a blank page on a direct hit with the module
  disabled. It now bounces to /insights like every other gated page.

The achievements query error branch, the custom-metric error branch and
the chronotype notice were already correct and are left alone.
2026-07-19 06:09:49 +02:00
Marc-André Bombeck c23f2545cc Give every locale its own strings on the non-prompt surfaces
Four locales received English on surfaces whose translations were
already complete, because the resolving code was a de/en binary.

- Medication reminder push and the Telegram inline-keyboard labels
  admitted only de/en, so es/fr/it/pl got the English template on every
  channel. Route the resolver through a shared coerceLocale().
- The Coach's trailing "reply now in X" clause said English for any
  non-German locale, overriding the correct system-prompt directive.
  Read the language name from a shared map instead.
- Coach refusal copy was a de/en constant pair, answering four locales
  in English at the one turn the Coach is declining to help. Move the
  copy into the bundles; the EN/DE constants now derive from them.
- The sleep-stage chart formatted axis ticks as en-US for four locales;
  the cycle calendar and the device-score tile followed the *browser*
  locale rather than the app locale.
- Three components carried a byte-identical duration formatter that
  branched on German. Lift it to one key-driven helper.

Polish needs a third plural tier: 2-4 takes a "few" form a two-form
scheme cannot express, so "2 godzin temu" read as "2 of-hours ago".
Add a pluralKey() helper driven by Intl.PluralRules and a Few tier to
the relative-time and stale-hint keys. The five locales whose rules
have no "few" category resolve exactly as before by construction, and
their Few values mirror Other. Teach the reverse-coverage guard to
expand a pluralKey() base into its three tiers.
2026-07-19 06:02:27 +02:00
Marc-André Bombeck 57626c9c28 chore(release): v1.30.29 — MCP write scope, fenced connector text, and the last legacy budget helper removed 2026-07-19 06:01:01 +02:00
Marc-André Bombeck 0e0f006702 Merge branch 'worktree-agent-a4a0ede7316e1ad06' into release/v1.30.29
# Conflicts:
#	src/lib/insights/comprehensive-generate.ts
2026-07-19 05:56:05 +02:00
Marc-André Bombeck f96c18574b Correct two test headers that described the removed cross-check 2026-07-19 05:53:05 +02:00
Marc-André Bombeck e6e2210314 Merge pull request #552 from MBombeck/release/v1.30.28
release/v1.30.28 — the daily briefing counts its spend too
2026-07-19 05:52:23 +02:00
Marc-André Bombeck 4183202ed3 Delete the citation-enforcement chain that never ran
generateInsight() parsed against the strict aiInsightResponseSchema,
cross-checked that every recommendation's metricSource appeared in
citations[], and threw InsightSchemaError on a second failure. Its only
caller was runWithFallback(), which had no production caller — every
live surface runs runRawCompletionWithFallback. So none of it executed.

Wiring it into the live path was the alternative and it is not viable:
the live insightResultSchema has no citations[] field at all and treats
metricSource as optional, so the cross-check would have rejected
essentially every generation rather than tightening anything.

Removed: runWithFallback and its param/result types, generateInsight
and its private helpers, findUncitedRecommendations,
findRecommendationsMissingRationale, the never-thrown InsightSchemaError
and the isHardProviderFailure branch that excluded it, and
computeConfidence — a deterministic override that only ran inside the
dead wrapper.

Kept: buildRetryCorrectionMessage, which comprehensive-generate sends on
its own retry leg; the file it lived in is renamed to match what it now
holds. aiInsightResponseSchema stays as the shape that message
describes. Note the retry prompt asks for a stricter payload than the
live validator requires — that mismatch predates this and is left alone.

computeCitationCoverage is untouched and still live: it is a metric, not
a gate, and v1.30.24 wired it into the comprehensive post-parse step.

Chain-runner coverage the deleted tests carried (last-working cache,
empty-input guard) is ported onto runRawCompletionWithFallback, which
shares the machinery.
2026-07-19 05:50:57 +02:00
Marc-André Bombeck 576be8b746 Harden the MCP write surface and fence its free text
Three things the write surface claimed but did not hold.

Scope: the run bodies never re-checked ctx.canWrite. Enforcement was
real but lived entirely in the registration decision, and the test
asserted registration only — so a refactor that registered the write
tools unconditionally and meant to refuse at call time would open the
surface with the suite still green. Wrap every run body in a scope
guard at the registry level, so a new write tool inherits it, and drive
the run bodies directly from a read-only context in the test.

Free text: medication names, doses, treatment classes and lab analyte
names were interpolated into tool prose with no delimiter. A lab
analyte name is transcribed out of an uploaded document, so a hostile
document chooses it, and the write tools ride the same wire. Fence the
assembled prose with the shared data-fence markers and scrub short
result titles, which would leak markers into a host's result list.

Confirm: the header called the confirm flag a PROTOCOL-LEVEL CONFIRM
GATE. It is a client-asserted boolean with nothing binding it to a
prior preview. Binding it to a server-issued preview token would prove
only that a preview call happened, which any client can do, so it buys
no human-in-the-loop for a round trip and server state the stateless
transport does not want. Describe it as the convention it is and name
what actually bounds the blast radius.
2026-07-19 05:38:56 +02:00
Marc-André Bombeck f61483d19b fix(sw): repair the service-worker version fallback after the release merge
The changelog merge left conflict markers inside the CACHE_VERSION
assignment. The version generator only rewrites the version string, so
it could not repair the broken structure, and every service-worker test
failed while both branches were green on their own.
2026-07-19 05:37:23 +02:00
Marc-André Bombeck b77a721474 Remove the superseded budget read-then-write pair
enforceBudget and recordSpend have no callers left. Both defects they
carried recurred every time a surface copied them: a TOCTOU window
between the read and the bump, and a cap parameter defaulting to the
operator ceiling so an omitted argument rationed a self-hoster paying
for their own key. Deleting them means a future surface cannot reach
for the older, simpler-looking helper and reintroduce either.

getDailyTokenSpend goes with them — it existed to feed the read half.
The ledger-poisoning clamps recordSpend carried (NaN, negative,
fractional token counts) move to reserveBudget, where the property now
lives. Refresh the doc comments that still narrated the old flow.
2026-07-19 05:35:13 +02:00
Marc-André Bombeck 5c2bee79d8 Merge branch 'main' into release/v1.30.28
# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
#	public/sw.js
2026-07-19 05:32:41 +02:00
Marc-André Bombeck de088febe6 Cap the medication-extract budget by the paying credential
The route used the enforceBudget + recordSpend read-then-write pair and
passed no cap, so every call measured against the operator ceiling — a
self-hoster on their own key was rationed by the operator's bill while
paying for their own usage. It also left the TOCTOU window the rest of
the codebase closed: concurrent calls could each read a sub-cap total
and all proceed.

Move the gate after the chain resolves and reserve atomically against
resolveDailyCap(chain), matching runStatusCompletion and the document
paths. Reconcile on every exit: the provider's reported count on a
completed call, a full refund when nothing was generated.
2026-07-19 05:31:38 +02:00
Marc-André Bombeck 624ce3292c Merge pull request #551 from MBombeck/release/v1.30.27
release/v1.30.27 — three quiet miscounts
2026-07-19 05:31:17 +02:00
Marc-André Bombeck a3be2d7c92 chore(release): v1.30.28 — meter the briefing path, the last provider entry a sync can reach 2026-07-19 05:26:16 +02:00
Marc-André Bombeck 395d59fd30 Merge branch 'worktree-agent-aeb38510a52ce89ac' into release/v1.30.28 2026-07-19 05:22:51 +02:00
Marc-André Bombeck a5631f3d12 chore(release): v1.30.27 — merge the resting read path, fix the rollup window and the batch duplicate 2026-07-19 05:20:29 +02:00
Marc-André Bombeck cd7e2bc0c4 Merge branch 'worktree-agent-ae1242804dbb4517b' into release/v1.30.27 2026-07-19 05:15:52 +02:00
Marc-André Bombeck 79922631ce Cover the briefing tier's ledger accounting
Pin the four guarantees at the new chokepoint and at both call sites: spend
lands on the ledger, over-cap is refused before any egress, a BYOK chain is
measured against the user-plan ceiling rather than the operator's, and a
failed call refunds in full. Also pin the ordering the consent gate depends
on — a consent-blocked user is never charged.
2026-07-19 05:15:30 +02:00
Marc-André Bombeck f2b146556a test(insights): cover the full window with resting rows in the workout-PULSE case
The fixture described a clean resting series but supplied rows for only
twenty of the thirty days it fed PULSE for. Now that the resolver merges
per day, the ten resting-free days are estimated from their own readings
— and on a day whose only readings are a 150 bpm workout the honest
estimate falls outside the band. Match the spans so the case tests what
its name claims; gap-day behaviour is pinned in resting-pulse.test.ts.
2026-07-19 05:13:29 +02:00
Marc-André Bombeck 79c663e680 Merge pull request #550 from MBombeck/release/v1.30.26
release/v1.30.26 — text from your documents is treated as data, not instructions
2026-07-19 05:10:37 +02:00
Marc-André Bombeck 8a1d7502d0 Meter the comprehensive briefing tier against the daily token ledger
The status tier funnels every generation through runStatusCompletion, which
reserves against the day's ledger before egress and reconciles after. The
comprehensive briefing did not: the shared generator and the on-demand
POST /api/insights/generate route both called the provider chain directly, so
none of that spend reached coach_usage and no daily ceiling applied to it.

Route every briefing provider call — first pass, JSON-shape retry,
grounding-correction retry, and the daily paragraph re-roll — through one
chokepoint that reuses reserveBudget / reconcileSpend and resolveDailyCap. The
reservation lands after the consent gate, so a consent-blocked user is never
charged, and a failed or timed-out call is refunded in full.

Over-cap is a typed skipped outcome for the off-request generator (no upstream
fault to retry against, so no failure marker and no retry enqueue) and a 429
for the on-demand route.
2026-07-19 05:10:12 +02:00
Marc-André Bombeck 3ccc991851 test: pin the rollup window, the intra-batch stats: winner, and the resting merge
Each case fails on the pre-fix code: the rollup tests on an unaligned
recompute window, the batch tests on the older stats: snapshot winning,
and the resting test on a series that stops at the fold horizon.
2026-07-19 05:08:20 +02:00
Marc-André Bombeck db08962d41 fix(analytics): merge resting and proxy heart-rate series per day
resolveRestingPulseSeries returned the resting branch as soon as a
single RESTING_HEART_RATE row existed and never consulted the PULSE
proxy again. Since the retention fold began minting derived resting
rows for the days it folds, a proxy account carries derived rows for
old folded days and nothing but raw PULSE for the recent ones — so the
visible series stopped dead at the fold horizon.

Merge per day instead: a day with a resting row uses it, a day without
falls back to the proxy. which stays a three-value labelling signal and
reports proxy as soon as one point is estimated, so a partly-estimated
series is never labelled resting heart rate.

Widen the intraday baseline read to pull PULSE history when the newest
resting row is stale, not only when the rows are few — the count gate
alone pinned the baseline to the fold horizon.
2026-07-19 05:05:41 +02:00
Marc-André Bombeck a877dcddf5 fix(measurements): let the newest stats: entry win inside one batch
Two entries carrying the same stats: key in one batch both miss the
existing-row probe, so the overwrite branch never runs, both land in the
bulk insert, and skipDuplicates keeps whichever the unique index saw
first. The older snapshot of a per-day cumulative total or of a filling
ten-minute heart-rate bucket therefore won and the newer, larger figure
was dropped silently — both entries reported inserted, the counters
still summed, and the client checkpointed past both.

stats: rows carry overwrite semantics by contract, so within one batch
the last entry for a key is the authoritative one. Resolve it up front
and report the superseded earlier entries as duplicate with a
superseded_in_batch reason, a status the client already checkpoints
past.
2026-07-19 05:05:41 +02:00
Marc-André Bombeck 3d1a1cec86 fix(rollups): align the recompute window to whole buckets
recomputeUserRollups filtered measurements by a caller-supplied from/to
but grouped them by date_trunc, and persistRollupRows then deletes every
rollup row in the bucket range it is about to write. A from that landed
mid-bucket therefore replaced a complete bucket with an aggregate
computed only from the readings after that instant.

Every caller passes a moving instant — now minus ninety days on the
read-path refresh, an arbitrary instant on the coverage fallback, now
minus five years on the boot backfill and the restore path — so a
different oldest bucket was corrupted on each invocation and nothing
repaired it. Snap both edges out to whole buckets via bucketSpan, whose
own contract note already required exactly this of every recompute.
2026-07-19 05:05:28 +02:00
Marc-André Bombeck e9424ef0be chore(release): v1.30.26 — fence document-sourced text and fix the sanitiser that defeated itself 2026-07-19 04:58:17 +02:00
Marc-André Bombeck eb443a314c Merge branch 'worktree-agent-a9fff6921d1f27c02' into release/v1.30.26 2026-07-19 04:54:34 +02:00
Marc-André Bombeck d0553cdace Guard the fence call sites structurally
The fencing helpers were covered but the calls were not, so removing a call
would have left every test green.
2026-07-19 04:48:20 +02:00
Marc-André Bombeck abb9a37672 Cover data-field injection in the red-team battery and derive the snapshot guard
The red-team corpus probed only user messages and model replies. Neither is
the channel that reaches the prompt unguarded: a lab analyte, panel or unit
name, or a medication label, none of which the account holder authors when
the row was committed from an uploaded document.

The free-text guard's file list was hardcoded and had rotted — three of its
four entries were no longer reachable from the snapshot, and it covered one
of the dozen files that actually emit free text, which is why the lab fields
went unnoticed. Derive it from the import graph instead; it now covers 75
files, with a floor assertion so a resolver break cannot silently empty it.

Deriving the list immediately surfaced a second leak: features.ts shipped a
raw medication name and dose into the snapshot medications block.

Also fix sanitizeForPrompt itself. It deleted control characters, which
welded neighbouring tokens together and destroyed the word boundary its own
injection patterns anchor on, so a payload preceded by a newline survived
because of the newline. Replace with a space and collapse before matching.
2026-07-19 04:47:53 +02:00
Marc-André Bombeck 0511e36ac9 Fence user-controlled text in the Coach and document-extraction prompts
The Coach SNAPSHOT was spliced into the prompt bare. Its JSON leaves are not
all server-computed: lab analyte, panel and unit names are transcribed by a
model out of an uploaded PDF and stored verbatim as the biomarker name, so a
hostile document chooses those strings. The about-me block has been fenced
since v1.16.1; extend the same pattern rather than adding per-field escapes.

- Move the fence markers and the wrapping primitive into a shared data-fence
  module. Content is scrubbed against every known marker, not just its own
  pair, so text in one block cannot forge another block's boundary.
- Fence the SNAPSHOT with an explicit data/instruction frame.
- Fence the OCR text handed to the document extractor; the previous
  'OCR TEXT:' label was a label, not a boundary.
- Sanitise analyte, panel and unit at the labs source, which feeds both the
  snapshot prompt and the labs_read tool result, and the medication label in
  the adherence storyline.
- Capture a ---REMEMBER--- sentinel as proposed rather than active. It was
  the only model-driven write with no confirm step, and the reminder recall
  block fed it back into every later turn. Proposed reminders are already
  excluded from that block and already carry a confirm control.
2026-07-19 04:39:31 +02:00
Marc-André Bombeck 813ad3ec5a Merge pull request #549 from MBombeck/release/v1.30.25
release/v1.30.25 — generated cards count against a budget; long conversations get cheaper again
2026-07-19 04:38:48 +02:00
Marc-André Bombeck 81e66cbc5c chore(release): v1.30.25 — meter the status tier, bound the locale fan-out, restore snapshot-once 2026-07-19 04:27:54 +02:00
Marc-André Bombeck b1370626fc Merge branch 'worktree-agent-acf4680fc308d77c8' into release/v1.30.25 2026-07-19 04:24:13 +02:00
Marc-André Bombeck 7edf79bff3 Cover the status ledger, the locale collapse, and snapshot re-grounding
The status-provider suite drives the real budget module against an
in-memory stand-in for the day's usage row, so it exercises the atomic
reserve/reconcile path rather than asserting a mock was called: spend
lands on the ledger, an exhausted day is refused before any egress, a
failed generation is refunded, and a chain on the user's own key is
measured against the user-plan ceiling rather than the operator's.

The snapshot suite walks a 60-turn conversation and pins the full
snapshot to exactly two turns — the first and the elision crossing —
including the case where a lost reply shifts the turn parity.
2026-07-19 04:18:16 +02:00
Marc-André Bombeck b940f5667d Merge pull request #548 from MBombeck/release/v1.30.24
release/v1.30.24 — the safety check on generated text now covers every surface and every language
2026-07-19 04:16:34 +02:00
Marc-André Bombeck 0ddc5097e4 Meter the status tier and stop the snapshot re-shipping every late turn
The provider chokepoint every status and reference generator funnels
through ran with no budget accounting at all. Nine generator families
egressed behind it — the specialised status cards, the generic metric
cards, biomarker cards, the batched assessment, the derived scores, the
period narrative, and the off-request Coach memory workers (rolling
summary, fact extraction, plan proposals) — and none of that spend landed
on the daily ledger or met any ceiling. On an operator-key account it was
unmetered operator spend.

Reserve and reconcile around the provider call, using the same atomic
upsert the Coach and document paths use. The ceiling follows the cost
owner: a chain whose primary is the operator's own credential draws the
operator ceiling, everything else draws the user-plan ceiling, so a
self-hoster on their own key is never rationed by the operator's bill.

The status enqueue keyed its de-dupe singleton on (user, metric, locale).
The locale on a status read is client-chosen, so each of the six
supported locales opened its own slot and one legitimate generation pass
could be multiplied into six. Key on (user, metric) and carry the locale
in the payload instead.

The Coach re-shipped the full snapshot on every turn past the history
cap, not just the turn that crosses it, so a long conversation paid the
per-turn snapshot cost the snapshot-once model exists to remove. Re-ground
on the crossing only, tested as a narrow window rather than an equality
because a turn advances the count by one or two depending on whether the
reply persisted.

Give /api/ai/test a daily ceiling and a ledger entry. Its burst bucket
bounded rate but nothing cumulative, and with an empty body it resolves
the same chain generation uses — which can be the operator's credential.

Correct the memory-refresh worker comment, which asserted a budget check
inside the chokepoint that did not exist.
2026-07-19 04:15:28 +02:00
Marc-André Bombeck 3d776a236a chore(release): v1.30.24 — one locale-aware safety screen on every model-output boundary 2026-07-19 04:05:37 +02:00
Marc-André Bombeck 2d7a2b5c7c Merge branch 'worktree-agent-a92158cf1cc1494a3' into release/v1.30.24 2026-07-19 04:01:47 +02:00
Marc-André Bombeck 95e4dce991 Drop the status-card imports the finalize helper subsumed 2026-07-19 04:00:06 +02:00
Marc-André Bombeck 544ae90365 Wire citation coverage into the live path and cover the screen in all six locales
The coverage check hung off generateInsight, whose only caller runWithFallback
has no production caller left -- every live surface routes through
runRawCompletionWithFallback instead. The admin quality dashboard was reading a
metric nothing emitted. Widen the input to the structural shape both payloads
share and call it from the comprehensive post-parse step, so the annotation
reflects real generations.

Writing the locale tests found three gaps the English-only banks hid: Polish
verb suffixes fell outside the ASCII word class, a trailing word boundary after
an accented character never closed, and German subordinate clauses put the verb
last so the natural phrasing of a dose instruction never matched. The payload
collector also skipped legacy bare-string recommendations entirely.
2026-07-19 03:54:33 +02:00
Marc-André Bombeck 0becc2a56d Screen the whole comprehensive-insight prose surface before it persists
The briefing gate grades numbers only, and only the paragraph, signals and
key-findings blocks -- so a digit-free risk assertion with a dose suggestion
attached passed it untouched, and recommendations and summary were not graded
at all. Screen every readable prose field against the insights contracts.

A safety violation cannot be surgically stripped the way an ungrounded number
can: the offending sentence may sit in any field, and deleting one would leave
the rest referring to it. So the background generator fails the whole run and
persists nothing, leaving the previous cached payload in place, while the
user-initiated route returns an explicit error for the same reason.
2026-07-19 03:47:23 +02:00
Marc-André Bombeck 70e6e5e374 Enforce the no-causation rule in all six locales and screen the period narrative for dose and risk
The causal bank lived in the narrative validator as an English and German list,
so the no-causation rule was enforced in code on one surface and for two of the
six locales it ships in. It now comes from the shared screen, which carries all
six, and the narrative also gets the dose and risk-score contracts it never
checked -- persisted prose with a dose imperative in it is as unsafe as a chat
reply with one.

Red-team cases carry their language. The German outbound case was probed as
English, which would have reported a pass the guard never earned.
2026-07-19 03:43:10 +02:00
Marc-André Bombeck a6851b7e80 Merge pull request #547 from MBombeck/release/v1.30.23
release/v1.30.23 — switching a module off holds on the connector wire too
2026-07-19 03:40:17 +02:00
Marc-André Bombeck 97df5c4704 Screen the document summary and split its policy by surface
The summary is model prose about the document and is contractually
descriptive-only, but it was returned to the browser verbatim with no guard.
Screen it against the insights contracts and let each caller apply its own
policy: the on-demand route replaces the text with a short honest statement
because a user is waiting on it, while the background job persists nothing --
its updateMany is guarded on a null summary, so a stored refusal would stamp
the document permanently with no path to regenerate.

Transcription stays unscreened by construction. It reproduces the user's own
document verbatim; a discharge letter that says to increase a dose is the
prescriber's instruction, and censoring it would delete the user's own record
from their own view. Locale is required on the summary input type and absent
from the transcription input so a caller cannot silently fall back to the
English banks.
2026-07-19 03:39:31 +02:00
Marc-André Bombeck ca37fab63c Screen model output on the status-card surface and thread locale into the outbound guard
The outbound safety screen ran on the Coach only. A dose-change imperative or
a fabricated clinical risk score written onto a per-metric, biomarker or
derived-score status card persisted as that day's cached assessment, while the
identical sentence in the Coach was caught and replaced -- the only transform
between provider and persistence was whitespace normalisation.

Move the pattern banks into a shared screen that takes the reader's locale.
The old signature took no locale, so the dose-refusal and risk-score contracts
had no enforcement for fr/es/it/pl even though the ground rules ship in all six
contract files. Banks now cover all six locales and each screen runs the
reader's bank plus English, since a provider often answers in English
regardless of the language directive.

Status cards withhold on a trip: serve the deterministic stub and persist no
model text. Nobody waits on a background-generated card, and persisting a
refusal would pin it to the card for the day.
2026-07-19 03:36:42 +02:00
Marc-André Bombeck 4b60852f9f chore(release): v1.30.23 — module switches hold on the connector wire 2026-07-19 03:27:30 +02:00
Marc-André Bombeck 3f5c34fd08 Merge branch 'worktree-agent-a6590e47058405dcf' into release/v1.30.23 2026-07-19 03:23:53 +02:00
Marc-André Bombeck 61d0d01cf1 fix(jobs): make the queue-policy reconcile observable
The reconcile is the only thing that carries a policy onto a queue that
already exists, so whether it ran is the difference between the previous
release working and doing nothing on an upgraded instance. It reported
that through workerLog at info level, which is deliberately silent - so
the intended deploy signal could never appear, and the change was not
verifiable in production.

Emit a boot wide event instead, on every boot including the no-op one:
a reconciled count of zero on a later boot confirms an earlier boot
already migrated everything, whereas silence is indistinguishable from
the reconcile never running.
2026-07-19 03:23:53 +02:00
Marc-André Bombeck 8e94f6cb20 Match the doctor-visit resource read signature in its gate test 2026-07-19 03:22:19 +02:00
Marc-André Bombeck bb41b1673e Cover the module gates on the MCP export reads
One suite per gate: the rich reads omit with an honest module_disabled
reason, the correlations reader omits and touches no channel, the
doctor-visit resource and prompt refuse without assembling the record,
and the clinician share collapses to its documents-only state. Each
asserts the operator availability path alongside the user toggle, and
that the gate short-circuits before the engine runs rather than building
the answer and withholding it.

The structural test pins that every metric the rich reads can resolve has
either an owning module or a written review decision, and is explicit in
its own comment about what it cannot prove — it does not show that any
read calls the gate. Writing it surfaced an unowned HRV_RMSSD, the
fallback type an RMSSD-only account resolves to, which would have served
recovery data the primary type refuses.
2026-07-19 03:18:25 +02:00
Marc-André Bombeck 42235076ac Merge pull request #546 from MBombeck/release/v1.30.22
release/v1.30.22 — background work stops piling up on itself
2026-07-19 03:11:26 +02:00
Marc-André Bombeck fa2b9531f7 Gate the MCP export reads on the module map
The MCP surface reached three data families that deliberately bypass the
Coach snapshot and so inherited none of its module narrowing: the
doctor-report aggregate behind healthlog://report/doctor-visit and the
doctor_visit_summary prompt, the rollup-backed rich reads (compare_metric,
get_metric_baseline, detect_changepoints), and the correlations reader.
A disabled module — or one an operator switched off server-wide — still
egressed to a third-party assistant.

Gate at the read rather than per call site. The module-to-data ownership
table moves out of the Coach snapshot builder into
lib/modules/measurement-scope so every read gates off one map; the rich
reads resolve metrics through a module-aware resolver; the correlations
reader gates on insights itself, which also closes the same gap on the
Coach chat tool and the metric-page read strip.

Per-domain reads omit with an honest module_disabled reason; the
whole-record aggregate refuses, mirroring /api/export/health-record.
collectDoctorReportData gains a fail-closed backstop on its own module
key. The clinician share link, found while tracing the aggregate's
callers, had the same gap and now collapses to its documents-only state.
2026-07-19 03:09:29 +02:00
Marc-André Bombeck a6600f0215 chore(release): v1.30.22 — background work stops piling up on itself 2026-07-19 03:00:36 +02:00
Marc-André Bombeck e6862df048 Merge branch 'worktree-agent-a87a74abcff7c248e' into release/v1.30.22 2026-07-19 02:56:57 +02:00
Marc-André Bombeck 99dc3b6915 Merge pull request #545 from MBombeck/release/v1.30.21
release/v1.30.21 — dose reminders land on the right hour across a clock change
2026-07-19 02:56:53 +02:00
Marc-André Bombeck 81e564a724 chore(release): v1.30.21 — dose reminders land on the right hour across a clock change 2026-07-19 02:44:42 +02:00
Marc-André Bombeck caa37b74b5 Merge branch 'worktree-agent-af686749a7ca278d4' into release/v1.30.21 2026-07-19 02:38:34 +02:00
Marc-André Bombeck 44046eda22 Pin the queue policy decisions with tests
Two layers. The integration suite runs a real pg-boss against a real
Postgres and proves the semantics the policies rest on: that a bare
singletonKey under the default policy de-duplicates nothing, that
exclusive suppresses a duplicate while the first job is queued AND while
it is active, that short suppresses only while queued and admits a send
once the job is active, and that a distinct key always enqueues.

It also pins the two facts that make the fix reach a running instance:
createQueue does not change the policy of a queue that already exists,
and updateQueue refuses a policy change, so the reconcile is required.

The unit guard couples each enqueue site to its registrar entry. The key
and the policy live in different files with nothing in the type system
holding them together, so deleting an entry would leave the call site
looking correct while it silently stopped working.
2026-07-19 02:31:45 +02:00
Marc-André Bombeck 40ef4b40b0 test(medications): pin the slot dedupe contract across differing reference instants
The projector, the reminder worker and the intake write path key on the slot
instant byte-for-byte, and all three derive it from a DIFFERENT reference —
a cron tick, a later cron tick, and the moment the user pressed take. That
is the whole hazard: the primitive used to sample the zone offset at its
reference, so on a transition day two ticks on opposite sides minted
instants an hour apart for the same slot and the dedupe key stopped
matching.

The existing DST cases compare the resolver to a projector mint computed
from the SAME reference, which cannot see this. Pass deliberately different
references across both Berlin transitions.
2026-07-19 02:28:01 +02:00
Marc-André Bombeck a54cb0a47f Merge pull request #544 from MBombeck/release/v1.30.20
release/v1.30.20 — refuse a restore whose payload names a different owner
2026-07-19 02:27:15 +02:00
Marc-André Bombeck 4791d6f543 test: pin the suite's timezone and make the zone-sensitive fixtures say which zone they mean
The suite was not timezone-pinned. CI runs UTC, so a whole class of
day-bucketing defect was invisible to the gate while a contributor outside
UTC saw a red suite on unmodified main. Verified: a UTC+14 host fails five
tests on an otherwise green tree. Pin TZ=UTC in both vitest configs; tests
that need a different HOST zone now set and restore it explicitly.

The resting-pulse fixtures passed offset-less ISO literals, which parse as
host-local, so they could not distinguish the Berlin day-key the
implementation uses from a UTC one — swapping the day-key's zone left the
file green. Build each instant explicitly in Berlin and add cases whose
local day and UTC day genuinely differ; that swap now fails two of them.

Delete the 'DST boundary (spring-forward)' compliance case. It passed no
timezone, so the slot builder took the legacy UTC path where the date
carries no transition, and it asserted totalExpected === 7 — which holds in
every zone and therefore could never see the double-emit its own comment
described. Threading a real zone does not rescue it: the assertion stays
true in Berlin, UTC, UTC+14 and UTC-11 alike. The property it claimed is
already pinned properly in the scheduling suite, which walks
buildCadenceTimeline with an explicit zone across both the 23 h
spring-forward and the 25 h fall-back day.
2026-07-19 02:27:03 +02:00
Marc-André Bombeck 4ba600d908 refactor(medications): share the next-intake day label between card and table
The card and the table carried byte-identical copies of the day-label
arithmetic. Two copies is how they drift, and neither was reachable from a
test, which is why the double conversion in both survived review.

Lift it into resolveNextDueDayLabel, which returns a discriminated label the
callers map onto their own strings, and cover the case the old code could
not get right: a browser zone that differs from the profile zone. Those
tests pin the host zone explicitly — the defect was invisible whenever host
and profile agree, which is the only configuration a UTC-only suite runs in.

Also pin that the window-status verdict is identical from every host zone,
including one whose spring-forward gap swallows the profile wall clock.
2026-07-19 02:19:37 +02:00
Marc-André Bombeck f2083eba36 Give the status, integration-sync and maintenance queues explicit policies
The per-connection backfills and per-user one-shots take 'exclusive': a
worker restarting during a heavy account's multi-hour pass used to append
another identical full-history job per restart, because the discovery
predicate only stops offering a job once it has completed.

The morning-digest refresh takes 'exclusive' so its user+localDate key
becomes a real at-most-once-per-morning contract; a wall-clock
singletonSeconds window cannot express a local date.

The per-document queues take 'short' -- they have no discovery pass to
re-converge, so only queued duplicates may be collapsed or a re-process
after a content change would be lost.

Leave the environment-fetch queue on 'standard' and say why: its
explicit-range backfill deliberately sends without a key, so any policy
would silently merge two different requested ranges into one.
2026-07-19 02:17:57 +02:00
Marc-André Bombeck 3ffaf1906c fix(admin): refuse a restore whose payload names a different owner
The restore target came from the encrypted payload's own userId field
while the audit entry recorded the backup row's userId. A file declaring
a different account would therefore have been written into that account,
with the trail naming the one the operator selected.

Both values are in hand at that point, so compare them and refuse on
mismatch, recording both ids. Adds the first test coverage for this
route; the mismatch case fails without the guard.

Also correct the CLAUDE.md claim that every API route wraps apiHandler -
the three MCP OAuth routes deliberately use withBackgroundEvent so they
can return RFC-format error bodies, and carry the same protections by
hand.

chore(release): v1.30.20
2026-07-19 02:16:54 +02:00
Marc-André Bombeck 1045639076 Give the rollup queues an explicit pg-boss de-duplication policy
Every queue was created under pg-boss's default 'standard' policy, under
which no partial unique index constrains singleton_key. The singletonKey
each enqueue site passed was therefore inert and de-duplicated nothing.

Add a per-registrar policy table that records the decision and the reason
per queue. The recompute queues take 'short' (collapse only while queued,
because the handler re-reads the bucket at run time); the per-user
backfills take 'exclusive' (queued or active, because a second concurrent
run is pure duplicated work and discovery re-enqueues while work remains).

createQueue only applies a policy on first provision, so also reconcile
the stored policy for queues that already exist -- without that the tables
would be correct on a fresh database and inert on every running instance.
2026-07-19 02:16:06 +02:00
Marc-André Bombeck eb071b5dd8 fix(medications): drop the fabricated-Date wall clock from the window status
toZonedDate rendered an instant as an en-US string in the target zone and
re-parsed it as host-local. Two defects rode on that.

When the fabricated wall clock landed in the HOST zone's spring-forward gap
the engine silently advanced it an hour, so a dose inside its window could
be classified late and a just-taken dose could fail to suppress the take-now
pill — for users whose device zone differs from their profile zone.

The medication card and table then passed that already-shifted value into
dateWithWeekdaySmart, which applies the profile zone itself, so the next-
intake day label converted twice and could name the wrong calendar day.
Invisible whenever browser zone and profile zone agree.

Migrate every consumer to wallClockInTz, which returns the fields directly.
The status math only ever reads year/month/day/hour/minute/weekday, so it
now runs on WallClockParts throughout and reduceCurrentWindowStatus takes
the real instant instead of a pre-shifted Date — caller and helper can no
longer disagree about which zone the comparison runs in. Day labels use a
calendar-day distance (localDayIndex) rather than an hour count, so a 23 h
or 25 h day cannot round tomorrow into today, and the formatter receives
the raw instant.

Fixtures name the zone they mean instead of building host-local Dates.
2026-07-19 02:13:08 +02:00
Marc-André Bombeck 75943496f7 Merge pull request #543 from MBombeck/release/v1.30.19
release/v1.30.19 — switching a module off now holds everywhere
2026-07-19 02:10:00 +02:00
Marc-André Bombeck 91c4f789db fix(tz): settle localHmAsUtc's offset at the target local time
localHmAsUtc sampled the zone offset at the reference instant and applied
it to the target wall clock, so on a DST-transition day the answer depended
on which side of the transition the reference sat: a 20:00 slot resolved to
19:00 local when the caller ticked before the Berlin autumn change and
correctly when it ticked after.

That broke the slot-dedupe contract. The projector, the reminder worker and
the intake write path each pass their own reference instant for the same
slot and key on the resulting instant byte-for-byte, so on that day they
disagreed and a duplicate pending row became reachable.

Rebuild it on zonedWallClockToUtc: resolve the local calendar day from the
reference, then convert that day's hh:mm. The offset now settles at the
target local time, and because zonedWallClockToUtc runs the same two-pass
converge as startOfLocalDayInTz, localHmAsUtc(x, tz, 0, 0) equals
startOfLocalDayInTz(x, tz) by construction — the two used to disagree by an
hour on transition days, which opened the one-shot on-time band an hour into
the previous local day.

Cover both Berlin transitions from references on either side, plus a
southern-hemisphere zone whose transitions invert.
2026-07-19 02:05:24 +02:00
Marc-André Bombeck 6b08ffd7c5 chore(release): v1.30.19 — module switches hold on the aggregate surfaces; consent required before assistant egress 2026-07-19 01:58:47 +02:00
Marc-André Bombeck d2656a2d90 Merge branch 'worktree-agent-ad9c547e230ce64bd' into release/v1.30.19 2026-07-19 01:53:18 +02:00
Marc-André Bombeck aed541910c Merge branch 'worktree-agent-aad9f8f93f1f3817b' into release/v1.30.19 2026-07-19 01:53:18 +02:00
Marc-André Bombeck 9084737635 Make the module gate inventory prove a gate, not a mention
The inventory had a BUILDER-GATED bucket that accepted the mere presence
of an aggregator's name -- loadFhirContext, collectDoctorReportData -- as
proof a route was gated. That proved nothing. loadFhirContext filtered
per-domain modules but never evaluated doctorReport, so all five
/api/fhir data routes sat in a green bucket while serving the whole
record to an account that had the module off.

Drop the bucket. A route now counts as gated only when it calls a gate,
and the orphan message says plainly that importing a module-aware builder
is not one.

Add two assertions the old shape could not make. REQUIRED_TREE_MODULE
pins a tree to the key its routes must name, so /api/fhir cannot satisfy
the inventory by gating on some other, more permissive module.
BEHAVIOURAL_GATE_TESTS lists the tests that actually exercise a disabled
module end to end and fails if one goes missing, since this file reads
source text and can never prove a refusal itself.

Verified by removing the gate from the Patient route with the builder
import left in place: the old inventory passed, this one names the file.
2026-07-19 01:46:51 +02:00
Marc-André Bombeck 91a605de2a test(ai): freeze the consent boundary around provider resolution
Both consent gaps had the same shape: a module resolved a live provider and
nothing connected that to "check a receipt first". Behavioural tests cover
the two surfaces; this covers the class.

A module importing resolveProvider, resolveProviderChain or
resolveProviderForTest holds something it can call generateCompletion on, so
it must also import a consent helper — or sit on an allowlist with a written
reason. Four entries today, all of them probes or the credential connection
test, none of which send health data.

The guard is an import-level tripwire and its header says so: it cannot show
the gate is reached on every path, called before the egress, or passed the
right surface. What it does is stop a new ungated call site landing without a
reviewer seeing this file change.
2026-07-19 01:45:30 +02:00
Marc-André Bombeck 5211771e8d fix(ai): require consent before server-managed egress on two coach surfaces
The self-context clarifying questions and the AI-composed proactive nudge
both reached a provider without a consent check of any kind. Either could
resolve to the operator's server-managed credential (admin-openai or the
shared central Codex), so a user with no AI credentials and no receipt on
file had health data leave the server on the operator's key.

The questions path is the sharper of the two: its prompt carries the
complete Coach snapshot. Gate both with the chain predicate pair the status
path already uses, on the coach surface. The gate is skip-shaped on both —
the questions fall back to the deterministic hints, the nudge keeps its
deterministic template — so a missing receipt costs the user nothing but the
model's phrasing. BYOK, local and ChatGPT-OAuth chains stay ungated; that
egress is the user's own act.

Move the questions path off the read-then-write budget check onto the atomic
reserve/reconcile pair, so two concurrent saves can no longer both observe a
sub-cap spend and both call the provider. The cap now follows the chain's
cost owner instead of always charging the operator ceiling.
2026-07-19 01:43:27 +02:00
Marc-André Bombeck a83fb6f936 Gate the dashboard summary payload on the module map
GET /api/dashboard/summary builds its own payload instead of going
through readDashboardSnapshotCached, so it never picked up the module
gate the shared snapshot builder applies. It emitted glucose and sleep
cards on data presence alone while /api/dashboard/snapshot correctly
withheld them for the same account.

Lift the drop decision out of gateSummariesByModules into
disabledSummaryTypes in the client-safe widget-modules module, so the two
dashboard aggregates decide from one map. The snapshot keys its summaries
by MeasurementType and the summary route emits cards keyed by an iOS
MetricKind, so what is shared is the decision, not the filtering;
SUMMARY_METRIC_TYPE_BY_KIND is the join between the two.

Omit rather than refuse, matching the snapshot sibling: the payload spans
ten cards across several domains, and blanking the dashboard because one
module is off would be the wrong trade. Core vitals always pass through.

The filter runs outside the analytics cache on purpose. That LRU is keyed
by user with a 60 s fresh / 1 h stale window and a module toggle does not
evict it, so gating inside the builder would keep serving a disabled tile
for up to an hour after the user turned it off.

Wire shape unchanged -- a gated card is absent, not null.
2026-07-19 01:42:56 +02:00
Marc-André Bombeck 4548df84bf Gate the FHIR REST face on the doctorReport module
/api/fhir/* serves the same whole-record aggregate as
/api/export/health-record -- right down to the decrypted insurance number
on the Patient resource -- but never consulted the module gate. The
export refuses with a 403 module.disabled envelope when the account turns
the doctor-report module off; the FHIR face happily returned the full
Bundle to the same token.

Gate all five data routes (Patient, Observation, MedicationStatement,
MedicationAdministration, $everything). Refuse rather than omit: a
whole-record export has no truthful partial answer, and unlike the sync
delta feed no background client depends on it draining to stay
consistent. The refusal reuses the shared module.disabled envelope so the
errorCode clients already branch on stays the same.

loadFhirContext gains a fail-closed assertion as a second layer. It is
the one door to the aggregate, so it refuses outright instead of trusting
that every future caller remembers its gate; reaching it means a route is
missing one, which is a bug to surface rather than a flow to serve.

The static CapabilityStatement at /api/fhir/metadata stays open -- server
metadata, no user data.
2026-07-19 01:39:58 +02:00
Marc-André Bombeck 24b1269b28 Merge pull request #542 from MBombeck/release/v1.30.18
release/v1.30.18 — health data and credentials no longer reach the logs or the backups
2026-07-19 01:39:34 +02:00
Marc-André Bombeck 076339c274 Gate the cycle domains in the sync delta feed
The multi-domain feed at GET /api/sync/changes read cycleDayLog and
menstrualCycle without consulting the module gate, so an account with
cycle tracking off still received period days and per-day symptom rows
over the same token that every /api/cycle/* route refuses with a 403.

Resolve the cycle module once per request and skip both queries when it
is off, rather than filtering after the read. The blocks come back empty
and the request still succeeds: the feed multiplexes five domains over
one cursor, so refusing it outright would wedge measurement, mood and
intake catch-up for every account that has cycle tracking off. The two
cycle watermarks stay untouched while the module is off, so re-enabling
resumes where the client left off.

A client that synced cycle rows before the module was turned off keeps
its stale local mirror. Tombstones would be the wrong signal there --
they mean deleted, and disabling a module must leave the rows intact --
so purging a paired mirror needs an explicit signal in the feed contract
and is left for a coordinated contract cycle.

Wire shape unchanged.
2026-07-19 01:35:14 +02:00
Marc-André Bombeck e31c66e959 fix(security): scrub wide events at the builder, encrypt the replay cache
Three leaks of the same class - data leaving the process unprotected.

addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.

Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.

The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.

chore(release): v1.30.18
2026-07-19 01:28:20 +02:00
Marc-André Bombeck 40ad2dfc96 Merge pull request #541 from MBombeck/release/v1.30.17
release/v1.30.17 — API tokens now enforce their scope
2026-07-19 01:25:37 +02:00
Marc-André Bombeck 586c4318ca chore(release): v1.30.17 — enforce Bearer token scope 2026-07-19 01:13:11 +02:00
Marc-André Bombeck ff6956decb test(auth): pin the fail-closed Bearer-scope contract
Four structural guards freeze the invariants the change rests on: the set of
files that can resolve a Bearer token to a user, the single deliberate
any-valid-token posture, the mint sites with their scope sets, and the rule
that a declared scope is an exported constant rather than a literal.

Thirteen behavioural cases run against a real Postgres and the real resolver.
Four of them go red on the pre-fix tree — a narrow token reading the
full-backup export and the labs surface, and an MCP token on a REST read.
The rest pin the flows that must NOT change: the per-medication ingest pair
still works, the token without the per-medication grant is still refused, a
wildcard token still reaches the export and the batch ingest, an MCP token
still resolves on /mcp with its write scope intact, a declared fhir:read
still opens the FHIR face and nothing else, cookie sessions are unaffected,
and requireAdmin still refuses a wildcard Bearer.

The workout-batch case that asserted narrow tokens are admitted claimed the
iOS sync depended on it. It does not: workouts:ingest has no mint site
anywhere, and iOS holds a ["*"] token. Replaced with a case that asserts both
halves of the real contract.

Documentation updated for the enforcement rule and the retired mint.
2026-07-19 01:07:43 +02:00
Marc-André Bombeck 8012aa095b feat(tokens): retire the generic API-token mint
POST /api/tokens minted ["medication:ingest"], and that token could never do
the job the settings copy advertised: /api/ingest/medication gates on the
per-medication medication:<id>:ingest grant as well, which this endpoint never
issued. It was refused at the ingest surface and admitted everywhere else.

With Bearer scope now fail-closed the token is inert rather than dangerous,
which is a worse thing to hand a user than the bug it replaces. Remove the
mint. The working credential comes from the per-medication API-endpoint
toggle, which issues both grants scoped to one medication.

List and revoke stay so existing tokens remain visible and revocable, and the
settings card now points at the per-medication toggle instead of offering a
name field. Six locales updated.
2026-07-19 00:51:29 +02:00
Marc-André Bombeck df611227f9 fix(auth): make Bearer scope fail closed on every route
resolveBearerToken took an optional permission, and an unset one admitted
any valid token. 324 of the 330 route files pass no permission, so a token
minted with a narrow scope reached the full-backup export, the labs and
measurement surfaces, the coach, and the bulk deletes — the settings copy
tells users to hand exactly that token to third-party automations.

Invert the default. The scope argument becomes a required ScopeRequirement
union: a route that names no scope now accepts cookie sessions and
cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and
an undeclared_scope audit row. A route that means to accept a narrow token
names it, as the FHIR routes already do.

No route file changes. The absence of an argument becomes a positive
statement rather than an omission, so a newly added route denies a newly
minted narrow scope by default — the safe outcome is the one you get by
doing nothing.

/mcp keeps its own posture through an explicit any-valid-token requirement:
it authenticates here and authorises downstream on audience and write scope.
That is the single deliberate fail-open call site in the tree, and making the
parameter required is what keeps it greppable.

An MCP-audience token loses its REST read leg as a result, narrowing it to
/mcp only. The isMcpAudienceToken guard stays as defence in depth.
2026-07-19 00:44:10 +02:00
Marc-André Bombeck 17033f9966 Merge pull request #540 from MBombeck/release/v1.30.16
release/v1.30.16 — stop an ongoing loss of resting heart-rate history
2026-07-19 00:25:14 +02:00
Marc-André Bombeck 20ff0c1868 fix(measurements): stop the derived resting mint from suppressing itself
The dense-intraday fold tombstones the raw heart-rate readings a proxy
account's resting figure is derived from, and mints one derived
RESTING_HEART_RATE row per folded day to carry the figure forward. The
mint is skipped for accounts that already hold native resting rows,
because the read resolver ignores the proxy for them.

The probe behind that skip matched every source, including the COMPUTED
rows the mint itself writes. From the second run onward it therefore
reported native resting data for every proxy account, skipped the mint,
and let the fold delete that day's raw readings anyway - losing the
resting figure permanently, once per run.

Scope the probe to non-computed sources. Adds a regression test that
reproduces the second-run state and fails on the previous code.

chore(release): v1.30.16
2026-07-19 00:13:54 +02:00
Marc-André Bombeck 1920321e44 Merge pull request #539 from MBombeck/release/v1.30.15
release/v1.30.15 — write generated health texts in the account's own language
2026-07-19 00:06:05 +02:00
Marc-André Bombeck e04383f015 chore(release): v1.30.15 — write generated health texts in the account's own language 2026-07-18 23:54:56 +02:00
Marc-André Bombeck d0a74e37da fix(ai): keep the output-language directive terminal on composed prompts
getBaseSystemPrompt appended the directive, then each metric module
appended its own section after it, so the directive was no longer the
last instruction the model reads - the one property that makes it
authoritative. The base now exposes its body, and every composing module
wraps the finished prompt so the directive ends it.

Also drops a duplicate import that survived the merge of the two prompt
branches.
2026-07-18 23:54:09 +02:00
Marc-André Bombeck 5c83156dcf Merge branch 'wt/l4-pipeline' into release/v1.30.15 2026-07-18 23:48:03 +02:00
Marc-André Bombeck f8c4032515 Merge branch 'wt/l5-narrative' into release/v1.30.15 2026-07-18 23:48:03 +02:00
Marc-André Bombeck d6692e3a4b fix(coach): compose deterministic memory facts in English by default
The deterministic fact-pattern pass fell back to German when no locale was
passed, the same de-default polarity the narrative surface just lost. Fact
text is model-facing memory, so English is the fallback for every locale
without a reviewed body; German stays for German readers.

Also correct the InsightNarrative.locale doc comment — the column holds any
shipped UI locale, not just de/en. Comment only, no SQL change.
2026-07-18 23:46:34 +02:00
Marc-André Bombeck ae3d291feb refactor(insights): name the instruction-body rule on the derived grid
The derived batch grid carries only the deterministic assessment, whose
templates ship de/en bodies. Its `locale === "de" ? "de" : "en"` was
correct but indistinguishable from the accidental binaries that were just
removed; route it through instructionLocale so the intent is explicit.
Behaviour unchanged.
2026-07-18 23:33:37 +02:00
Marc-André Bombeck 93738ae968 test(insights): pin the period-narrative and coach output-language contract
Three layers. The German and English narrative prompts are frozen by content
hash, normalised for the prompt-version string that a prompt change is
required to move — any other drift fails. The four riding locales must name
their language, carry the verbatim reply-language directive from their own
safety-contract file as the last instruction, and contain no German-body
sentinel. A generator-level test drives a French run end to end and asserts
the row is keyed fr, the cache action is locale-scoped, and the system prompt
reaches the provider in English with the French directive.

Alongside it a source-level ban on the exact shape the bug took
(`=== "en" ? "en" : "de"`, `?? "de"`) across the coach and narrative
surfaces, with a self-check so a typo in the pattern cannot turn the guard
into a silent pass, and coach-memory recall cases for all four locales.
2026-07-18 23:32:49 +02:00
Marc-André Bombeck 81065f3f9d test(insights): pin the reader's locale from entry point to prompt
Covers the assertion that would have caught the narrowing: normalizeLocale
passes all six locales through and defaults an unknown value to English, the
de/en cache keys stay byte-identical while the four widened locales get their
own, and a fr locale entering a status generator reaches the prompt builder
as fr and writes the fr cache row. The forced-warm case pins the same for the
comprehensive and per-status warms.
2026-07-18 23:30:57 +02:00
Marc-André Bombeck decc6681f6 test(ai): pin the output-language adoption across the prompt family
Holds every sibling prompt module to one invariant: strip the directive
and the interpolated language name out of a French, Spanish, Italian or
Polish prompt and what remains is the English prompt, byte for byte. A
German instruction body leaking back into a non-German prompt, an
English body drifting away from what the four locales receive, and a
module appending a second directive on top of the one the base prompt
already carries all fail here.

Also drops the now-unused narrow locale alias from the two modules whose
prompt builders take the full locale.
2026-07-18 23:29:25 +02:00
Marc-André Bombeck e4ec0eec49 fix(insights): stop sending German period narratives to fr/es/it/pl readers
The narrative read route narrowed every non-English locale to German, so a
French, Spanish, Italian or Polish account read the German narrative row and
warmed it from the German prompt body. Carry the resolved locale through
instead: the row is keyed (user, period, locale) across the full UI locale
union, and the four locales now compose the English instruction body with
their language named and the locale's own reply-language directive appended
last. The German and English prompts are unchanged apart from the version
string.

The nightly warm keeps the user's stored locale for the same reason — it has
to write the key the read route reads — and the coach and dashboard narrative
recalls read that same key. Coach memory stays de/en: it is model-facing
context, not user-facing prose, so the four locales compose and key English
rather than German.

NARRATIVE_PROMPT_VERSION 1.13.0 -> 1.14.0.
2026-07-18 23:25:44 +02:00
Marc-André Bombeck 37c33d0205 fix(insights): carry the reader's locale through the assessment pipeline
The per-metric assessment pipeline typed every locale as `de | en` and
narrowed on the way in, so a French, Spanish, Italian or Polish account
never reached a prompt with its own locale — the output-language directive
could not fire and the reader got English, or German where the binary
defaulted that way.

Widen `SupportedLocale` to the six locales the UI ships and replace the
narrowing binaries with one validating `normalizeLocale` that passes a
recognised locale through unchanged and defaults an unknown value to
English. The status-generate payload, status batch, status cache,
comprehensive generation, the pre-generate warm passes and the forced-warm
handlers all carry the full locale now.

Fix the collapse sites that discarded it: the warm enqueue on the
comprehensive route and the pregenerate route, the nightly batch cron, and
the forced-warm job handler — the last two defaulted to German outright.
The deterministic derived-score templates and the previous-context block
ship de/en bodies only, so they route through `instructionLocale` (German
for German readers, English otherwise) while the prompt names the reader's
own language.
2026-07-18 23:23:40 +02:00
Marc-André Bombeck 13197c7ee9 fix(ai): route the score, biomarker and coach prompts by instruction locale
The derived-score archetype and the Coach preference overrides branched
on the raw locale, so fr/es/it/pl composed German instruction text. Both
now route through `instructionLocale`. The score archetype rides on
`getBaseSystemPrompt` and therefore appends no directive of its own —
the base prompt already carries it.

The biomarker assessment is the one prompt in the family that does not
compose the base scaffold, so it appends the reader's directive itself;
the append collapses into the shared path once that prompt moves onto
the base scaffold.

The two prompt builders in each of those modules now take the full
`Locale` rather than the narrowed de/en alias, so the routing is right
the moment the pipeline stops collapsing the four locales on the way in.

Also targets the output-language contract test's regex at the compile
target instead of the dotAll flag, which does not compile.

German and English prompts are unchanged, byte for byte.
2026-07-18 23:22:15 +02:00
Marc-André Bombeck fff84ea84f fix(ai): route the metric assessment prompts by instruction locale
The per-metric system and user prompt builders branched
`locale === "en" ? EN : DE`, so a French, Spanish, Italian or Polish
reader composed the German instruction body — whose output clause asks
for German prose. Route them through `instructionLocale` instead, so
German is the only locale that takes the German body and everyone else
takes the English one plus the reader's own output-language directive
already carried by the base prompt.

The German and English prompts are unchanged, byte for byte.
2026-07-18 23:18:58 +02:00
Marc-André Bombeck 2032b74fc6 test(ai): pin the output-language contract across all six locales
The parity test joined raw fragments, which no longer matches now that
the English output clause carries a language token; it substitutes the
expected name from a literal map instead of from the module under test.

Adds the contract test: which body each locale composes, that its own
language is named, that its own translated directive is present and
last, that no German reaches a non-German prompt, and that no two
locales collapse to the same prompt. What it cannot show - whether the
prose reads well in the four languages - is stated in the file.
2026-07-18 23:14:45 +02:00
Marc-André Bombeck ec4e3adfdc fix(ai): write assessments in the reader's language, not German
getBaseSystemPrompt collapsed every non-English locale to the German
instruction body, whose output clause asks for a German assessment. A
French, Spanish, Italian or Polish reader therefore received German
prose on any surface that reached this prompt.

Those four locales now compose the English instruction body with their
language named in the output clause, and the locale's own reply-language
directive - already carried in production by the native insights and
briefing prompts - appended last. German and English compose exactly as
before, byte for byte.

Adds the shared output-language helper the remaining prompt modules
adopt, replacing the scattered en-or-else-German binaries.
2026-07-18 23:13:34 +02:00
Marc-André Bombeck 7fa6498ad8 chore(hooks): reject tooling co-author trailers at commit time
The voice rule states that commits are authored by the maintainer's git
identity alone. Editors and CLIs append co-author trailers and session
links by default, and once such a line is in the object store only a
history rewrite removes it — so enforce it before the commit exists.

The guard rejects tooling co-author trailers, session links, and
generated-with lines; human co-authors and ordinary messages pass.
2026-07-18 23:05:12 +02:00
Marc-André Bombeck 15d63c3842 Merge pull request #538 from MBombeck/release/v1.30.14
release/v1.30.14 — server-prefetch the workouts list; correct the aggregated heart-rate bucket docs
2026-07-18 22:53:31 +02:00
Marc-André Bombeck 5b464b26de chore(release): v1.30.14 — server-prefetch the workouts list; correct the aggregated heart-rate bucket docs
Move the workouts list read into a shared cached module so the RSC
wrapper and GET /api/workouts share one deduplicated projection pass,
and dehydrate it under the client cell's exact query key so the page
paints its rows instead of a loading skeleton.

Correct the aggregated heart-rate externalId documentation: the example
and the min/max field notes still described the original hourly bucket,
while the accepted contract has been 10-minute buckets since v1.30.7.
2026-07-18 22:42:23 +02:00
Marc-André Bombeck 521f5d354c Merge pull request #537 from MBombeck/release/v1.30.13
release/v1.30.13 — server-prefetch the insights, medications, and coach pages
2026-07-18 20:55:36 +02:00
Marc-André Bombeck 7802903d93 chore(release): v1.30.13 — server-prefetch the insights, medications, and coach pages 2026-07-18 20:38:35 +02:00
Marc-André Bombeck e0387c5ceb perf(prefetch): server-prefetch above-the-fold data for /insights, /medications, /coach
Extend the dashboard's RSC server-prefetch pattern to three more route roots
so their above-the-fold content paints populated on first load instead of
flashing a shell that fills in on mount.

Each route gains a thin RSC wrapper that runs the same cached read its API
route uses, dehydrates the result under the exact client query key, and hands
it to the client leaf through a HydrationBoundary. Every wrapper fails soft to
today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH
kill-switch the e2e server sets.

- /insights: prefetch the dashboard snapshot — the same locale-keyed cell the
  dashboard warms, resolved once server-side so the key matches by
  construction. The AI briefing and the heavy analytics aggregations stay
  client-lazy (the briefing must never trigger generation; a cold analytics
  read would trade FCP for TTFB).
- /medications: prefetch the medications list through the shared
  caches.medications cell (extracted to src/lib/medications/list-read.ts). The
  list cell keeps refetchOnMount: "always" — a cross-device take/skip produces
  no client mutation event, so the mount refetch is a correctness gate; the
  prefetch removes the initial skeleton flash without dropping that guarantee.
- /coach: prefetch the nudge status (extracted to
  src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is
  ready at hydrate, collapsing the one sequential waterfall. Only runs when the
  Coach surface is reachable; the streaming conversation is never warmed.

Add per-route tests pinning server-key === client-key, JSON wire-shape
round-trip, and the fail-soft / module-gate paths. Repoint the source-reading
insights, take-all-due, and Coach gate-cascade tests at the client leaves.
2026-07-18 20:36:19 +02:00
Marc-André Bombeck 3743a46a40 Merge pull request #536 from MBombeck/release/v1.30.12
release/v1.30.12 — index the dashboard medication reads
2026-07-18 18:27:30 +02:00
Marc-André Bombeck 9e2c445ad7 chore(release): v1.30.12 — index the dashboard medication reads 2026-07-18 18:04:41 +02:00
Marc-André Bombeck b75771a445 perf(medications): index the dashboard meds-today read shapes
Two additive indexes on medication_intake_events: (user_id, scheduled_for)
for the sibling reads that filter without medication_id, and
(user_id, medication_id, taken_at) for the per-medication last-taken
group-by that scanned the whole intake history on every dashboard load.
Additive migration 0253, no data change, no behaviour change.
2026-07-18 18:04:26 +02:00
Marc-André Bombeck 64e7c57734 Merge pull request #535 from MBombeck/release/v1.30.11
release/v1.30.11 — native OIDC SSO sign-in
2026-07-18 17:31:32 +02:00
Marc-André Bombeck c2420d7f02 chore(release): v1.30.11 — native OIDC SSO sign-in 2026-07-18 17:03:35 +02:00
Marc-André Bombeck 16fd4966d0 fix(auth): force the native-bundle branch on the OIDC token exchange
Adversarial review found the transport gate (isCookielessNativeCaller) was
broader than finishLogin's bearer predicate: a blank-UA caller with no
X-Client-Type could pass it and fall into the cookie branch, receiving a
Set-Cookie session instead of the token bundle (inert, not exploitable, but
the endpoint's own invariant was false). Also require isNativeClientRequest
so the cookie path is structurally unreachable here, and report the actual
revoke outcome on a replay so the wide event never overclaims.
2026-07-18 17:02:45 +02:00
Marc-André Bombeck ada147522f test(auth): cover the native OIDC SSO handoff
Unit-test the handoff library (mint stores only the hash + binds the
challenge + 90s TTL; consume covers not-found / replay-with-revoke /
expiry-burn / PKCE-burn / atomic consume / lost-race), the
token-exchange route (rate limit, transport gate, one identical generic
401 across every invalid-code class, success with issued-hash stamp +
audit, and OIDC_ONLY does not block it), the callback native branches
(handoff mint + scheme redirect with no session, MFA ticket on the
scheme with no session/cookie, post-identity + IdP errors to the
scheme, CSRF web-fallback), the login native-param handling, and the
by-hash refresh-token revoke used for replay containment.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck e6d9cc229a feat(auth): native OIDC SSO session handoff for the iOS client
The web OIDC flow ends in a session cookie the Bearer-transport native
client cannot consume, so on an OIDC_ONLY instance the iOS app had no
sign-in. Add a native leg: the app opens the login URL with
client=native and its own PKCE S256 challenge inside an in-app browser;
the callback resolves identity exactly as before, then mints a one-time
handoff code and 302s to the fixed compiled-in scheme
healthlog://oidc-callback (or hands back the existing MFA ticket when
the account has a second factor). The app exchanges the code plus its
PKCE verifier at POST /api/auth/oidc/native/token for the standard
native token bundle.

The return address is a compile-time constant — no redirect_uri
parameter and no env allowlist, so the open-redirect / scheme-hijack
class is removed by construction. The native flag and app challenge
travel only inside the AES-256-GCM state blob, so a network attacker or
the IdP cannot flip a web login into a native one. The handoff code is
256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a
guarded consume; a failed PKCE check burns it. A replay of a consumed
code revokes exactly the pair the first exchange issued. The token pair
never rides a URL; only the opaque code or the MFA ticket do. The SSO
login can never satisfy requireFreshMfa step-up — the transport is
Bearer and step-up is cookie-only.

Adds the OidcNativeHandoff model (migration 0252), the token-exchange
route, a daily cleanup sweep, and the OpenAPI entry.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck 74891e95f4 Merge pull request #534 from MBombeck/release/v1.30.10
release/v1.30.10 — localized correlation insights + richer workout detail
2026-07-18 16:25:51 +02:00
Marc-André Bombeck 57e855eeb3 chore(release): v1.30.10 — localized correlation insights + richer workout detail 2026-07-18 16:13:53 +02:00
Marc-André Bombeck dfa8675a89 chore(insights): drop the now-unused ENVIRONMENT_CHANNEL_KEYS const 2026-07-18 16:09:28 +02:00
Marc-André Bombeck 906acee978 chore(insights): drop the now-unused environment-field label helpers
The correlation-narrative localisation replaced the env-channel narration
labels (which read through getEnvironmentField().narrationLabel) with the
localised insights.correlation.channel.ENV_* keys, leaving these two helpers
dead.
2026-07-18 16:08:29 +02:00
Marc-André Bombeck e61fe42bd1 feat(workouts): enrich the per-workout detail view (#67)
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
2026-07-18 16:05:17 +02:00
Marc-André BombeckandClaude Opus 4.8 04320c415d fix(insights): localise correlation narratives across all six locales
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.

Route every correlation narrative through the server translator:

- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
  the reader's locale, resolved from the correlations route the same way
  other insight generators resolve it. Sentence templates, the direction
  word, and every channel label live under a new insights.correlation.*
  namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
  (BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
  localised the same way; the analytics cache key now includes the locale
  so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
  causal framing ("not a cause" / "an association, never a cause") and the
  faint/moderate/high hedging tiers are preserved in every language.

English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-18 16:05:17 +02:00
Marc-André Bombeck 36f73233b0 Merge pull request #533 from MBombeck/release/v1.30.9
release/v1.30.9 — faster dashboard first load
2026-07-18 15:04:43 +02:00
Marc-André Bombeck f3d92520d1 chore(release): v1.30.9 — faster dashboard first load 2026-07-18 14:43:57 +02:00
Marc-André Bombeck 5c60558d4f perf(dashboard): gate the profile-tz batch window on snapshot mode
Legacy mode (snapshot flag off) keeps its browser-local batch window
unchanged — the profile-tz window + series prefetch run only in snapshot
mode where a byte-identical dehydration key exists.
2026-07-18 14:42:18 +02:00
Marc-André Bombeck 271abd7b64 perf(dashboard): server-prefetch the Today hero + chart series into the dehydrated cache
Kill the above-the-fold first-load flash on `/`: the hero digest and the
batched chart series still fired client-side after JS download + hydrate,
so the hero and charts painted empty and filled in seconds later (LCP
~5.5 s mobile-throttled while the snapshot already dehydrated).

Extend the existing `page.tsx` snapshot prefetch to also seed the daily
digest and the chart series batch into the same dehydrated TanStack cache,
under keys byte-identical to the client's:

- Extract the series-batch route core into `readSeriesBatch(...)` so the
  RSC and the route share one code path (no HTTP-to-self); the route thins
  to auth + Zod + the shared reader, behaviour unchanged.
- New client-safe pure module `batch-chart-types.ts`: `deriveBatchChartTypes`
  (the single implementation of the visible-with-data type-set derivation,
  order + resting-HR-vs-pulse pick preserved) and `computeBatchWindow`
  (profile-tz end-of-day minus the coverage window). Server and client run
  the same function over the same snapshot payload, and the window is
  computed once server-side and threaded to the client as a prop, so the
  `chartSeriesBatch` key matches on both sides by construction.
- Render the hero data-first so the SSR HTML carries the populated hero
  (the LCP element) — hydration-safe because the digest is dehydrated and
  never restored from the IndexedDB persist allowlist; bound that invariant
  with comments on both the hero gate and the allowlist.
- Warm the shared chart-runtime chunk during hydration.

Both prefetches honour the `DASHBOARD_SSR_PREFETCH=false` escape and fail
soft to the client-fetch path on any error; a ~400 ms soft budget bounds
the cold-day digest cost. No contract change, no visual change.
2026-07-18 14:39:56 +02:00
Marc-André Bombeck 427b5f0b7c Merge pull request #532 from MBombeck/release/v1.30.8
release/v1.30.8 — harden aggregated heart-rate bucket validation
2026-07-18 12:16:26 +02:00
Marc-André Bombeck d4f78811d9 chore(release): v1.30.8 — harden aggregated heart-rate bucket validation 2026-07-18 11:59:38 +02:00
Marc-André Bombeck 6af0e36c88 fix(measurements): harden the aggregated HR bucket validation
Two data-integrity gaps found in review of the v1.30.7 contract:

- The bucket externalId guard accepted a non-canonical timestamp that V8
  silently rolls over (T24:00, 04-31, non-leap 02-29). Such a row would key
  under a literal string the canonical client never emits, so the two would
  never collapse on the overwrite key, and it would plot on the wrong local
  day. Require canonical round-trip (toISOString() === suffix) in both the
  guard and the bucket-start parser.
- The per-bucket min/max bypassed the plausibility-range guard that value
  passes; now that they drive the DAY min/max band and the intraday envelope,
  an out-of-range or mis-ordered spread poisoned the aggregate. Persist the
  spread only when both extremes are in range and ordered around value;
  otherwise drop to null and keep the average.
2026-07-18 11:58:51 +02:00
Marc-André Bombeck 9a6a05a0ef Merge pull request #531 from MBombeck/release/v1.30.7
release/v1.30.7 — 10-minute aggregated heart-rate upload contract
2026-07-18 11:36:53 +02:00
Marc-André Bombeck 0b37c15a52 chore(release): v1.30.7 — 10-minute aggregated heart-rate upload contract 2026-07-18 11:21:17 +02:00
Marc-André Bombeck 5fa18a3754 feat(measurements): 10-minute aggregated heart-rate upload contract
Refine the go-forward aggregated HR wire contract (iOS #34) from the never-
adopted hourly bucket to a 10-minute UTC bucket, so one contract serves both
the measurements-list de-explosion and the intraday-pulse 10-min curve.

- Batch route accepts stats:HKQuantityTypeIdentifierHeartRate:<10-min ISO-Z>
  with avg + min/max, overwrite semantics, malformed-suffix rejection; the
  validator is identifier-generic (allowlist) so respiratoryRate/SpO2 can join
  later without a grammar change.
- The intraday reader classifies a day's PULSE rows three ways by externalId
  shape (raw / uploaded 10-min bucket / server-folded hourly), builds the
  10-min curve directly from uploaded buckets with the min/max envelope and
  tension detection enabled, and overlays buckets over raw on the one
  cutover-straddling day.
- DAY rollup min/max read COALESCE(value_min, value) so a bucket-native day's
  band reflects the true intra-bucket extremes.

No schema change (value_min/value_max exist since v1.19.2). Reconciliation is
go-forward cutover; the nightly retention fold handles the raw backlog.
2026-07-18 11:20:26 +02:00
Marc-André Bombeck 389bdb5d5d Merge pull request #530 from MBombeck/release/v1.30.6
release/v1.30.6 — accessibility pass
2026-07-18 10:46:19 +02:00
Marc-André Bombeck cf3f217e01 chore(release): v1.30.6 — accessibility pass 2026-07-18 10:29:43 +02:00
Marc-André Bombeck 47a78896c5 fix(a11y): heading semantics for two metric sub-page sections
The target-summary and coach-read strip tiles sit directly under a
metric sub-page's h1, alongside the stat strip that already renders its
title as an h2. Render their titles as real h2 headings too via the
existing titleAs prop, so heading navigation lands on each section. A
test pins that the dashboard exposes its page-level h1 through the
shared page header (no duplicate sr-only heading needed).
2026-07-18 10:27:55 +02:00
Marc-André Bombeck bfe23c17b3 fix(a11y): give the heatmaps a screen-reader day list
The mood and medication-compliance heatmaps exposed per-day values only
through pointer tooltips. The cells now carry a visually-hidden day list
beside the grid — one entry per day that holds a value — so a screen
reader reaches the same figures without a tab stop per cell. A cell
subtree nested under the SVG's aggregate role=img is pruned from the
accessibility tree anyway, and a year-long grid would otherwise flood
the keyboard order; the rects stay a pure pointer affordance and the
aggregate summary label is unchanged. Tests pin the list and that the
cells carry no tab stops.
2026-07-18 10:27:55 +02:00
Marc-André Bombeck 156c4574b5 test(admin): match the token-overview meta assertion to the text-xs floor
The mobile meta lines moved off the sub-xs px size; anchor the wrap
assertion on the muted text-xs paragraph instead of the old px class.
2026-07-18 10:27:55 +02:00
Marc-André Bombeck e1c6941b20 fix(ui): lift muted meta text off the sub-xs floor
Bump text-[10px]/text-[11px] to text-xs on muted description, hint, and
status-label surfaces that had drifted below the type-scale floor —
coach conversation captions, settings/admin status badges, mood
confidence pills, side-effect and cycle meta lines. Tabular-nums
badges, chart-plot legend/axis chrome, ring geometry, and
width-constrained tag tiles keep their compact size by design.
2026-07-18 10:27:55 +02:00
Marc-André Bombeck b6a1fc1c2b Merge pull request #529 from MBombeck/release/v1.30.5
release/v1.30.5 — reachable ECG surface and a sync-status fix
2026-07-18 09:44:36 +02:00
Marc-André Bombeck 0f5b18d946 chore(release): v1.30.5 — reachable ECG surface and a sync-status fix 2026-07-18 09:26:58 +02:00
Marc-André Bombeck 4ca34d4b00 fix(insights): point the ECG cross-link at the routed sub-page
Repoint EcgCrossLink from the overview `#ecg` fragment to `/insights/ecg`.
The fragment is absent until the overview section's own fetch resolves, so
the deep link no-oped on navigation (UX-flows finding F1-1); the routed
sub-page always mounts.
2026-07-18 09:25:29 +02:00
Marc-André Bombeck adf80da989 feat(insights): give ECG a routed home and index-surface presence
ECG recordings had no pill in the tab strip, no entry in the sub-page
manager, and no routed page — the only inbound pointer was a cross-link
card on the spot-pulse page. Hiding the overview ECG teaser left no route
back to a user's recordings.

- Add a routed `/insights/ecg` sub-page that reuses the existing ECG
  section (heading suppressed so the page shell owns the title); the
  overview teaser stays.
- Add an ECG entry to the Heart group of the tab strip, gated on a
  dedicated `hasEcgRecordings` signal since ECG carries no measurement
  type. The Heart group surfaces for an ECG-only device too. No ECG pill
  appears without recordings.
- Mount the ECG cross-link on the resting-pulse and HRV pages, matching
  its placement on the pulse page.
- List ECG in the sub-page manager under Heart, its eye toggling the ECG
  overview section so hiding it is reversible.
- Point the metric catalog's ECG row at the new page.
- Add `navEcg`, the sub-page description, and the empty-state copy across
  all locales.
2026-07-18 09:24:28 +02:00
Marc-André Bombeck e8f4bb3dba fix(whoop): advance sync-status ledger on per-resource webhook syncs
The webhook-driven per-resource sync advanced the resource cursor but never
touched IntegrationStatus, so lastSuccessAt stayed frozen at the last full
poll — the Settings 'last synced' pill and the integration-status read
reported stale for up to an hour after a webhook had already landed last
night's data. Stamp recordSyncSuccess per resource, reusing the full sync's
exact success guard (a genuine import clears the error state; a 403 soft-skip
leaves the looks-healthy window closed).
2026-07-18 08:44:58 +02:00
Marc-André Bombeck 0c5049cd69 Merge pull request #528 from MBombeck/release/v1.30.4
release/v1.30.4 — assistant connector reaches the full v1.30 record
2026-07-18 08:25:29 +02:00
Marc-André Bombeck c93d02c089 chore(release): v1.30.4 — read-only assistant connector reaches the full record 2026-07-18 08:07:35 +02:00
Marc-André Bombeck d2e36e2546 fix(mcp): exempt the reviewed discovery allowlist from the mcp:false veto
The signal registry's `mcp:false` flag vetoes a metric-status resolution
so an unreviewed non-exposed signal cannot leak through the id/display-name
path. The metric-status discovery allowlist is a deliberate, human-named
exposure — co-equal to `mcp:true` — so its ids must stay resolvable, or
compare_metric / get_metric_baseline would advertise a metric in discovery
that then refuses to resolve. Exempt the allowlist from the veto; the guard
still fires for genuinely-unreviewed mcp:false ids (workout HR stats).
2026-07-18 08:06:42 +02:00
Marc-André Bombeck a70c98d2b4 feat(mcp): surface metric-status-only rich metrics for discovery
Wrist temperature, cardio recovery, sleep score, breathing
disturbances, strain/load, and the rest of the v1.10/v1.18.1
metric-status-registry ids were already resolvable through
compare_metric / get_metric_baseline / detect_changepoints (an exact
registry-id or display-name match), but sat off the Coach snapshot
inventory entirely — so list_metrics, the measurements-inventory
resource, and search never advertised them. A discover-before-fetch
assistant, which the server's own instructions direct, could never
learn they existed.

Adds a fixed, reviewed allowlist (MCP_METRIC_STATUS_DISCOVERY) plus a
metricStatusDiscoveryRows() presence probe, mirroring the existing
MCP_CLINICAL_SIGNALS pattern: one grouped presence query, appended as
supplement rows to list_metrics and the inventory resource, and merged
into the same probe search already runs for the v1.25 clinical
signals. fetch(id) now hydrates these ids via the baseline read the
same way it already does for clinical signals.
2026-07-18 07:57:04 +02:00
Marc-André Bombeck fdc8ed81a0 feat(mcp): add get_ecg_recordings read tool
Adds MCP coverage for ECG recording metadata (recorded time, duration,
sampling rate, sample count, average heart rate, lead, and the
device's own rhythm classification) — previously unreachable because
ECG has no MeasurementType and so cannot appear in list_metrics or the
metric resource templates.

get_ecg_recordings mirrors GET /api/insights/ecg exactly, down to the
select clause: waveformEncrypted is never selected, so a decrypt can
never happen on this path. Non-diagnostic framing carries through:
classificationSource is a fixed "device" literal, and the description
states plainly that HealthLog never re-classifies. Gated on both the
insights module and the insightStatus assistant-surface flag, mirroring
the route's own two-layer gate.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck c6b8d33b0c feat(mcp): add get_intraday_pulse read tool
Adds MCP coverage for the intraday pulse / "shape of the day" —
previously unreachable, since get_metric_series only serves daily
aggregates and cannot answer "what happened this afternoon?" or "was I
tense during the meeting?".

get_intraday_pulse is a thin wrapper over loadIntradayPulse, returning
the same DTO GET /api/insights/pulse/intraday serves verbatim: the
10-minute mean shape (dense-90d, hourly-grain fallback outside that
window per the resolution field), the resting baseline, and the
cautious elevated-at-rest tension window when every confidence gate
holds. Gated on the insights module; never fabricates a reading for a
day with no data.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck e062721812 feat(mcp): add get_nutrients read tool
Adds MCP coverage for the NutrientIntakeDay pipeline (water, caffeine,
24 micronutrients) — previously fully unreachable over MCP, and
search("water") / search("vitamin") silently miscollided or read as
honest absence even for a daily logger.

get_nutrients is a thin facade over the same two reads GET
/api/nutrients and GET /api/nutrients/daily run: the tz-anchored
day-key math, the sum-across-sources fold, and the sex-aware EFSA
reference resolution. Gated on the opt-in nutrients module, mirroring
both backing routes. Discoverable via search (a grouped presence
probe) and fetch, plus a healthlog://nutrient/{code} resource template
with completion + listing scoped to the user's own logged codes.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck c6885bc7d3 fix(coach,mcp): collapse workout dedup and union HRV flavours for MCP parity
The Coach snapshot read raw prisma.workout rows while GET /api/workouts
runs pickCanonicalWorkoutRows, so a dual-source user (e.g. Apple Watch +
Withings recording the same run) got the session double-counted in the
Coach prompt and the get_workouts MCP tool. Route the snapshot's workout
block through the same picker, keyed off the user's own sourcePriorityJson.

HRV resolves SDNN only on every MCP/coach path, so an Oura/Polar/WHOOP-only
account (RMSSD, not SDNN) saw a false empty HRV block even though the app's
HRV sub-page already unions both flavours. The coach snapshot now queries
both types; the MCP rich reads (compare_metric, get_metric_baseline,
detect_changepoints) fall back to RMSSD when the user has zero SDNN rows,
mirroring the app's primary-then-fallback rule.

Also aligns surfaces.mcp as a real gate: a metric-status id the signal
registry marks mcp:false (cardio recovery, wrist temperature, sleep score,
...) no longer resolves through the rich-read registry path, closing a
contract gap between the two registries (no data was actually exposed
before this, but a future mcp:false signal without this guard would have
leaked silently).

The remaining day-edge tz mismatch between the rollup-backed rich reads
and the user-tz coach timelines is left documented rather than patched:
the underlying rollup buckets are themselves written on UTC calendar
boundaries, so shifting only the read-side window would trade one
one-sided inconsistency for another without fixing the root cause.
2026-07-18 07:56:14 +02:00
Marc-André Bombeck 6b259c2c28 Merge pull request #527 from MBombeck/release/v1.30.3
release/v1.30.3
2026-07-18 07:03:42 +02:00
Marc-André Bombeck aaa05241b3 chore(release): v1.30.3 — quality re-check follow-ups 2026-07-18 06:49:16 +02:00
Marc-André Bombeck 009e5adc43 fix(i18n): close medical-content gaps in explainer, watchdog, and backup copy
Visceral-fat explainer was missing its unitless-scale caveat in every
non-English locale, so those readers got the heart-disease/diabetes framing
without the "not interchangeable with imaging thresholds" caveat. Translate
the full sentence into de/es/fr/it/pl, and mirror the study citations
(fat mass, visceral fat, daylight, falls) that had only been added to the
English strings.

Condition the "automatic screening" watchdog copy on a source actually being
connected — silence with nothing connected means nothing is being watched,
not that everything looks fine — across all six locales.

Disclose in the full-backup scope note that document metadata and any
AI-generated summary are included in the plaintext export; only the
original files and workout GPS/sample data are excluded.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck a977df24a0 fix(ui): catalog loading/error truth, info-popover dedupe, touch sweep
Metric catalog page painted every metric as "Not tracked yet" (with
Connect CTAs) while its four availability probes were still in flight,
and stayed there forever on a failed batch — a load failure indistinguishable
from an honest absence. Gate rows on the probes' aggregate loading/error
state: row skeletons while loading, a QueryErrorCard with retry on failure.

Repoint ComplianceInfoTip onto InfoPopover instead of carrying a verbatim
structural copy (adds icon/align/data-slot override props to the shared
primitive); migrate the cycle log-sheet's FieldInfo off a hover Tooltip
onto the same click-opened InfoPopover, since a hover trigger never opens
reliably on the sheet's thumb-first touch surface.

Lift ViewToggle/SegmentedToggle off their stale 40px tap floor onto the
44px/36px floor the sibling intake and history-rail controls already
carry, and correct the docstrings to match. Restore the dropped
safe-area-inset-top term in /coach/conversations' height calc so it
matches /coach on notched devices. Bring the coach history rail's
off-scale spacing and sub-floor timestamp text onto the standard scale.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 1d559d7ed0 fix(nutrients): evict the dashboard snapshot on a landed batch row
POST /api/nutrients/batch performed zero cache invalidation, so an
Apple-Health-synced water/nutrient total kept the dashboard's tile
serving the pre-sync figure until the analytics fresh TTL lapsed
(~3 min) — the manual water POST already hard-evicts on its own write.

Calls invalidateUserDashboardSnapshot(user.id) once at least one row
inserted or updated, mirroring the manual route's own posture so a
synced total reflects on the very next dashboard read.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 56a32c528e fix(analytics): pair BP readings on the user's own tz, not a hardcoded Berlin day
computeBpInTargetPct's same-calendar-day pairing fallback (for readings
whose sys/dia timestamps drift past the 5-minute session window) keyed
on toBerlinDayKey regardless of the caller's actual user. A non-Berlin
user's two same-local-evening readings straddling Berlin midnight (but
not their own midnight) were rejected as a pair, running the in-target
percentage on fewer readings than it should have — precisely the
population the cross-tz runtime guard in bp-in-target-fast-path.ts
force-routes to this same pairing fallback to protect.

Threads an optional tz through computeBpInTargetPct / collectBpPairs /
computeBpInTargetWindows (default Berlin for untouched legacy
callers), wires it through the fast-path's live fallback and the
comprehensive-insights route's own computeBpInTargetPct call — the
other caller with the user's tz already in scope.

Investigated but left the comprehensive route's resting-pulse-proxy
day-key and BP-medication-continuity day-key on their current UTC
convention: both are joined by an exact YYYY-MM-DD key against a
UTC-anchored partner (the mood rollup's bucketStart, and the
aggregator's date_trunc'd BLOOD_PRESSURE_SYS daily series). Switching
only one side to the user's tz would trade "evening readings shift a
day" for "evening readings mismatch their pairing partner" — a
different bug, not a fix. Documented in place; a real fix needs the
mood-rollup / aggregate tiers themselves to bucket per-user-tz.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck e51e81c76f fix(measurements): flag the oldest groupBy=day bucket as partial when the cap hits
The raw-row read backing the day-collapsed list is desc + take(limit).
On a dense account whose in-window row count crosses the cap, the
oldest returned row sits mid-day — every earlier sample on that same
calendar day was cut off, so its bucket's SUM silently understates the
real day total with no marker.

Flags exactly that one bucket partial: true when the cap was actually
hit, so a page that came in under the cap (the whole window covered)
carries no flag on any bucket.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck dfa4533c7c fix(insights): roll the daily card cache over at the user's own midnight
The eleven per-metric/derived-score status helpers (pulse, weight,
bmi, mood, blood-pressure, medication-compliance, general, the generic
metric card, biomarker, the shared feature cache, the derived-score AI
warm layer) computed todayKey from toBerlinDayKey(new Date()) before
resolving the user's own timezone — several fetched the profile only
for downstream day-bucketing, after the cache read had already run on
the Berlin key. A non-Berlin user's card regenerated at their local
evening (Berlin midnight) and, for biomarker series and the
derived-score prompt label, mis-keyed data or displayed a hardcoded
"(Europe/Berlin)" regardless of their actual zone.

Threads resolveUserTimezone()/userDayKey() ahead of every cache read
so the day-key and the data bucketing agree, and reuses the one
resolved value everywhere downstream instead of re-deriving it.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 1e8e46f58c fix(personal-records): re-derive the cumulative-type records the pre-fix SUM inflated
Before the source-collapse fix, a day with both an Apple Health AND a
Withings write for the same cumulative metric (steps, active energy,
flights, distance, daylight, falls) summed both sources into one
inflated total and locked it in as the all-time PersonalRecord. The
worker's improvement gate only ever compares a freshly-computed day
against the stored best, so the false record silently outranked every
honest day forever and the affected metric could never set a genuine
record again.

Adds a boot-time one-shot repair (queue: cumulative-pr-rederive),
mirroring the existing step-consolidation-repair pattern: deletes the
suspect measurement-driven records created before the fix landed, then
re-runs detection with silent:true so the honest re-derived best
replaces it without firing a "new record" push for what is data
hygiene, not an achievement. Self-converging by a fixed cutoff instant
rather than a marker column or reused SQL migration number.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 6c2eb15f05 fix(insights): hoist the correlation window fetch so every reader shares one grain
The asc+take(20000) window-cap bug (fixed on the correlations route
alone) and the mixed-grain toDailyMeans bug (fixed on three of four
readers) both survived on the Coach get_correlations tool, the
per-metric assessment card, and the period narrative. Dense accounts
saw the cap drop the newest rows on the coach/card readers and drop
the CURRENT period on the narrative — backwards for a recent-window
and a current-vs-prior surface respectively; the narrative's own local
toDailyMeans twin also averaged sleep stage-segments and blended
per-sample step chunks with drained daily totals instead of summing.

Hoists the fetch + grain resolution into
fetchMeasurementWindowSeries/fetchMoodWindowSeries
(correlation-channel-series.ts) — desc+cap+resort, full column select
for buildMeasurementDailySeries's per-type grain — and routes the
route, the coach tool, the per-metric card, and the period narrative
through it, deleting every local copy.
2026-07-18 06:47:32 +02:00
Marc-André Bombeck 635d7e2618 Merge pull request #526 from MBombeck/release/v1.30.2
release/v1.30.2
2026-07-18 05:26:28 +02:00
Marc-André Bombeck de8368cf01 chore(release): v1.30.2 — coach history, navigation, correctness, and performance 2026-07-18 05:08:06 +02:00
Marc-André Bombeck 1a642f1173 feat(coach): full-history search + pagination; document the labs watchdog
Coach conversation history was capped at the rail's first ~20 threads and
searched only that loaded page. The rail and the standalone conversations
page now walk the caller's entire history via cursor pagination
(useInfiniteQuery + a scroll sentinel), and search runs server-side against
the conversation title (GET /api/insights/chat?q=) instead of filtering an
already-truncated client array.

Also extends the "automatic screening" info line in the metric catalog to
name the labs "what changed since your last panel" card alongside the
rhythm/breathing/health-status watchdogs it already covered — it un-mounts
the same way when there's nothing to flag and was missing from the copy.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 8dd04e0170 fix(ux): close medium UX-audit findings across flows, IA, and onboarding
Coach deep-links now carry launch context in the URL instead of dropping it
at the route boundary: /coach reads ?scope= (validated against the closed
CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope
props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own
words instead of opening a blank chat, and the workout detail page scopes
its coach button to workouts.

IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped
documents (the auto-stage/OCR-commit linkage existed server-side with no UI
surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs
the composite wearable page) with a distinct label plus a two-way
cross-link; move the opt-in nutrients sync card from Source priority to
Integrations, where every other sync surface lives; rename the user-menu
theme switcher off "Appearance" (collides with the Settings hub) and the
Environment settings section to "Location & weather"; cross-link mood,
mental-wellbeing check-ins, and mood insights, which were three
unconnected islands.

Onboarding: the getting-started checklist's data-source/notifications rows
now gate their fetch on the same checklistRelevant condition the other rows
use, instead of only-while-onboarding — they used to be stuck undone
forever for anyone who finished the wizard with under 5 measurements; the
tour wrap-up no longer promises a replay affordance that was removed;
the wizard's Withings card shows a connected badge once the OAuth
round-trip lands instead of leaving the button looking untouched in the
tab it was opened from; the done screen and Modules hub get a quiet
cross-link; the sleep empty-state CTA reads "Connect a source" instead of
being pinned to one vendor.

All copy changes land across every locale; de.json reviewed for register.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck e9e186dca5 fix(gamification): bucket achievement streaks by the badge owner's own tz
Every day-key derivation feeding the achievement streak/count badges
(login, entry, weekend, miss-free, sleep-log, on-time, compliance-80)
plus the hidden night-owl/early-bird triggers hardcoded Europe/Berlin,
including the SQL vitals-bucket query's `AT TIME ZONE` literal. A
non-CET user's evening entries could fold into the wrong calendar day,
or two genuinely distinct local days could merge into one, silently
breaking streak badges for every non-CET account.

Threads the account's own `User.timezone` through
`buildAchievementsResult` into the SQL query (bound as a parameter,
not spliced — Postgres accepts a text value on the right side of
`AT TIME ZONE`, verified against a live instance) and every JS-side
day-key helper. The pure helpers in `expansion-metrics.ts` and
`care-metrics.ts` keep the Berlin default when `tz` is omitted, so
every existing caller/fixture is unaffected.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 44ab74e125 fix(nutrients): count each day once and anchor the overview window on user tz
GET /api/nutrients had two correctness bugs:

- `daysWithData` incremented on every row whose day differed from the
  PINNED latest-day field, so a second source's row on an already-seen
  older day (water logged via both Apple Health and a manual entry on
  the same day) opened a phantom second "day". Track a per-nutrient
  set of distinct day keys instead.
- The window floor used a UTC day key against a local-timezone `day`
  column, off by up to a day at the edge for non-UTC users. Anchor on
  the caller's own local "today" instead, mirroring the sibling
  `GET /api/nutrients/daily` route.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 71d7ca78cb fix(insights): scope intraday-pulse reads to the local day and fix the baseline tz
Two bugs in the S11 intraday pulse seam:

- The PULSE/step reads queried a ±15h/39h padded superset around the
  viewed day, then capped the row count ascending. On a dense account
  the cap could be entirely consumed by the PREVIOUS local day's
  samples before reaching the viewed day at all, truncating the chart
  partway through the afternoon. Query the exact local-day window
  (DST-aware) instead — the workout-overlap read keeps the wider
  padding since it has no cap and a workout can legitimately span a
  local-day boundary.
- The resting-baseline PULSE-proxy fallback bucketed every user's
  samples by a hardcoded Berlin day, not the caller's own timezone,
  shifting the personal floor the tension detector judges the day
  against for every non-Berlin user. Threads `timezone` into
  `resolveBaseline`'s `dayKeyOf`.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 5169be5887 fix(insights): order the correlations read desc so a hit cap keeps recent rows
The FDR discovery read capped a dense account's 180-day window at
orderBy asc + take, so exceeding the cap silently dropped the NEWEST
rows. The early-detection pass exists specifically to see the recent
window, so a capped read gave it nothing to work with.

Order desc for both the measurement and mood reads, re-sort ascending
in JS afterward (every downstream day-bucketing pass groups by day
key, so pre-sort order doesn't matter to them), and annotate when a
read actually hit its cap.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck a2cb4fb4a9 perf(workouts): cache the canonical dedup projection per filter, not per page
GET /api/workouts read and deduped the full filtered row set on EVERY
page request because the cache key included offset/limit. A historical
drain (the iOS app's multi-page pull, thousands of rows on a long Apple
Health import) paid the full read + dedup pass once per page instead of
once per filter combination.

Split the route into a cached projection builder (keyed on
userId|since|until|sportType, deliberately excluding offset/limit) and a
cheap in-memory slice per page. Also runs the row read and the
sourcePriorityJson lookup concurrently instead of sequentially.
2026-07-18 05:06:24 +02:00
Marc-André Bombeck 513e5309ac Merge pull request #525 from MBombeck/release/v1.30.1
release/v1.30.1
2026-07-18 04:08:44 +02:00
Marc-André Bombeck ccfda544f8 chore(release): v1.30.1 — mobile, feedback, and consistency polish 2026-07-18 03:55:34 +02:00
Marc-André Bombeck 2c08201695 fix(content): German register breaks, stale disclaimer comments, safety-floor wording
Three German strings broke register: the doctor-report wellness
disclaimer and the Sie-register clinician view's attachment hint both
leaked a du-form word into otherwise neutral/Sie text handed to a
clinician, and the sharing settings' "shownOnce" hint used a Sie
imperative inside the app's own du-form settings surface. All three
now match the register their surface actually uses.

daily.line.allClear ("everything's tracking normally") edged toward an
all-clear verdict the safety-floor module deliberately never makes;
drops the second clause to match the already-existing, safer
daily.today.allClear wording, across all six locales.

Four insights explainers (fat mass, visceral fat, daylight, falls)
asserted health-outcome links with no citation while their siblings
(resting HR, pulse wave velocity, VO2 max) all cite a named guideline
body inline; adds the same parenthetical anchor to each.

Two coach-panel comments still described a per-page "always visible"
clinical-decisions disclaimer that DISC-01 (v1.18.6) replaced with the
one-time onboarding acknowledgment — the code was already correct, the
comments were stale and now say so.
2026-07-18 03:52:33 +02:00
Marc-André Bombeck f223ca8979 fix(a11y): explicit accessibilityLayer, tile headings, stronger focus ring
Health-chart and mood-chart now pass accessibilityLayer explicitly on
their ComposedChart root — Recharts 3 already defaults it on, but
pinning it here documents the choice and survives a future default
flip on the two busiest chart surfaces in the app.

TileHeader's titleAs="h2" was previously read as a single-owner
exception; the docstring undersold it — sibling h2 tiles in one grid
nest correctly under the page h1 same as a lone owning block. Wires it
through the dashboard's recent-workouts tile and the insights vitals
grid (both the loaded SparklineDeltaTile card and the two provisional/
building states), so a rotor user gets one heading per tile instead of
landing on the page's single h1 and nothing else.

The coach composer's focus ring rode ring-primary/15 — likely under
the 3:1 non-text-contrast floor next to its /50 border. Raises it to
/50, matching the family of "wrapper focus-within ring, outline-none
inner control" the standard already uses elsewhere at full strength.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 3a45fcc3bd fix(ui): close remaining consistency-audit findings
Sweeps the banned gap-5/space-y-5/pl-5 spacing residue the audit found
scattered across coach pages, settings coach cards, the labs/records/
custom-metrics form family, onboarding, and a handful of one-off
surfaces (glucose panel, clinician view, illness correlation card,
invite-tokens, the mental-health check-in wizard including its stray
sm:p-6 shell steps) down onto the 4/6 scale.

Two medication chart colours (dose-strength-curve, drug-level-chart)
painted raw --dracula-purple with no light-theme override; both now
read --chart-1, the same alias the mood-chart fix already established.

Raises ~40 sub-text-xs muted-prose sites below the UI-STANDARDS §3/§5
floor (settings API/MCP/sharing/coach-prefs cards, insights workout
detail, suggested prompts, tab-strip group popovers, sources rail,
metric-stat-strip, the health-score provisional pill) to text-xs,
leaving genuine tabular-nums Badges untouched — the one exemption the
standard carries.

Routes the 10 hand-rolled settings/insights "no data yet" paragraphs
(coach memory, coach reminders, security sessions/activity/keys, API
tokens, sharing, insights pill order, per-metric overrides, nutrient
intake) through the shared EmptyState primitive instead of a bare
muted <p>, so they read the same, centered, icon-ready shape every
other empty list in the app already uses. The notification-status
card's empty copy stays on SettingsCardHeader's description slot —
converting it would mean restructuring the card, not routing it.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck a2f926576e feat(labs): add "save & add another" to the manual result form
A real lab report is 10-20 analytes sharing one blood-draw date,
usually in the past; the add form closed on every save, resetting
takenAt to now and clearing the biomarker pick, so entering a full
panel meant re-setting the date away from now on every single row.
The new button keeps the sheet open, clears the reading, and
deliberately preserves takenAt (and the biomarker pick when not
locked to one detail page) so the next analyte in the same panel just
needs a pick + a value.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck b00450aec8 fix(pwa): fix pull-to-refresh's at-top check and extend coverage
The authenticated shell's actual scroll container is <main
id="main-content"> (AuthShell sets it overflow-y-auto inside a
height-locked layout); document.scrollingElement never moves there, so
the gesture's "only arm at the top of the page" check was permanently
true regardless of how far a user had scrolled a long list. Point it
at the real scroll container instead, with the old document-root check
kept as a fallback for the few body-scrolled surfaces outside the
shell.

Extend pull-to-refresh from 4 pages (labs/checkups/mood/measurements)
to the dashboard, /insights, /medications, and the documents vault —
the sharpest PWA-resume-staleness surfaces, per the audit: a
background-refreshed dose or a cross-device write otherwise sits
stale until a full remount or the staleTime window lapses.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck c1c3aa9754 fix(settings): resync threshold rows after a reset
Each MetricRow is keyed on the stable metric name, so a per-row or
reset-all mutation that lands a new override (typically null) never
re-ran the useState initializers — the switch, min/max fields, and
override mode kept showing the just-cleared values while the
"Überschrieben" badge (driven straight off the prop) vanished, a
contradictory row until a reload. Re-derive an identity from the
override prop and resync local state during render when it changes;
typing in the fields never touches that identity, so this never
fights a live edit.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 7ac9e7833e fix(medications): give backdated/manual intakes the same Undo the card has
Logging a dose via Medications → Add → "Log intake" (incl. backdated)
or the dashboard's medication-intake quick-add toasted success with no
Undo, unlike the card's own take/skip path — a dose logged against the
wrong medication (easy on the quick-add, which auto-picks) was only
correctable via detail page → history → find row → delete. Both paths
now capture the created event id and attach the same Undo action.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 8c385b9ac2 fix(coach): delete-with-undo replaces the arm-forever confirm
The history rail + /coach/conversations armed a row on the first tap
and only disarmed on a successful delete — no timeout, no
outside-click reset — so a row armed minutes earlier deleted on a
later stray tap, and there was no way back once it fired. Replace it
with the same delayed-commit delete-with-undo the Documents surface
already uses: one tap hides the row and schedules the real DELETE a
few seconds out; the toast's Undo cancels it. Centralised in
useDeleteCoachConversationWithUndo so both surfaces, which carried
byte-identical arm/confirm logic, share one implementation.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 9531bbdcec fix(measurements): remember the last-used type on the add form
The add form always defaulted to BLOOD_PRESSURE regardless of what a
user actually logs day to day, forcing a re-pick from a 15-entry
select on every entry unless arriving via a ?add= deep link. Seed the
default from the last manually-saved type (localStorage, client-only)
when no deep link overrides it, and record the type on every
successful save.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 3c468b5e00 fix(charts): persist the range-tab pick like the overlay toggles
rangePoints lived in plain useState(30), so every chart reset to the
30 d tab on mount while the sibling trend/comparison/target-band
toggles on the same card persisted per chart. Add rangePoints to
ChartOverlayPrefs (server-validated against the closed 0/7/30/90 set)
and hydrate it once the prefs resolve; a range-tab tap now persists
through the same PUT the toggles already use.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck a66a346d0d fix(capture): guard the bottom-nav Log sheet against silent discard
CapturePicker mounted the same measurement/mood/medication-intake forms
the dashboard quick-entry sheets do, but closed unconditionally on a
backdrop tap, Escape, or mobile swipe-down — the dashboard's
sheetBodyHasUnsavedInput() confirm guard never made it into this newer
picker. Reuse it here too, so a half-filled form from the primary
mobile capture path asks before dropping typed input instead of
losing it.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 20f0d13eba style: prettier reflow on the mobile-audit touch-up files 2026-07-18 03:52:32 +02:00
Marc-André Bombeck 07f161bf25 fix(ui): workout header stacking, chart-shell inset parity, silent retarget failure
WorkoutDetailHeader held three columns at every width, wrapping the
long-format date to 4-5 lines beside a text-2xl duration at 360px;
stack below sm with duration moving under the title block. Same pass
drops a stray text-[11px] muted hint below the size floor.

The intraday pulse chart and the VO2/cardio-fitness link card carried
a static p-4 shell with no md:p-6 step, and the intraday loading state
was a raw pulse div instead of the Skeleton primitive under-reserving
the chart height — both now match the card inset contract used
elsewhere. Same one-line fix on the insights hero shell (sm-stepped
inset instead of the standard md-stepped one) and the glucose advanced
panel's 3-up grid at a 320px fold.

The medication efficacy tab's retarget control failed saves silently —
no toast, no inline alert, an unhandled rejection escaping past the
disabled-button reset. Surface a toast on failure. Also swaps the
level-shift date off a raw toISOString slice onto the locale
formatter, and the primary-target tag off bare muted text onto the
Badge component the rest of the app uses for this kind of marker.

The caffeine card collapsed its 280px skeleton to nothing on every
nutrients-page visit for an account with no logged caffeine; a 60s
staleTime (already the convention on the sibling insights queries)
keeps the flash to once per session. The efficacy chart's dynamic-
import fallback was the full card-chrome ChartSkeleton nested inside
its own Card, snapping ~100px when the real bare chart mounted — swap
for a plain Skeleton sized to the actual chart.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 5532a42ff6 fix(ui): mobile stacked intake history, icon-collapse bulk bar, ResponsiveSheet parity, tap-target floor
Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.

The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.

Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).

Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck 66fe69b493 fix(ui): break-words on coach prose, hero/wellness skeleton parity, priority-card pending state
Coach chat bubbles and the document OCR text panel wrapped long unbroken
tokens (URLs, IDs) past their column width on narrow viewports — add
break-words. Drop the /90 alpha on the coach error text so it stays on the
AA-retuned status token.

Today hero's loading skeleton had drifted from the v1.29.1 tightened hero
(gap/items-start/space-y/rail-heading/xl:grid-cols-3), causing a visible
shift on every dashboard load; mirror the loaded classes 1:1.

The wellness-scores strip returned null while its shared batch loaded,
so it popped in late and pushed the page below it down — render the
section heading + tile-shaped skeletons instead, matching the vitals
dashboard's loading pattern.

Priority-card actions (coach check-in keep/let-go) had no pending state,
so a slow round trip could be double-tapped into firing the mutation
twice; disable the action row while in flight. Priority-item dismiss is
now optimistic (removes the card from the rail on tap, rolls back on
error) instead of waiting on invalidate-after-success.
2026-07-18 03:52:32 +02:00
Marc-André Bombeck d2ad8e03e0 Merge pull request #524 from MBombeck/release/v1.30.0
release/v1.30.0
2026-07-18 02:41:27 +02:00
Marc-André Bombeck a0bde126dd test(withings): expect the no-credentials connect to redirect, not 400 2026-07-18 02:30:33 +02:00
Marc-André Bombeck 749fb8eef1 chore(dead-code): drop the unused settings group-order export + stale knip ignore 2026-07-18 02:16:08 +02:00
Marc-André Bombeck efdcafb08d chore(release): v1.30.0 — quality pass, document summaries, efficacy chart
Correctness (multi-source dedup, ordering, Strava sport, PR/correlations), medical-safety fixes, faster Today view, onboarding, app-wide year-on-dates, a metric catalog, a fuller backup, accessibility, and internal refactors — plus document auto-summaries and the medication efficacy chart.
2026-07-18 02:12:52 +02:00
Marc-André Bombeck 3dd5dbeb42 feat(export): make the full backup actually full
The full-backup export (plaintext + passphrase-encrypted, and the weekly
auto-backup) only ever covered measurements, medications, mood, and cycle
data. Labs, illness episodes, allergies, family history, and workouts were
silently missing, and the export UI implied "everything" was included.

Adds a shared records-backup section (src/lib/export/records-backup.ts) that
both the on-demand export routes and the weekly data-backup worker read from:
lab results + the biomarker catalog, illness episodes (including flares via
parentConditionId) with their day-logs, allergies, family history, and
workout summaries. Every read is owner-scoped and soft-delete-filtered;
encrypted free-text columns decrypt fail-soft so one bad row never aborts
the whole backup.

Document original files and workout GPS/per-sample time series stay
deliberately out of the JSON payload (size). The payload now carries an
explicit manifest disclosing exactly that, and the export UI states it in
plain text instead of implying completeness. These new domains are
export-only for now — admin restore still only recreates the original
measurements/medications/mood/cycle domains; the payload validates and
reports counts for the rest, but restoring a snapshot does not yet recreate
them.

Also fixes the admin backup-upload total to count every domain, not just
the original four.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 837c44e4c2 test(migrations): seed→apply→assert harness for the workout-sport backfills
The raw-SQL data backfills 0247 (WHOOP) and 0251 (Strava) rewrite
historical Workout.sport_type rows with no test — the thinnest-covered
class in the tree (audits L2). Add an integration test that seeds
pre-backfill rows, applies the EXACT migration SQL from disk against the
testcontainer Postgres, and asserts:

  - canonical relabel across both value shapes (id placeholder + raw
    label), case/normalisation, unknown→other fallback;
  - the source guard (a WHOOP pass leaves Strava/MANUAL rows verbatim and
    vice-versa);
  - idempotency (a second apply reports zero affected rows).

Runs in CI's integration job; locally needs Docker/OrbStack via
pnpm test:integration.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 3dc6c7436f fix(analytics): replace NUL-byte map-key separators with a visible one
Two in-memory key builders joined their parts with a literal NUL
(`${a}\x00${b}`): the sleep writer-bucket key and the correlation
pair key. The embedded NUL made grep classify both files as binary, so a
plain `grep symbol src/**` skipped them silently.

Swap the separator for U+241F (␟, the unit separator). It cannot appear
in a `MeasurementSource` enum literal, a device-type tag, or a
correlation-channel name, so the key semantics are identical — the sleep
key still slices its source prefix unambiguously and the correlation Set
still excludes double-counts. Both keys are ephemeral (one pass, never
persisted), so the byte change is invisible to any stored state.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 55186ccd3e feat(observability): fireAndForget breadcrumb for background side effects
Best-effort background work was swallowed with `.catch(() => {})` — a
persistent failure (a broken reminder enqueue, a lost re-auth transition)
left no annotate() or logger trail, against the project's own
'annotate() on every interesting path' rule.

Add `fireAndForget(promise, { action })`: it annotates the rejection into
the current wide event under a per-action meta key (so it rides the
central redaction at emit time — the raw message never hits the console)
and warns the action label only for failures that settle after the event
flushed. Control flow is unchanged; the promise stays fire-and-forget.

Adopt it at the meaningful sites: reminder-satisfy enqueues (measurement
+ labs writes and the five integration sync workers), the post-write
safety-floor dispatch, and the consent audit-log write. The Withings
re-auth mark stays awaited (its persisted state feeds the same response)
but now breadcrumbs its failure instead of swallowing it.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck a7c82eb582 refactor(types): restore Prisma's type-checker on the cycle + PR write paths
The cycle write path and the PR worker cast Prisma inputs to `never`,
which switches off the compiler exactly where a schema/enum drift would
otherwise be caught.

- day-log-write: `ExistingRow` and `SensitiveFields` widened every enum
  column to `string`, forcing 21 `as never` writes. Type them to the
  generated enums (FlowLevel, OvulationTest, CervicalMucus, Cervix*,
  HomeTestResult, ContraceptiveKind) — the Zod input enums already match
  member-for-member — and build the create row from the typed locals
  instead of re-casting the update-input shape.
- cycle/backup: thread the enum element type through `enumOrNull` and pin
  each allow-set to its enum, so the 12 restore writes type-check against
  the generated create input rather than `never`.
- pr-detection-worker: type the candidate `source` as MeasurementSource
  (both Workout and PersonalRecord use it) and give `findBestWorkout` a
  real `Prisma.WorkoutWhereInput` + `WorkoutOrderByWithRelationInput`.

No behaviour change; the writes are now compiler-checked. No latent
drift surfaced — every enum set lines up with the schema.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck d978c2b662 refactor(compliance): split the god-module into focused units behind a barrel
The medication-compliance engine lived in one 2,099-line file with 38
exports feeding eight surfaces (Coach prompt, BP-status gate, dashboard
pillar, per-medication tile, insight targets, doctor report, …). A single
edit intended for one surface could silently shift all of them.

Split along the existing internal seams into `analytics/compliance/`:
parsing, types, adapters, dose-status, occurrences, windows, cycle,
ledger, display. `analytics/compliance.ts` is now a thin barrel that
re-exports every public symbol, so no consumer import path changes. Pure
move — no logic edits; the tally, window ladder, ledger and cycle math
are byte-for-byte the code they were. Tests move alongside under
`compliance/__tests__/` and keep exercising the public barrel.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck e7802701ba refactor(insights, settings): unify the Heart group and add settings sidebar groups
The insights tab strip's "Cardiovascular" popover held only three rare
Withings markers while pulse, resting heart rate, HRV, and cardio
recovery sat scattered across a flat pill and the unrelated Vitals
group. Cardiovascular is now Heart and gathers all six; bmi and
workouts — flat only by historical accident — collapse into their
correct Body / Activity groups, and breathing disturbances moves out
of Vitals into the Sleep manager bucket instead of vanishing whenever
the sleep module is off. Blood pressure, weight, sleep, mood, and
medications stay pinned as flat top-level pills — now a documented
`PINNED_SUB_PAGE_SLUGS` set instead of an unexplained "predates
v1.4.32" carve-out. Active energy moves from Vitals to Activity to
match its own tab-strip section comment. All changes are grouping /
presentation data only — no slug, route, or redirect changed.

The Settings sidebar's 19 entries rendered as one flat list with no
visual structure. They now cluster under seven group headers (Account,
Your data, Display, Health profile, AI, Connectivity, System),
reordered to match; the desktop sidebar gets a non-interactive text
header per group, the mobile chip strip a quiet divider. Presentation
only — no route changed.

Reference: 2026-07-17 UX/IA audit, H2 + M1 + M2 + M3 + M6.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 4c436d7dfc fix(a11y): keyboard-reachable admin log rows, range-bar marker, heatmap cells
Admin app-log rows opened their JSON detail dialog through a click-only
<tr> with no keyboard path; the Action cell now wraps a real <button>
that carries the same handler plus a descriptive aria-label, leaving
the row onClick as a pointer convenience.

The target range-bar's current-value marker showed value + band only
in a hover-only tooltip on a non-focusable div. It is now focusable
(tabIndex, role=img) with an aria-label that reuses the same value /
range / delta copy the tooltip already renders, so focus opens the
tooltip and a screen reader gets the text alternative either way.

Mood and medication-compliance heatmap day cells were pointer-only SVG
rects. Each cell is now focusable with its own per-day aria-label
(reusing the existing tooltip copy), and Escape dismisses any open
tooltip — pinned (touch) or focus-shown.

Reference: 2026-07-17 a11y audit, H1 + M1 + M2.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 247bc4f33c fix(ui): close consistency-audit contrast and duplication findings
Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.

Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.

Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 1ff179178f fix(dates): show the year whenever a date is not the current one
Sweeps every surface that renders a date from before this year without
a year suffix — illness episodes/flares/day-logs, lab and custom-metric
charts, the mood chart family, medication titration/side-effects/dose-
history/next-dose captions (incl. the Dec→Jan rollover), cycle
predictions and history, the BBT chart, the intraday-pulse day-nav, the
date-range filter chip, and the nutrient/compliance charts. Each now
routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts)
or the matching date-format.ts helper, which add the year only when it
differs from the current one — mirroring the workout list's existing
model instead of a bare "19.02." that reads as this year's.

Also fixes two sites that bypassed the app's locale entirely: the
trusted-devices expiry (bare toLocaleDateString, host locale) and the
admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both
render through the user's own locale, hour-cycle, and timezone
preference.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck 7f44e2fa37 feat(format): add conditional-year date formatters
dateShortSmart / dateWithWeekdaySmart add the year only when a date
falls outside the current calendar year, so a chart axis or history
row never shows a bare "19.02." for a reading from last year. Also
adds monthYear for Dec-to-Jan rollover labels (next-dose captions).
Year comparison runs in the formatter's own timezone so UTC-pinned
bucket-label formatters agree with what they print.
2026-07-18 01:23:37 +02:00
Marc-André Bombeck e8327109b1 test(nutrients): use relative day-keys so the daily-window test can't date-bomb 2026-07-18 00:38:33 +02:00
Marc-André Bombeck e3dfe9f400 perf(medications): batch the intake CSV import duplicate probe
The CSV import loop ran a findUnique duplicate check before every
single create — up to 2000 sequential round trips on a 1000-row
import. Resolve takenAt + the idempotency key for every row first,
then read every key's existence in one indexed query before the write
loop; create and the per-row inventory consumption stay unchanged.

Also runs prettier over the batch-route test files touched in the
prior two commits.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2c6699f682 perf(batch): replace per-entry existence probes with batched reads
Three batch-ingest routes ran a per-entry findUnique/upsert loop —
up to 1000 sequential round trips on a 500-entry batch.

- nutrients/batch: one indexed findMany existence read splits the
  batch into a bulk createMany (new day/nutrient pairs) and a
  $transaction of per-row updates (existing pairs), the shape
  measurements/batch already uses. A write failure now marks its
  whole group skipped rather than one entry, documented at the call
  site; validation failures stay isolated per entry as before.
- mood-entries/bulk: the per-entry findUnique probe is replaced by
  one batched findMany keyed exactly like each entry's own dedup
  contract (externalId when carried, else date+moodLoggedAt). The
  per-entry upsert, tombstone check, tag-link writes and reverse push
  are unchanged — only the existence read is batched.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2bc706a445 perf(insights): read PULSE cards through the day-rollup tier
/api/insights/cards pulled every raw PULSE row from the last 90 days
into JS to compute an average and an anomaly count — on a dense
wearable account that's 100k+ rows the DAY rollups already summarise.
WEIGHT and blood pressure stay on the bounded raw read (manual-entry
volume, and blood pressure needs exact-timestamp pairing for the
in-target percentage); PULSE now reads through the existing rollup
read-swap helper, falling back to a bounded per-day-grouped live read
on a coverage miss.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2b89259cce perf(daily): cache the digest's milestone + tension gather
Every 120s poll of every open tab re-derived the S11 tension window
and the S12 milestone from ~8.5k rows even though both are usually
null and change at most once a day. Wrap the two independent reads in
one SWR cell under the existing analytics bucket, keyed per user and
local day, with a TTL that mirrors the dashboard-snapshot cell so a
scheduled poll lands warm. Riding the analytics bucket's `${userId}|`
prefix means the same measurement-write invalidation the snapshot cell
already relies on covers this cell too, so a fresh sleep landing still
refreshes it. Also hoists the milestone gather and the tension read
into one Promise.all instead of two sequential awaits.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 95ab0514b9 chore(charts): drop two unused recharts chart components
sleep-hypnogram.tsx and compliance-line-chart.tsx are not imported
anywhere; a future import would mint a second recharts chunk and blow
the bundle budget. Delete both plus the dedicated hypnogram test file,
and drop the now-missing filename from the touch-action guard's file
list.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 1417137162 fix(daily-loop): stop bad ?doc= links from trapping the coach, add upload processing feedback
An invalid ?doc=<id> deep-link (a deleted document, a mistyped id) 404s
its detail fetch forever. The fenced send-gate never cleared, so every
message stayed blocked behind a phantom "untitled" attachment banner
that could never resolve — a bad link was a dead end instead of
degrading to a normal chat. Dropping a staged id the moment its detail
fetch settles into an error now clears the doc-scope, drops the banner,
and lets the coach send normally; a toast explains the link is gone.

Also gives the document vault processing feedback: the auto-index (and
optional AI summary) job that starts right after upload is
fire-and-forget server-side, so a freshly uploaded card sat with no
signal while it indexed. The vault list now polls while any recently
uploaded document is still unindexed (bounded to a few minutes so an
old, permanently-unindexed document never keeps it polling), and the
card shows a "Processing…"/"Ready" chip that clears on its own once the
index lands.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 2b3ff0e58f fix(daily-loop): thread the overdue medication id into the digest's Log-dose link
The Today digest's dose-window rail item linked to the bare /medications
list even though the overdue medication's id was already resolved
server-side alongside its name — a tap landed the user on a generic list
they then had to scan themselves.

buildMedsTodayBlock now carries nextDueMedicationId; the digest's
"Log dose" action links to /medications?highlight=<id> when the id is
known, falling back to the bare list for an older cached block. The
medications page reads the highlight param, scrolls the matching card
into view, and rings it briefly (mirrors the vault's duplicate-upload
highlight) in both the card grid and the table view. Regenerates
docs/api/openapi.yaml for the new medsToday field.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck 844b24c7ba fix(daily-loop): invalidate the Today digest on measurement/mood/water writes
Manual measurement, mood, and water writes never carried queryKeys.dailyDigest()
in their invalidation bundles — only medication intake did (v1.29.1). The digest
query also runs with refetchOnMount/WindowFocus off, so a write from the values
list, the mood list, or the hydration quick-add sheet left the Today hero's
score and rail items stale until a hard reload.

Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds
a shared refetchInactiveDailyReads() helper (mirrors the existing medication
seam) that forces the dashboard snapshot and digest to refetch even while
unmounted. Every measurement/mood/water write call site now runs it alongside
its existing *DependentKeys bundle.
2026-07-18 00:37:24 +02:00
Marc-André Bombeck bf1c052797 feat(insights): add a metric catalog for discoverability
Metrics with zero data stay hidden from the dashboard and Insights nav
by design — that keeps the daily views uncluttered but leaves no
reachable surface telling a sparse-device-kit account which
capabilities exist at all. Add /insights/catalog: every metric domain
grouped by category, each row showing whether the account has data
for it and what would supply it, with a connect CTA into
settings/integrations when absent and device-sourceable.

Reuses the existing tab-strip gating (hasMetricData over the shared
analytics/comprehensive/workouts/nutrients reads) and the per-metric
empty-state copy already shown on each sub-page, so names, sources,
and presence can't drift from what's shown elsewhere. Adds one quiet,
always-visible pill at the tail of the Insights tab strip as the entry
point — no new loud nav destination.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck 5f253f7d09 fix(insights): make the nutrients pill reachable once the module is on
The nutrients pill required BOTH the opt-in module on AND at least one
NutrientIntakeDay row — but the in-context enable CTA lives on
/insights/nutrients itself, so a user who had just turned the module on
(or wanted to) had no way to reach the page that would let them. Drop
the row-count floor for this one metric: the pill now gates on the
module toggle alone, matching the deliberate opt-in the user already
made. The page itself is unchanged and still degrades from "enable" to
"no rows yet" to the three-card spine.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck d124a3239d fix(insights): stop the pulse chart from faking a continuous shape, add honest error states
Intraday pulse rendered sparse spot readings as a smooth interpolated
curve across hours-wide gaps, implying continuous monitoring the data
never had. The chart now grids one point per bucket, breaks the area
over any missing bucket (connectNulls={false}), and discloses coverage
("based on N readings across M hours") whenever that produces a visible
break.

Four surfaces also collapsed a failed fetch into the honest empty/no-
data state instead of surfacing the error: the Today hero, the
medication efficacy tab (previously stuck on the loading skeleton
forever on error), the intraday pulse chart, and the recent-workouts
dashboard tile. All four now branch on isError and render
QueryErrorCard with a retry action; the tile's "loading…" text line is
replaced with row-shaped skeletons.
2026-07-17 23:50:06 +02:00
Marc-André Bombeck fb73c97125 fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new
tab, which returned a raw JSON 400 for every brand-new account (Withings
credentials are per-user BYO with no env fallback, and the wizard never
collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical
raw-JSON-on-no-creds pattern on their own /connect routes, while every other
failure branch already redirected with a toast reason. All six now redirect
to /settings/integrations?<provider>=error&reason=nocreds instead, and the
wizard's Withings card checks the integrations-status envelope first: unless
credentials are already configured, the CTA reads "Set up in Settings" and
links straight to the credentials form rather than launching a doomed OAuth
handshake.

Also surfaces the BYO-developer-app requirement, which was invisible until a
user was deep inside a Settings card. The source-picker step now says
up front that device sources need a free developer app on the user's own
account; each of the six provider cards gets a compact 3-step guide with the
exact callback URL to register, shown until the user has saved their own
credentials.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 4f7420905a fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.

Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8902d3985c fix(measurements,insights): source-collapse + timezone parity across multi-source surfaces
Four data-correctness fixes in the same class as the workouts-list
ordering bug (05f80ad37):

- GET /api/measurements groupBy=day: the raw-row scan ordered oldest
  first and sliced to `limit` before bucketing, so a long history never
  surfaced its recent days. Reads newest-first now. The per-day sum
  also ran over every source unfiltered — an Apple Health + Withings
  write for the same day doubled the total. Routes through
  pickCanonicalSourceRows first, matching the rollup tile's collapse.

- PR detection's cumulative-day picker summed every row on a calendar
  day regardless of source (false "record" days) and bucketed on the
  UTC calendar day instead of the user's timezone. Rewritten to
  source-collapse before summing and bucket on the user's IANA zone,
  reading through the worker's injected Prisma client so the existing
  dependency-injection contract for tests stays intact.

- The correlation-discovery matrix (the insights route, the per-metric
  assessment card, and the Coach's get_correlations tool — three
  independent copies of the same fetch) averaged cumulative types
  (steps, daylight) and sleep across mismatched grains: per-sample
  chunk means blended with drained daily totals, and sleep averaged
  per-stage segment durations instead of summing a night's total.
  Hoisted the grain resolution into one shared
  buildMeasurementDailySeries helper (source-collapsed day-sum for
  cumulative types, per-night reconstruction for sleep, mean
  unchanged for spot vitals) so all three surfaces agree.

Regression tests cover newest-first ordering, cross-source collapse
matching the rollup, PR no-double-count, and the shared grain helper.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 30a0528ad0 fix(analytics): resting-pulse pill drops the diagnosis-shaped verdict
classifyPulse() labelled resting pulse < 60 "Bradycardia" and > 120
"Tachycardia", the latter painted destructive-red. The interpretation layer
already frames the same signal calmly for the same range ("below the
standard resting range (common in trained hearts)", neutral valence) — a
trained user at 55 bpm resting read a diagnosis-shaped verdict on one
surface and a calm contextual band on the other.

Rename the categories to "Below typical range" / "Above typical range"
(matching the interpretation layer's own wording) and step the high band
down from destructive/danger to warning/amber — still flagged, never a
verdict noun. Neither category paints the pill red anymore.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck cffebd78db fix(mental-health): crisis card no longer sends AT/CH users DE-only numbers
The PHQ-9 item-9 crisis card routed every "de" locale to the Germany-only
TelefonSeelsorge freephone lines. Those numbers don't connect from Austria
or Switzerland, so a Swiss or Austrian user in crisis was shown dead numbers
first on the one surface where that costs the most.

Add dedicated Austria (Telefonseelsorge 142) and Switzerland (Die
Dargebotene Hand 143) resource sets. `User.locale` only ever stores a bare
short code today, so an unqualified "de" now resolves to a combined
DE/AT/CH set (112 first, then every country's own line) instead of
defaulting to Germany; a region-qualified tag resolves to the exact country
when one is available.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 8c0b200a5b fix(doctor-report): footer states the user's own timezone, not Berlin
The clinician PDF footer read "Timezone: Europe/Berlin" for every user
regardless of their actual timezone — a maintainer-anchored default leaking
into a document handed to a clinician, who then reads every timestamp on
the page against the wrong basis for a non-Berlin operator.

`userTz` already carries the resolved per-user zone into the formatters;
thread the same resolution (valid IANA name, else the Berlin fallback) into
the footer string as {timezone} instead of the literal text.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 0518c39050 fix(illness): glucose safety-floor push honours the user's display unit
The hypoglycemia/hyperglycemia escalation push hardcoded mg/dL in all six
locale strings and interpolated the raw mg/dL reading regardless of the
user's glucose display preference. A mmol/L user hitting the confirmed-hypo
alarm read "70 mg/dL (65 mg/dL)" — the wrong unit at the single most
safety-critical moment.

Resolve User.glucoseUnit in the safety-floor write hook and convert both the
reading and the floor threshold before handing them to the notification
copy. The bundle strings now take {threshold} {unit} / {value} {unit}
instead of a baked-in "70 mg/dL" / "54 mg/dL".
2026-07-17 23:12:12 +02:00
Marc-André Bombeck a08e4378a0 Merge pull request #521 from MBombeck/feat/efficacy-scale-history
feat(medications): scale efficacy y-axis and reach older doses
2026-07-17 22:41:36 +02:00
Marc-André Bombeck 0a08b166cf Merge pull request #523 from MBombeck/feat/document-auto-summary
feat(documents): persist an auto summary for uploaded documents
2026-07-17 22:41:30 +02:00
Marc-André Bombeck ba362f1d10 Merge pull request #522 from MBombeck/release/v1.29.5
release/v1.29.5
2026-07-17 17:47:16 +02:00
Marc-André Bombeck 5cff7473c9 feat(documents): persist an auto summary for uploaded documents
When the auto-AI-read opt-in is on, a freshly uploaded document is summarised
once in the background and the encrypted summary is stored and shown on the
document detail view.

- A background step runs after upload alongside the existing content-index and
  thumbnail steps. It generates a short (3-4 sentence) plain-language summary
  only when the user's documentsAutoAiRead opt-in is on (that flag is also the
  standing AI-egress consent), skips a document that already has one, and
  no-ops when no vision provider is configured. It reuses the same provider
  resolution, egress-consent re-assertion, and AI-budget reserve/reconcile
  helpers as the on-demand route; a provider error refunds the reservation and
  never fails the upload.
- The summary is stored encrypted (summaryEncrypted Bytes) with a
  summaryGeneratedAt timestamp on inbound_documents, registered for key
  rotation, and returned decrypted in the document detail response.
- The on-demand transient summary route is unchanged.

Gated by the same consent, budget, and module checks.
2026-07-17 17:46:28 +02:00
Marc-André Bombeck 85a05efb4d chore(release): v1.29.5 — OIDC redirect base + same-origin redirect hardening 2026-07-17 17:30:54 +02:00
Marc-André Bombeck d8e48a6304 fix(security): reject a same-origin next path that re-resolves protocol-relative
sanitizeSameOriginPath returned pathname+search+hash, so a crafted relative next like /..//evil.com passed the origin check but left a //evil.com pathname that re-resolves to a foreign origin when a consumer re-feeds it to new URL(result, base) or router.push. Re-verify the reconstructed path resolves on-origin; reject to / otherwise. Covers the OIDC callback and the password/passkey login redirect.
2026-07-17 17:30:04 +02:00
Marc-André Bombeck 60658be776 Merge pull request #519 from balajiv113/fix-url
fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
2026-07-17 17:26:57 +02:00
Marc-André Bombeck b11ecca8d2 feat(medications): scale efficacy y-axis and reach older doses
- Efficacy target chart: replace the implicit zero-based y-axis with a
  padded data-range domain so a small relative trend fills the plot
  instead of compressing into the top band.
- Add a "% since start" chart mode that reindexes the target series to a
  percentage of the start-anchored baseline; the absolute reference band
  is hidden in that mode.
- Intake history tab: toggle between the trailing 90-day dose ledger and
  the full paginated intake history so older doses stay reachable.
2026-07-17 17:17:54 +02:00
Balaji Vijayakumar 351cf7dd85 fix(auth): build OIDC redirects from NEXT_PUBLIC_APP_URL, not the request URL
Every browser-facing redirect in the OIDC login/callback routes was built
from req.url. Behind a reverse proxy that doesn't forward the original
host, that reflects the address the app process was addressed as inside
the container rather than the public domain, sending users to an
unreachable internal URL after login. Route every redirect through the
trusted, operator-set NEXT_PUBLIC_APP_URL instead, matching the
convention the Withings integration already uses.
2026-07-17 20:30:41 +05:30
1537 changed files with 139095 additions and 28229 deletions

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ public/sw-version.js
# Editor / agent worktrees + caches.
.claude
.worktrees
.cursor
.aider*
.vscode
+11
View File
@@ -98,6 +98,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Users bring their own API keys via Settings. No server-side key needed by
# default. The admin can configure a fallback key in the Admin Panel.
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
# internal hosts by default. Set to an exact host allowlist (comma-separated,
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
# INSIGHTS_MAX_TOKENS=""
# Maximum number of comprehensive-insight generations a single user can
# trigger per hour (POST /api/insights/generate). Default 10. The 24h
# DB cache short-circuits this for read traffic; only force=true and
+6 -4
View File
@@ -115,10 +115,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# hides password/passkey login entirely once set to "true" -- but only takes
# effect when the three vars above are also fully set, so a typo can't lock
# every user out. Enforced server-side, not just a hidden button. The native
# iOS client authenticates via password/passkey only -- there is no OIDC
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
# entirely until a native SSO flow ships. Do not enable it on a deployment
# with active iOS users.
# iOS client signs in through the native OIDC SSO handoff (the app opens the
# login URL in an in-app browser and completes at a custom-scheme callback), so
# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships
# that flow. An older password/passkey-only iOS build is locked out of sign-in
# by OIDC_ONLY; confirm the app version before enabling it on a deployment with
# active iOS users.
# Serve the issuer over HTTPS wherever possible: sign-in material (the
# authorization code, the token-endpoint response carrying the ID token)
# crosses this connection. Plain HTTP is defensible only on a fully trusted
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
+115 -78
View File
@@ -37,6 +37,57 @@ concurrency:
cancel-in-progress: true
jobs:
release-policy:
name: Validate release policy
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Validate release policy
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
echo "::notice::Pull-request image validation does not publish release tags."
exit 0
fi
if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2
exit 1
fi
package_version="$(node -p "require('./package.json').version")"
tag_version="${GITHUB_REF_NAME#v}"
if [ "$package_version" != "$tag_version" ]; then
echo "::error::package.json version '$package_version' does not match release tag '$GITHUB_REF_NAME'." >&2
exit 1
fi
git fetch --no-tags origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Release commit $GITHUB_SHA is not an ancestor of origin/main." >&2
exit 1
fi
successful_quality_runs="$(
gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&event=push&status=success&per_page=100" \
--jq '[.workflow_runs[] | select(.name == "Security & Quality" and .head_branch == "main")] | length'
)"
if [ "$successful_quality_runs" -lt 1 ]; then
echo "::error::Release commit $GITHUB_SHA has no successful prior Security & Quality push run on main." >&2
exit 1
fi
echo "Release policy passed for $GITHUB_REF_NAME at $GITHUB_SHA."
# ── Stage 1 ────────────────────────────────────────────────────────
# Build each architecture on its own native GitHub-hosted runner so
# Next.js's static-page workers run on real silicon instead of QEMU
@@ -54,6 +105,7 @@ jobs:
# manifest in a third step, so consumers see a single tag that
# auto-selects on `docker pull` per local platform.
build:
needs: [release-policy]
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
# 25 min wall clock — native amd64 build finished inside 12 min
@@ -265,6 +317,11 @@ jobs:
if: github.event_name != 'pull_request'
needs: [build]
timeout-minutes: 10
outputs:
image_ref: ${{ steps.published.outputs.image_ref }}
image_tag: ${{ steps.published.outputs.image_tag }}
image_digest: ${{ steps.published.outputs.image_digest }}
build_sha: ${{ steps.published.outputs.build_sha }}
permissions:
contents: read
packages: write
@@ -335,9 +392,21 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_REF }}@sha256:%s ' *)
- name: Inspect published manifest
- name: Resolve published image
id: published
env:
IMAGE_TAG: ${{ github.ref_name }}
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
image_digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::Could not resolve a valid manifest digest for ${IMAGE_REF}:${IMAGE_TAG}." >&2
exit 1
fi
echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
docker buildx imagetools inspect "${IMAGE_REF}@${image_digest}"
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
# Signing the INDEX digest covers both per-platform manifests in
@@ -357,91 +426,59 @@ jobs:
- name: Sign multi-arch manifest (keyless)
env:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }}
run: |
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ -z "$digest" ]; then
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
exit 1
fi
echo "Signing ${IMAGE_REF}@${digest}"
cosign sign --yes "${IMAGE_REF}@${digest}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
echo "Signing ${IMAGE_REF}@${IMAGE_DIGEST}"
cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${IMAGE_DIGEST}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${IMAGE_DIGEST}"
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
# the new image. Replaces the former manual SSH + force-pull recipe
# on the deployment host.
#
# Runs only on the publish path (push to `main` or to a `v*` tag), never
# on PRs. `continue-on-error: true` because the image is *already*
# published to GHCR by the time we reach this step — a Coolify-side
# outage must not retroactively fail the build. The maintainer still
# has the manual recipe as fallback.
#
# Required GitHub repository secrets (set by the maintainer once):
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
# COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens
# Both secrets are referenced exclusively here. If either is unset, the
# step short-circuits to a skip rather than calling Coolify with empty
# auth.
#
# v1.4.26 P6-3 — maintainer toggle.
# Three releases in a row shipped via the host-side SSH-retag fallback
# because the secrets above silently absent + the warn-only skip
# below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY`
# repository variable now distinguishes the two intended modes:
#
# COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent,
# FAIL the step so the maintainer sees the
# missing-secret gap on the next release.
# COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use
# this when the host-side SSH recipe is
# the intended deploy path.
# (unset / any other) → warn-and-skip (legacy behaviour).
#
# Set the variable under repo Settings → Secrets and variables →
# Actions → Variables (NOT Secrets — it is intentionally readable).
verify-published-image:
name: Verify exact published image
needs: merge
permissions:
contents: read
packages: read
uses: ./.github/workflows/post-publish-verify.yml
with:
image_ref: ${{ needs.merge.outputs.image_ref }}
image_tag: ${{ needs.merge.outputs.image_tag }}
image_digest: ${{ needs.merge.outputs.image_digest }}
build_sha: ${{ needs.merge.outputs.build_sha }}
secrets: inherit
promote:
name: Promote verified image to Coolify
needs: [merge, verify-published-image]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Trigger Coolify deploy
continue-on-error: true
env:
COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }}
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
IMAGE_TAG: ${{ needs.merge.outputs.image_tag }}
IMAGE_DIGEST: ${{ needs.merge.outputs.image_digest }}
BUILD_SHA: ${{ needs.merge.outputs.build_sha }}
run: |
mode="${COOLIFY_AUTO_DEPLOY:-}"
if [ "$mode" = "off" ]; then
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead."
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook."
exit 0
fi
if [ "$mode" != "on" ]; then
echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables."
exit 0
fi
if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then
if [ "$mode" = "on" ]; then
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence."
exit 1
fi
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning."
exit 0
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2
exit 1
fi
# v1.4.31 — wait for GHCR cache propagation before firing
# the Coolify webhook. Five releases in a row (v1.4.27
# through v1.4.30.1) the webhook reported "finished" but
# Coolify still pulled the prior `:latest` digest. The
# `docker buildx imagetools create` + `inspect` steps
# above confirm the manifest exists at registry-write time,
# but GHCR's CDN edges can take 30-90 s to propagate the
# new digest. The webhook fires inside that window, so
# Coolify pulls "latest" while the edge still serves the
# prior digest, gets "no change", and returns success.
# Waiting out the propagation window before firing the
# webhook closes that gap.
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}."
sleep 90
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
# and re-pull the registry digest. If the maintainer's secret
# URL already carries a `?force=` query, leave it alone;
# otherwise append `?force=true` (or `&force=true` if other
# query params are present) so doc-only deploys still trigger
# a registry-digest check.
deploy_url="$COOLIFY_WEBHOOK"
if ! echo "$deploy_url" | grep -q "force="; then
if echo "$deploy_url" | grep -q "?"; then
@@ -450,18 +487,18 @@ jobs:
deploy_url="${deploy_url}?force=true"
fi
fi
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
response=$(curl --silent --show-error --max-time 30 \
echo "Triggering Coolify deploy for ${IMAGE_TAG}@${IMAGE_DIGEST} (source ${BUILD_SHA})."
response="$(curl --silent --show-error --max-time 30 \
--write-out "\n__HTTP_STATUS__:%{http_code}" \
--request GET "$deploy_url" \
--header "Authorization: Bearer $COOLIFY_TOKEN")
--header "Authorization: Bearer $COOLIFY_TOKEN")"
status="${response##*__HTTP_STATUS__:}"
body="${response%__HTTP_STATUS__:*}"
echo "Coolify response (HTTP $status):"
echo "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2
exit 1
fi
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}."
+6 -4
View File
@@ -37,10 +37,11 @@ jobs:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
steps:
@@ -48,7 +49,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
@@ -87,10 +88,11 @@ jobs:
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright HTML report on failure
if: failure()
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
@@ -38,6 +38,6 @@ jobs:
# string "0" — failing crypto's hex/base64 length check and breaking
# the backups + integration-status suites for 50+ runs (regression
# since v1.4.13). Quoting forces a literal scalar.
API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod"
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
+108 -79
View File
@@ -1,33 +1,42 @@
name: Post-publish verify
# Informational reachability gate that runs AFTER `Build & Publish Docker
# Image` finishes a `main` (or tag) build. Pulls the just-published image
# from GHCR and probes:
# - the image manifest is fetchable (i.e. the push actually completed)
# - the container starts and `/api/version` returns the expected version
# - the container's `/api/health` endpoint reports OK
#
# Does NOT block deploys — `continue-on-error` is set on every probe step
# so a transient registry blip doesn't go red. This is a smoke gate the
# v1.4.14 release would have caught: the main push appeared to "succeed"
# from the deploy host's POV, but the host had to pull the tag image
# because main's manifest never finished pushing.
#
# Triggered by `workflow_run` so it only runs after the build workflow
# actually completes — chaining via `needs:` would force the build into
# the same workflow file.
on:
workflow_run:
workflows: ["Build & Publish Docker Image"]
types: [completed]
branches: [main]
workflow_call:
inputs:
image_ref:
description: Exact registry repository to verify
required: true
type: string
image_tag:
description: Exact published release tag
required: true
type: string
image_digest:
description: Exact published multi-arch manifest digest
required: true
type: string
build_sha:
description: Expected short source commit SHA
required: true
type: string
workflow_dispatch:
inputs:
image_ref:
description: Exact registry repository to verify
required: true
type: string
image_tag:
description: "GHCR image tag to verify (defaults to `main`)"
required: false
default: "main"
description: Exact published release tag
required: true
type: string
image_digest:
description: Exact published multi-arch manifest digest
required: true
type: string
build_sha:
description: Expected short source commit SHA
required: true
type: string
permissions:
contents: read
@@ -35,14 +44,15 @@ permissions:
jobs:
verify:
name: Pull + smoke-test published image
name: Pull and smoke-test exact published image
runs-on: ubuntu-latest
timeout-minutes: 10
# Skip if the upstream build itself failed — there's nothing to verify.
# Manual dispatch always runs.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
env:
IMAGE_REF: ${{ inputs.image_ref }}
IMAGE_TAG: ${{ inputs.image_tag }}
IMAGE_DIGEST: ${{ inputs.image_digest }}
BUILD_SHA: ${{ inputs.build_sha }}
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
services:
postgres:
@@ -60,15 +70,6 @@ jobs:
--health-retries 10
steps:
- name: Resolve image tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ github.event.inputs.image_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=main" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
@@ -76,69 +77,97 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image
id: pull
continue-on-error: true
- name: Pull exact image
run: |
IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}"
echo "Pulling $IMAGE"
docker pull "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
TAG_REF="${IMAGE_REF}:${IMAGE_TAG}"
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
- name: Start container
if: steps.pull.outcome == 'success'
id: run
continue-on-error: true
echo "Pulling published tag $TAG_REF"
docker pull "$TAG_REF"
resolved_digest="$(docker buildx imagetools inspect "$TAG_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then
echo "::error::Published tag digest mismatch: $TAG_REF resolved to '$resolved_digest', expected '$IMAGE_DIGEST'." >&2
exit 1
fi
echo "Pulling immutable image $DIGEST_REF"
docker pull "$DIGEST_REF"
- name: Run migrations
run: |
# Minimal env to satisfy the strict-env validators on boot.
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
docker run --rm \
--network host \
--entrypoint node \
-e DATABASE_URL="$DATABASE_URL" \
"$DIGEST_REF" \
/opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
- name: Start exact image
run: |
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
docker run -d --name healthlog-verify \
--network host \
-e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \
-e DATABASE_URL="$DATABASE_URL" \
-e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
-e ENCRYPTION_ACTIVE_KEY_ID=v1 \
-e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \
-e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \
-e AUTH_RP_NAME=HealthLog \
-e AUTH_RP_ID=localhost \
-e AUTH_RP_ORIGIN=http://localhost:3000 \
-e NODE_ENV=production \
-e HEALTHLOG_PROCESS_TYPE=web \
-e PORT=3000 \
"${{ steps.pull.outputs.image }}"
"$DIGEST_REF"
# Wait up to 60 s for the server to start answering.
- name: Verify exact version
run: |
body=""
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:3000/api/version; then
echo "ready after ${i}*2s"
if body="$(curl -sf http://localhost:3000/api/version)"; then
echo "Version endpoint ready after $((i * 2))s"
break
fi
sleep 2
done
if [ -z "$body" ]; then
echo "::error::Exact image did not expose /api/version within 60 seconds." >&2
docker logs healthlog-verify || true
exit 1
fi
EXPECTED_VERSION="${IMAGE_TAG#v}"
echo "$body" | jq .
if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then
actual="$(jq -r '.data.version // "<missing>"' <<< "$body")"
echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2
exit 1
fi
if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then
actual="$(jq -r '.data.buildSha // "<missing>"' <<< "$body")"
echo "::error::Build SHA mismatch: exact image reports '$actual', expected '$BUILD_SHA'." >&2
exit 1
fi
- name: Verify health
run: |
for i in $(seq 1 30); do
if body="$(curl -sf http://localhost:3000/api/health)" && jq -e '.status == "ok"' <<< "$body" >/dev/null; then
echo "$body" | jq .
echo "Health endpoint ready after $((i * 2))s"
exit 0
fi
sleep 2
done
echo "::warning::Container did not become ready within 60 s"
echo "::error::Exact image did not report healthy within 60 seconds." >&2
docker logs healthlog-verify || true
exit 1
- name: Probe /api/version
if: steps.run.outcome == 'success'
- name: Container logs
if: always()
continue-on-error: true
run: |
set -e
BODY=$(curl -sf http://localhost:3000/api/version)
echo "$BODY" | jq .
VER=$(echo "$BODY" | jq -r '.data.version // .version')
echo "::notice::published image ${{ steps.tag.outputs.tag }} reports version=$VER"
- name: Probe /api/health
if: steps.run.outcome == 'success'
continue-on-error: true
run: |
curl -sf http://localhost:3000/api/health | jq .
- name: Container logs (always)
if: always() && steps.run.outcome == 'success'
continue-on-error: true
run: docker logs healthlog-verify | tail -100
run: docker logs healthlog-verify
- name: Tear down
if: always()
continue-on-error: true
run: docker rm -f healthlog-verify || true
run: docker rm -f healthlog-verify
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
@@ -70,7 +70,7 @@ jobs:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
+4
View File
@@ -85,3 +85,7 @@ next-env.d.ts
# Local screenshot/audit harnesses (repo-root for module resolution; never committed)
.audit-*.mjs
# Isolated development worktrees
/.worktrees/
+1509
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -86,9 +86,10 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
@@ -165,7 +166,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
## DO-NOTs
+21 -19
View File
@@ -1,14 +1,15 @@
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
# ── Stage 2: Build ─────────────────────────────────────────
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
@@ -45,17 +46,15 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine AS runner
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC on Alpine images that ship without it.
RUN apk add --no-cache tzdata && \
corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache tzdata
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_PATH="/opt/pg-boss/node_modules"
ENV TZ=Europe/Berlin
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
@@ -80,6 +79,10 @@ RUN addgroup --system --gid 1001 nodejs && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
# Prisma's pure-JS adapter stays bundled; only modules loaded through native
# Node resolution belong in this runtime assertion.
RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
# native binary's package into the standalone tree but NOT the pnpm symlinks that
@@ -123,20 +126,19 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/src/generated ./src/generated
# Install all worker runtime dependencies in a shared prefix.
# NODE_PATH makes them available to Node.js module resolution.
# These packages are listed in next.config.ts serverExternalPackages
# and are NOT bundled by Next.js — they must exist at runtime.
RUN mkdir -p /opt/pg-boss && \
cd /opt/pg-boss && \
npm init -y && \
npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
# stripping npm/Corepack and their caches from the runtime surface.
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm install --omit=dev prisma@7.8 @prisma/engines@7.8
npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 2950,
"totalClientKbGz": 3050,
"maxRechartsChunks": 1
}
+2 -2
View File
@@ -227,7 +227,7 @@ services:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
]
interval: 30s
timeout: 5s
@@ -245,7 +245,7 @@ services:
# dockerfile: Dockerfile
# container_name: healthlog-worker
# environment:
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
# NODE_ENV: production
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
+43 -1
View File
@@ -9,7 +9,7 @@ set -e
# Validating here guarantees a clear, deploy-platform-agnostic
# failure message before we touch the database.
missing=""
for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
for var in DATABASE_URL API_TOKEN_HMAC_KEY; do
eval "value=\$$var"
if [ -z "$value" ]; then
missing="$missing $var"
@@ -21,6 +21,48 @@ if [ -n "$missing" ]; then
exit 1
fi
# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same
# fail-closed shape the runtime crypto loader accepts before touching the DB.
if ! node -e '
const validKey = (value) => {
if (typeof value !== "string") return false;
if (/^[0-9a-fA-F]{64}$/.test(value)) return true;
if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false;
try { return Buffer.from(value, "base64").length === 32; }
catch { return false; }
};
const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim();
if (rawKeyring) {
let keyring;
try { keyring = JSON.parse(rawKeyring); }
catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); }
if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) {
throw new Error("ENCRYPTION_KEYS is invalid: expected an object map");
}
const entries = Object.entries(keyring);
if (
entries.length === 0 ||
entries.some(([id, value]) =>
!/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value)
)
) {
throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key");
}
const activeId =
(process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() ||
(entries.length === 1 ? entries[0][0] : "");
if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) {
throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS");
}
} else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) {
throw new Error("ENCRYPTION_KEY is missing or invalid");
}
'; then
echo "HealthLog: ERROR — encryption configuration is invalid." >&2
echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2
exit 1
fi
# ── Wait for PostgreSQL ──────────────────────────────────────
MAX_RETRIES=30
RETRY=0
+3178 -751
View File
File diff suppressed because it is too large. Load diff
+59 -18
View File
@@ -112,29 +112,50 @@ mapped set (workout routes, ECG voltage traces) is counted in
### Cumulative HK type → daily aggregate
Cumulative quantity types — steps, active energy, walking/running
distance, flights climbed — get **collapsed into one row per
user-local day** to match the iOS daily-aggregation convention.
distance, flights climbed, time in daylight, and fall count — get
**collapsed into one estimated row per user-local day**. `export.xml`
contains source-specific records, not HealthKit's canonical daily
statistics, so the importer does not sum different devices or apps
into a source-blind total.
- Records are grouped by type, local day, and a SHA-256 hash of the
available source name, source version, and device attributes.
Values sum within each identified source-day; the largest
source-day subtotal is selected deterministically. If every source
attribute is absent, records remain separate rather than being
trusted as one source. Raw source and device labels are not
persisted.
- The row records `EXPORT_XML_SOURCE_MAX` provenance, its distinct
contributor count, and the selected non-identifying source hash.
- External ID format: `stats:<HKType>:<YYYY-MM-DD>` (e.g.
`stats:HKQuantityTypeIdentifierStepCount:2026-05-15`).
- The day boundary respects the user's timezone preference. The
worker defaults to `Europe/Berlin` when no preference is set
(`src/lib/jobs/apple-health-import-worker.ts:132-133`).
- A second import of the same archive re-folds the same days onto
the same external IDs — the values update in place rather than
duplicating.
(`src/lib/jobs/apple-health-import-worker.ts`).
- XML intervals are not reconstructed or split. A record crossing
midnight is assigned using its mapped timestamp and remains covered
by the explicit estimate contract.
- A second import re-folds the same days onto the same external IDs.
Equal-authority estimates update idempotently rather than duplicate.
The terminal job result exposes
`cumulativeEstimates: { days, rows }`; the web import card warns when
one or more cumulative days used this estimate path.
### Re-import idempotency
The two-axis idempotency story:
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
of bytes) short-circuits to the previous job before the parser
even starts.
of bytes) short-circuits to the previous job only when that job
used the current parser revision. Existing jobs are revision 1;
new jobs use revision 2, so a pre-fix successful archive can be
deliberately processed again under the corrected aggregation.
2. **Record-level** — when an older `export.zip` is re-exported from
iOS (same history, slightly newer device timestamps), every
record's external ID stays stable. The upsert path matches on
`(userId, externalId)` and updates instead of inserting.
record's external ID stays stable. The serialized reconciliation
path matches the canonical identity and updates instead of
inserting.
Practically: a user who exports monthly and re-imports each archive
sees fresh data merged in without duplicates. The job result line
@@ -150,14 +171,34 @@ is identical to the user-facing flow.
The `ImportJob` row carries `triggeredByAdminId = admin.id` so the
status endpoint admits both the target user and the triggering
admin. Idempotency is scoped by target user — an admin re-uploading
the same archive for the same user resolves to the previous job.
admin. Idempotency is scoped by target user and parser revision — an
admin re-uploading the same archive for the same user resolves to the
previous current-revision job.
Typical use: migrating a friend's history into their HealthLog
account when they cannot run the import themselves, or backfilling
a household member's data after the admin received the `export.zip`
out of band.
## Aggregate authority and recovery
Native HealthKit daily statistics submitted by the iOS batch endpoint
are canonical and carry `HEALTHKIT_STATISTICS` provenance. XML
source-maximum estimates carry `EXPORT_XML_SOURCE_MAX`. Both use the
same serialized reconciler with this ordering:
`LEGACY_UNKNOWN < EXPORT_XML_SOURCE_MAX < HEALTHKIT_STATISTICS`
A later XML import cannot overwrite native statistics. Native
statistics repair an XML estimate or legacy row in either arrival
order, and every material reconciled update or resurrection increments
`syncVersion`. Existing Apple Health `stats:*` rows are migrated to
`LEGACY_UNKNOWN`; they are never guessed or heuristically promoted.
To recover an already-corrupted legacy total, the user must re-upload
the original archive under parser revision 2 or sync native HealthKit
statistics. Without either input, the correct per-source total cannot
be reconstructed from the stored aggregate.
## Source-priority interaction
HealthLog's source ladder is **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻
@@ -169,12 +210,12 @@ max). The defaults live in `src/lib/validations/source-priority.ts:205-220`.
Concrete consequences:
- If you already sync steps from a Withings ScanWatch and also import
Apple Health, the Apple-Health-aggregated stream wins for the day —
iOS HealthKit folds the watch sensor + iPhone motion data into a
single canonical stream, so it is the most complete cumulative
source. The Withings rows stay in the database as an audit trail
but drop out of the per-day aggregation.
- Native HealthKit cumulative statistics are the canonical Apple
Health stream for a day and outrank both XML estimates and Withings
cumulative rows. An XML upload chooses the largest source subtotal
only; it never claims to reproduce HealthKit's device de-duplication.
Lower-priority rows stay in the database as an audit trail but drop
out of the displayed per-day aggregation.
- If you weigh yourself on a Withings scale and also import Apple
Health (which received the same reading via the Health Mate iOS
app), the Withings row wins for display. Apple Health is
+3 -3
View File
@@ -84,9 +84,9 @@ pnpm dlx tsx scripts/restore-backup.ts \
/tmp/restored.json
```
The production standalone image strips `tsx`, so a bare
`pnpm tsx scripts/...` fails inside the container — always invoke
one-shot scripts via `pnpm dlx tsx`.
Run this command from a source checkout with the production backup variables
exported. The script imports the full application dependency graph, which the
minimal production image does not expose as an operator scripting environment.
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
+14 -15
View File
@@ -46,24 +46,23 @@ metadata (`medication_schedules`):
Always. The default mode only reports and never mutates:
```bash
# inside the app container (or any checkout) — DATABASE_URL must point
# at the target database
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# inside the production app container — DATABASE_URL is inherited
healthlog-tsx scripts/repair-intake-anomalies.ts
# scoped to one account
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
healthlog-tsx scripts/repair-intake-anomalies.ts --user <userId>
# source-checkout equivalent
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
```
The script is self-contained: its only import is `pg` and it reads
`DATABASE_URL` straight from the environment (no dotenv, no `@/` path
alias, no `src/` imports). That matters in the production container —
the standalone image ships no project `node_modules`, so the previous
revision died there with `Cannot find module 'dotenv/config'`. Inside
the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a
checkout it resolves from the project `node_modules`; the
`--package pg --package tsx` pins cover any environment with neither.
Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
`tsx`.
alias, no `src/` imports). The production image provides the pinned
`healthlog-tsx` launcher and resolves `pg` from its standalone runtime tree
without retaining a package manager. In a source checkout, `pg` resolves from
the project `node_modules`; `pnpm dlx --package pg --package tsx ...` also
works in an environment where neither dependency is installed.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned), a table of implausible rows (id, medication,
@@ -74,7 +73,7 @@ would write), and a table of stale-anchor pending rows.
## Applying the fix
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
healthlog-tsx scripts/repair-intake-anomalies.ts --fix
```
`--fix` does five things:
@@ -92,7 +91,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
in explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
```
`--tombstone-implausible` is refused without `--fix`.
@@ -139,7 +138,7 @@ slot anchors:
The default run only **reports** the proposals. Apply them explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
```
Review the dry-run table first — the inference is a heuristic. A wrongly
+5 -5
View File
@@ -47,11 +47,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
```
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
| Variable | Purpose | Rotation cost |
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
stack: compose constructs the connection string inside the `app`
+4 -4
View File
@@ -65,10 +65,10 @@ Still in **Settings → MCP → Tokens**:
3. **Copy the `hlk_…` value now** — it is shown exactly once and cannot
be retrieved again. Tokens default to a 90-day lifetime.
The token never carries the `*` wildcard and never the
`medication:ingest` scope the generic `/api/tokens` mints — it is the
least-privilege MCP scope and nothing more. The same list shows
last-used time and lets you revoke any token.
The token never carries the `*` wildcard and never a medication-ingest
scope — it is the least-privilege MCP scope and nothing more, and it is
bound to the MCP surface: it is refused on every REST route. The same
list shows last-used time and lets you revoke any token.
Cloud connectors that use OAuth (next section) mint the same
`health:read` scope automatically, so for Claude.ai / ChatGPT you do not
+846 -114
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -195,7 +195,11 @@ test.describe("achievements page renders all four card states", () => {
// Page heading present
await expect(
page.getByRole("heading", { name: "Achievements" }),
page.getByRole("main").getByRole("heading", {
name: "Achievements",
level: 1,
exact: true,
}),
).toBeVisible({ timeout: 5000 });
// Unlocked public — title visible
+218
View File
@@ -121,4 +121,222 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible();
}
});
test("provider editors submit once by Enter or click and isolate connection tests", async ({
page,
}) => {
const patchBodies: Array<Record<string, unknown>> = [];
let releasePendingPatch: () => void = () => undefined;
let holdNextPatch = false;
let testConnectionRequests = 0;
let providerState: Record<string, unknown> = {
provider: null,
model: null,
baseUrl: null,
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
if (holdNextPatch) {
holdNextPatch = false;
await new Promise<void>((resolve) => {
releasePendingPatch = resolve;
});
}
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "codex", available: true },
{ providerType: "openai", available: true },
{ providerType: "anthropic", available: true },
{ providerType: "local", available: true },
],
},
error: null,
}),
}),
);
await page.route("**/api/ai/test", (route) => {
testConnectionRequests += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { ok: true, providerType: "local", model: "test-model" },
error: null,
}),
});
});
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=openai", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const select = page.getByTestId("ai-active-provider-select");
async function assertEnterAndClickSubmitOnce({
provider,
formTestId,
inputId,
enterValue,
clickValue,
expectedField,
}: {
provider: "openai" | "anthropic" | "local";
formTestId: string;
inputId: string;
enterValue: string;
clickValue: string;
expectedField: string;
}) {
await select.selectOption(provider);
const form = page.getByTestId(formTestId);
await expect(form).toHaveJSProperty("tagName", "FORM");
const input = page.locator(`#${inputId}`);
const save = form.locator('button[type="submit"]');
await expect(save).toHaveCount(1);
await input.fill(enterValue);
const enterBaseline = patchBodies.length;
await input.press("Enter");
await expect.poll(() => patchBodies.length).toBe(enterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(enterBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(enterValue);
await input.fill(clickValue);
const clickBaseline = patchBodies.length;
await save.click();
await expect.poll(() => patchBodies.length).toBe(clickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(clickBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(clickValue);
}
await assertEnterAndClickSubmitOnce({
provider: "openai",
formTestId: "ai-provider-config-openai",
inputId: "ai-openai-key",
enterValue: "sk-enter-openai",
clickValue: "sk-click-openai",
expectedField: "openaiKey",
});
await assertEnterAndClickSubmitOnce({
provider: "anthropic",
formTestId: "ai-provider-config-anthropic",
inputId: "ai-anthropic-key",
enterValue: "sk-ant-enter",
clickValue: "sk-ant-click",
expectedField: "anthropicKey",
});
await assertEnterAndClickSubmitOnce({
provider: "local",
formTestId: "ai-provider-config-local",
inputId: "ai-local-base-url",
enterValue: "http://localhost:11434/v1",
clickValue: "http://localhost:11435/v1",
expectedField: "baseUrl",
});
const timeoutForm = page.locator("form").filter({
has: page.locator("#ai-response-timeout"),
});
const timeoutInput = page.locator("#ai-response-timeout");
const timeoutSave = timeoutForm.locator('button[type="submit"]');
await expect(timeoutForm).toHaveCount(1);
await expect(timeoutSave).toHaveCount(1);
await timeoutInput.fill("9");
const invalidTimeoutBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect(timeoutForm.locator("p.text-destructive")).toBeVisible();
expect(patchBodies).toHaveLength(invalidTimeoutBaseline);
await timeoutInput.fill("180");
const timeoutEnterBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect.poll(() => patchBodies.length).toBe(timeoutEnterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutEnterBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(180);
await timeoutInput.fill("240");
const timeoutClickBaseline = patchBodies.length;
await timeoutSave.click();
await expect.poll(() => patchBodies.length).toBe(timeoutClickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutClickBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(240);
holdNextPatch = true;
await page.locator("#ai-local-base-url").fill("http://localhost:11436/v1");
const pendingBaseline = patchBodies.length;
await page
.getByTestId("ai-provider-config-local")
.evaluate((form: HTMLFormElement) => {
form.requestSubmit();
form.requestSubmit();
});
await expect.poll(() => patchBodies.length).toBe(pendingBaseline + 1);
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeDisabled();
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
const testConnection = page.getByTestId("ai-test-active-provider");
await expect(testConnection).toHaveAttribute("type", "button");
await testConnection.click();
await expect.poll(() => testConnectionRequests).toBe(1);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
releasePendingPatch();
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeEnabled();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from "@playwright/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
const CONVERSATION_ID = "conversation-rename-e2e";
const INITIAL_TITLE = "Morning check-in";
const RENAMED_TITLE = "Weekly health review";
test.describe("Coach conversation rename", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renames by keyboard once and survives reload on desktop and mobile", async ({
page,
}) => {
let persistedTitle = INITIAL_TITLE;
let patchCount = 0;
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
if (
request.method() === "PATCH" &&
url.pathname === `/api/insights/chat/${CONVERSATION_ID}`
) {
patchCount += 1;
const payload = JSON.parse(request.postData() ?? "{}") as {
title: string;
};
persistedTitle = payload.title;
await delay(150);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: CONVERSATION_ID, title: persistedTitle },
error: null,
}),
});
return;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
conversations: [
{
id: CONVERSATION_ID,
title: persistedTitle,
createdAt: "2026-07-20T10:00:00.000Z",
updatedAt: "2026-07-20T10:00:00.000Z",
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
},
],
nextCursor: null,
},
error: null,
}),
});
},
);
await page.goto("/coach/conversations", {
waitUntil: "domcontentloaded",
});
const item = page.locator('[data-slot="coach-conversations-item"]');
await expect(item).toContainText(INITIAL_TITLE, { timeout: 10_000 });
const renameButton = item.locator(
'[data-slot="coach-conversation-rename"]',
);
await renameButton.click();
const input = item.locator('[data-slot="coach-conversation-rename-input"]');
await expect(input).toBeFocused();
await input.fill("Discard this draft");
await input.press("Escape");
await expect(input).toHaveCount(0);
await expect(item).toContainText(INITIAL_TITLE);
expect(patchCount).toBe(0);
await renameButton.click();
const activeInput = item.locator(
'[data-slot="coach-conversation-rename-input"]',
);
await activeInput.fill(` ${RENAMED_TITLE} `);
await activeInput.evaluate((element) => {
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await expect(activeInput).toBeDisabled();
await expect(item).toContainText(RENAMED_TITLE);
await expect(activeInput).toHaveCount(0, { timeout: 10_000 });
expect(patchCount).toBe(1);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="coach-conversations-item"]'),
).toContainText(RENAMED_TITLE, { timeout: 10_000 });
expect(patchCount).toBe(1);
});
});
+7
View File
@@ -93,7 +93,14 @@ test.describe("settings AI codex device flow (mocked)", () => {
}),
);
// Hydration gate — the settings query is fired by the same client
// boundary that renders the Codex form, so its response proves React
// has attached the CTA's click handler. On mobile the SSR-painted button
// can otherwise be clicked before hydration and the event is lost.
const codexFormHydrated = page.waitForResponse("**/api/insights/settings");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await codexFormHydrated;
// Step 1 — CTA visible
const cta = page.getByRole("button", {
+23 -16
View File
@@ -283,11 +283,18 @@ test.describe("document vault — AI assist + content search", () => {
await expect(hint).toBeVisible();
const indexAll = page.locator('[data-slot="content-index-all"]');
await expect(indexAll).toBeVisible();
await indexAll.click();
await expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible();
const reindexRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === "/api/documents/inbound/reindex",
);
await Promise.all([
reindexRequest,
expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible(),
indexAll.click(),
]);
expect(reindexCalls).toBe(1);
});
@@ -351,24 +358,24 @@ test.describe("document vault — AI assist + content search", () => {
expect(indexHadJsonBody).toBe(false);
});
// ── (h) Provenance: a provider-read document is marked "Read by AI" ──────
// ── (h) The detail view does not label how the text was extracted ────────
test("a vision-indexed document surfaces the AI-read provenance", async ({
test("a vision-indexed document carries no extraction-provenance line", async ({
page,
}) => {
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
// the vault card no longer carries the AI-read glyph (it looked wrong on
// the card face); the provenance now lives as a calm muted meta line in the
// document DETAIL view. Deep-link straight to the document so the sheet
// opens deterministically (the vault grid is virtualized).
// CONTENT_DOC_ID is seeded with source "vision". The detail view used to
// print a "Read by AI" meta line under the summary; it told the reader
// nothing they could act on — how the text came out of the page is our
// plumbing, not their business — so it is gone. This test keeps it gone:
// the sheet still opens on the deep link, and the line stays absent.
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
await expect(marker).toBeVisible();
await expect(marker).toHaveText("Read by AI");
await expect(
sheet.locator('[data-slot="document-detail-ai-read"]'),
).toHaveCount(0);
await expect(sheet.getByText("Read by AI")).toHaveCount(0);
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
+158 -1
View File
@@ -99,6 +99,42 @@ async function runAxe(page: Page) {
return blocking;
}
async function mockDocumentCoach(page: Page): Promise<void> {
const fulfil = (
route: Parameters<Parameters<Page["route"]>[1]>[0],
data: unknown,
) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
await page.route("**/api/documents/inbound/usage", (route) =>
fulfil(route, {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: true,
contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 },
}),
);
await page.route("**/api/documents/inbound/capability", (route) =>
fulfil(route, {
available: true,
mode: "vision",
reason: null,
pdfSupported: true,
egress: "external",
}),
);
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfil(route, { documentsAutoAiRead: false }),
);
}
test.describe("document vault", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -191,6 +227,121 @@ test.describe("document vault", () => {
await expect(page).not.toHaveURL(/doc=/);
});
test("ordinary card selection owns doc history and preserves unrelated query state", async ({
page,
}) => {
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
await page.goBack();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
await page.goForward();
await expect(detail).toBeVisible();
await page.keyboard.press("Escape");
await expect(detail).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
});
test("Coach close and overlay dismissal consume the document handoff", async ({
page,
}) => {
await mockDocumentCoach(page);
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
await expect(detail).not.toBeVisible();
await drawer.locator('[data-slot="coach-drawer-close"]').click();
await expect(drawer).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
await expect(detail).not.toBeVisible();
await page.reload();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect(detail).toBeVisible();
await detail.locator('[data-slot="document-chat-open"]').click();
await expect(drawer).toBeVisible();
await page
.locator('[data-slot="sheet-overlay"]')
.last()
.click({ position: { x: 2, y: 2 } });
await expect(drawer).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
await expect(detail).not.toBeVisible();
});
test("Ask Coach maximize then browser back restores the selected document", async ({
page,
}) => {
await mockDocumentCoach(page);
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
await drawer.locator('[data-slot="coach-drawer-maximize"]').click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/coach");
expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID);
await page.goBack();
await expect.poll(() => new URL(page.url()).pathname).toBe("/documents");
await expect(detail).toBeVisible();
const restoredUrl = new URL(page.url());
expect(restoredUrl.searchParams.get("doc")).toBe(MRT_DOC_ID);
expect(restoredUrl.searchParams.get("episode")).toBe(KNIE_EPISODE_ID);
expect(restoredUrl.searchParams.get("view")).toBe("compact");
expect(restoredUrl.searchParams.get("q")).toBe("MRT");
});
test("the condition page links into the vault and back", async ({ page }) => {
await page.goto(`/illness/${KNIE_EPISODE_ID}`);
const card = page.locator('[data-slot="episode-documents-card"]');
@@ -210,10 +361,16 @@ test.describe("document vault", () => {
await page.goto("/documents");
await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title));
await page.getByRole("searchbox", { name: "Search documents" }).fill(title);
// The uploaded card is optimistic and can paint before the 200 ms search
// draft is committed to the URL. Opening it earlier races that pending
// replace against the detail's `?doc=` history entry and closes the sheet.
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe(title);
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
await openButton(page, `${title}.pdf`).click();
const sheet = page.getByRole("dialog");
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Delete" }).click();
+26 -9
View File
@@ -29,6 +29,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// A visible server-rendered link is not yet an interactive React link.
// Wait for this client component's hydration boundary before scrolling or
// clicking so the pointer gesture cannot race React replacing the anchor.
const tabStrip = page.locator("[data-slot='insights-tab-strip']");
await expect(tabStrip).toHaveAttribute("data-hydrated", "true");
// v1.25.1 — the app-wide scroll-to-top resets the `#main-content`
// scroll container on every route change (auth-shell), with a
// `window.scrollTo(0,0)` fallback for the body-scrolled shells. The
@@ -53,18 +59,24 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
window.scrollTo({ top: 600, behavior: "auto" });
}
});
await expect.poll(readScrollTop).toBeGreaterThan(100);
// Click the first available sub-page pill.
const subPagePill = page
.locator("[data-slot='insights-tab-strip-pill']")
.nth(1);
// Recovery is always present and remains a direct link across responsive
// pill grouping. Target its route contract instead of a mutable list index.
const subPagePill = tabStrip.locator(
"[data-slot='insights-tab-strip-pill'][href='/insights/recovery']",
);
await expect(subPagePill).toBeVisible();
await subPagePill.click();
await page.waitForURL(/\/insights\//);
await expect(page).toHaveURL(/\/insights\/recovery\/?$/);
// Back to the mother page via the overview pill (pill index 0).
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
await page.waitForURL(/\/insights\/?$/);
// Back to the mother page through the overview pill.
const overviewPill = tabStrip.locator(
"[data-slot='insights-tab-strip-pill'][href='/insights']",
);
await expect(overviewPill).toBeVisible();
await overviewPill.click();
await expect(page).toHaveURL(/\/insights\/?$/);
// The shell's route-change effect resets the scroll on the next tick;
// `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it
@@ -91,6 +103,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const nav = page.locator("[data-slot='insights-tab-strip']");
const innerScroller = nav.locator(
"[data-slot='insights-tab-strip-scroller']",
);
await expect(nav).toHaveAttribute("data-hydrated", "true");
await expect(innerScroller).toBeVisible();
const navOverflowX = await nav.evaluate(
(el) => getComputedStyle(el).overflowX,
);
@@ -100,7 +118,6 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
expect(navOverflowX).not.toBe("auto");
expect(navOverflowX).not.toBe("scroll");
const innerScroller = nav.locator("> div > div").first();
const innerOverflowX = await innerScroller.evaluate(
(el) => getComputedStyle(el).overflowX,
);
+3 -2
View File
@@ -20,8 +20,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — daily cadence.
*
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* Walks the 7-step daily path through the modal dialog. The shared helper
* opens `/medications?new=1` directly so cadence coverage is independent of
* the legacy `/medications/new` redirect.
* The DE label surface (the maintainer's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
+9 -6
View File
@@ -120,8 +120,9 @@ export async function stubDashboardAnalytics(page: Page): Promise<void> {
}
/**
* Open the wizard dialog by hitting `/medications/new` (which
* redirects to `/medications?new=1` and opens the create dialog).
* Open the wizard directly on `/medications?new=1`. The redirect from the
* legacy `/medications/new` route is a separate concern; these cadence specs
* exercise the wizard without racing that server navigation.
*/
export async function openCreateWizard(page: Page): Promise<void> {
// Pin the German locale for the wizard flow. The app default locale is
@@ -136,12 +137,14 @@ export async function openCreateWizard(page: Page): Promise<void> {
{
name: "healthlog-locale",
value: "de",
url: page.url().startsWith("http")
? new URL(page.url()).origin
: "http://localhost:3000",
url: new URL(process.env.E2E_BASE_URL ?? "http://localhost:3000").origin,
},
]);
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await page.goto("/medications?new=1", { waitUntil: "load" });
await page.waitForURL(
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
{ timeout: 10_000 },
);
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toBeVisible({ timeout: 10_000 });
+23
View File
@@ -33,6 +33,29 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("medication wizard — one-shot", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("does not replay the one-shot URL flag after history traversal", async ({
page,
}) => {
await stubDashboardAnalytics(page);
await openCreateWizard(page);
await page.keyboard.press("Escape");
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toHaveCount(0);
await page.locator('a[href="/"]:visible').first().click();
await page.waitForURL((url) => url.pathname === "/");
await page.goBack();
await page.waitForURL(
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
);
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toHaveCount(0);
});
test("creates a single-dose medication end-to-end", async ({ page }) => {
test.slow();
await stubDashboardAnalytics(page);
+1 -1
View File
@@ -206,7 +206,7 @@ test.describe("mobile-viewport smoke", () => {
// 1) The center capture action opens the capture picker.
await page.getByTestId("bottom-nav-capture").click();
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
for (const kind of ["measurement", "medication", "mood"]) {
for (const kind of ["measurement", "medication", "mood", "water"]) {
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
}
// Dismiss the picker before opening the hub.
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("mood timestamp capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
const details = page.getByRole("button", { name: "Note & details" });
await expect(details).toHaveAttribute("aria-expanded", "false");
const timestamp = page.locator('[data-slot="date-time-field"]');
await expect(timestamp).toBeVisible();
const dateInput = timestamp.locator(
'[data-slot="date-field"] input[type="text"]',
);
const timeInput = timestamp.locator(
'[data-slot="time-field"] input[type="text"]',
);
await dateInput.fill("2026-01-15");
await dateInput.blur();
await timeInput.fill("09:45");
await timeInput.blur();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
});
});
});
+18
View File
@@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => {
}) => {
// v1.18.0 (S5) — the panel (and its disclosure) live on the
// dedicated Gesundheitsakte section now.
await page.route("**/api/share-links", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [], error: null, meta: null }),
});
});
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await sharingDataReady;
const toggle = page.getByTestId("health-record-included-data-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
+95 -4
View File
@@ -53,6 +53,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
);
const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString();
const monthAgo = new Date(
Date.now() - 28 * 24 * 60 * 60 * 1000,
).toISOString();
await page.route("**/api/integrations/status", (route) =>
route.fulfill({
@@ -81,6 +84,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
tokenExpired: false,
scope: "user.info,user.metrics,user.activity",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
],
},
{
integration: "moodlog",
@@ -91,9 +98,13 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
consecutiveFailures: 0,
configured: true,
enabled: true,
legacyLastSyncedAt: recent,
legacyLastSyncedAt: monthAgo,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
entryCount: 42,
// Configured, card-less, and no longer attempting: the case the
// fallback row exists for.
syncHealth: { verdict: "stalled", since: monthAgo },
metricFreshness: [],
},
// v1.28.x — Strava OAuth card reads off this consolidated
// envelope; without a `strava` entry the new card's query key
@@ -108,6 +119,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
configured: false,
available: false,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
// Nightscout reads the envelope too since the card dropped its
// own status round-trip; without an entry its pill never resolves.
{
integration: "nightscout",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
hasToken: false,
allowPrivateHost: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
],
},
@@ -157,10 +185,27 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
// carries no pill.)
// Oura, Strava, Nightscout — plus the fallback row for the configured,
// card-less moodLog entry. (The Garmin info note is not an OAuth card and
// carries no pill; the Apple Health card's pill keeps its own testid.)
const pills = page.locator('[data-testid="integration-status-pill"]');
await expect(pills).toHaveCount(8, { timeout: 10_000 });
await expect(pills).toHaveCount(9, { timeout: 10_000 });
// The acceptance case: a configured integration with no card of its own,
// which has stopped even attempting, is visible on the page.
const fallback = page.locator(
'[data-testid="integration-fallback-row"][data-integration="moodlog"]',
);
await expect(fallback).toHaveCount(1);
await expect(
fallback.locator('[data-testid="integration-status-pill"]'),
).toHaveAttribute("data-state", "stalled");
// Card anatomy is uniform now that Nightscout, the OAuth cards and Apple
// Health all carry the divider.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(9);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
@@ -198,3 +243,49 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}
});
});
test.describe("Apple Health guidance", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens on Integrations and reaches the one-shot import fallback", async ({
page,
}) => {
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
entries: [],
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
metricFreshness: [],
},
error: null,
}),
}),
);
await page.goto("/settings/integrations");
const card = page.getByTestId("apple-health-card");
await expect(card).toBeVisible();
await expect(card).toContainText("HealthLog iOS app");
await expect(card).toContainText("not a web or OAuth connection");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"pending-setup",
);
const importLink = page.getByTestId("apple-health-import-link");
await expect(importLink).toHaveAttribute(
"href",
"/settings/export#settings-section-import-title",
);
await page.goto("/settings/export#settings-section-import-title");
await expect(page).toHaveURL(
/\/settings\/export#settings-section-import-title$/,
);
await expect(page.getByTestId("import-card-apple-health")).toBeVisible();
});
});
+21 -3
View File
@@ -97,6 +97,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// that tries to click a header / sidebar / quick-add button times
// out (50+ failed CI runs since v1.4.13). Specs that need the
// tour can opt back in by mocking `/api/auth/me`.
// Nutrients is intentionally opt-in in production. The shared fixture
// enables it because the water-capture specs exercise that real gate.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
@@ -105,14 +107,16 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
codex_connection_status,
insights_privacy_mode,
onboarding_completed_at,
onboarding_tour_completed)
onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, $5,
$6, $6,
180, $7, 'MALE',
'disconnected',
'aggregated',
$6,
true)
true,
'{"nutrients":true}'::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
@@ -129,7 +133,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
insights_cached_at = NULL,
insights_cached_text = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_USER.username,
@@ -141,6 +146,19 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// Repeated local E2E runs reuse the same seeded account and database.
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
// run can start above the 20-operation ceiling and fail before exercising
// the share flow. Reset only this fixture user's bucket; never touch
// unrelated rate-limit state in a developer database.
await pool.query(
`DELETE FROM rate_limits
WHERE key = 'share-link:' || (
SELECT id::text FROM users WHERE username = $1
)`,
[E2E_USER.username],
);
// Opt the seed account into cycle tracking. The user is seeded as
// `MALE`, and v1.18.0 resolves the `cycle` module through
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
+21 -4
View File
@@ -57,7 +57,14 @@ interface CreatedShare {
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
// Open the document picker and isolate the seeded trio.
@@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
// Exactly the three share-fixture documents match the prefix.
const rows = list.getByRole("button");
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i++) await rows.nth(i).click();
for (let i = 0; i < 3; i++) {
const row = rows.nth(i);
await row.click();
await expect(row).toHaveAttribute("aria-pressed", "true");
}
await page.getByRole("button", { name: "Done" }).click();
// Three chips staged before create.
await expect(
page.getByTestId("share-attached-chips").getByRole("listitem"),
).toHaveCount(3);
await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled();
await page.getByRole("button", { name: "Create link" }).click();
@@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => {
clinician.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// Class A image → inline <img> at the token-scoped serve route.
await expect(
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
).toBeVisible();
const inlineImage = clinician.getByRole("img", {
name: "Share e2e scan",
});
await expect(inlineImage).toBeVisible();
await expect(inlineImage).toHaveAttribute(
"src",
new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`),
);
// Class A PDF → inline <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
+151
View File
@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.31.0 — Today reacts in place.
*
* The milestone's actual moment: new data lands, and the ALREADY-OPEN
* dashboard starts reading differently on the cadence it already runs. No new
* poll, no push, no socket — a state change, not a notification.
*
* The spec drives that end-to-end against the real read path:
*
* 1. load the dashboard with nothing fresh — no chip, day is provisional;
* 2. land last night's sleep through the REAL batch endpoint, and the
* arrival marker the spine's worker would have written;
* 3. surface the tab's real visibility-change signal — the browser event
* TanStack Query's focus manager consumes for
* `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll);
* using that existing trigger keeps the test inside the real cadence
* while staying well under the poll interval;
* 4. assert the chip appeared and the phase flipped — in place, no reload.
*
* The marker is seeded directly rather than waited for from pg-boss: whether
* the queue drains inside the e2e web server is the SPINE's contract and has
* its own tests. What this spec owns is everything downstream of a marker
* existing — the read path, the DTO, and the surface.
*
* Assertions anchor on `data-slot` / `data-phase`, never on visible text:
* responsive `sm:hidden` classes have broken `getByText` in this repo before.
*/
test.use({ storageState: STORAGE_STATE_PATH });
/** The user's local day, in the same profile-tz space the digest files under. */
function localDayKey(at: Date, timeZone: string): string {
// en-CA renders ISO-ordered YYYY-MM-DD, which is exactly the key shape.
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(at);
}
test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({
page,
}) => {
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
const pool = new pg.Pool({ connectionString: dbUrl });
try {
const { rows } = await pool.query<{ id: string; timezone: string | null }>(
"SELECT id, timezone FROM users WHERE username = $1",
[E2E_USER.username],
);
expect(rows.length, "seeded e2e user must exist").toBe(1);
const userId = rows[0].id;
const timezone = rows[0].timezone ?? "UTC";
// A clean slate: no marker, and last night's sleep not yet in. Both are
// preconditions for the "before" assertion to mean anything.
await pool.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
userId,
]);
await pool.query(
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
[userId],
);
// ── BEFORE ────────────────────────────────────────────────────────────
// The globally-seeded account is intentionally empty. Wait until the
// digest settles, then prove there is no stale hero or arrival chip.
await page.goto("/");
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
0,
);
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toHaveCount(0);
// ── NEW DATA LANDS ────────────────────────────────────────────────────
// Last night's sleep through the real ingest route, on the session the
// storage state already carries.
const now = new Date();
const wokeAt = new Date(now.getTime() - 60 * 60_000);
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
const batch = await page.request.post("/api/measurements/batch", {
data: {
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 420,
unit: "min",
startDate: fellAsleepAt.toISOString(),
endDate: wokeAt.toISOString(),
externalId: `e2e-just-in-${now.getTime()}`,
sleepStage: 3,
},
],
},
});
expect(
batch.ok(),
`sleep batch must be accepted (got ${batch.status()})`,
).toBe(true);
// The marker the spine's worker writes on a salient arrival, plus the
// morning-refresh stamp it rides alongside — the two rows that together
// make the day final and the arrival news.
const localDate = localDayKey(now, timezone);
await pool.query(
`INSERT INTO arrival_reactions
(id, user_id, kind, local_date, occurred_at, arrived_at, created_at)
VALUES ($1, $2, 'sleep_night', $3, $4, NOW(), NOW())
ON CONFLICT (user_id, kind, local_date) DO UPDATE
SET occurred_at = EXCLUDED.occurred_at,
arrived_at = NOW()`,
[`c${now.getTime()}justin000000`, userId, localDate, wokeAt],
);
await pool.query(
"UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1",
[userId, localDate],
);
// The tab becomes visible — the browser signal TanStack Query's focus
// manager consumes. The digest refetches in place; the page never reloads.
await page.evaluate(() =>
window.dispatchEvent(new Event("visibilitychange")),
);
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
timeout: 20_000,
});
await expect(hero).toHaveAttribute("data-phase", "final", {
timeout: 20_000,
});
// Sleep is in, so the provisional freshness note is gone with it.
await expect(
hero.locator('[data-slot="today-hero-sleep-pending"]'),
).toHaveCount(0);
// The chip names the arrival kind — the stable attribute, not the copy.
await expect(
hero.locator('[data-slot="today-hero-just-in"]'),
).toHaveAttribute("data-just-in-kind", "sleep_night");
} finally {
await pool.end();
}
});
+1
View File
@@ -279,6 +279,7 @@ export function buildMockSnapshot(
nextDueAt: null,
nextDueOverdue: false,
nextDueMedicationName: null,
nextDueMedicationId: null,
},
healthScore: options.briefing
? { score: 84, band: "green", delta: -2, restMode: null }
+169 -14
View File
@@ -5,21 +5,14 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 MB6 — `/measurements?add=<TYPE>` consumer.
*
* The insights empty-state CTAs link here with a `?add=<TYPE>` query
* param. The page reads the param during render, opens the
* Add-Measurement primitive, pre-selects the matching type, then
* `router.replace("/measurements")` strips the query so the back
* button drops the user on the bare list instead of re-opening the
* dialog.
* Insight metric actions link here with `?add=<TYPE>` and an optional,
* allowlisted `returnTo`. The page opens the responsive capture primitive,
* pre-selects the matching type, and strips the query without losing the
* one-shot return context.
*
* Three contracts under test:
* 1. The primitive opens on first paint.
* 2. The form's type combobox shows the requested type.
* 3. The URL settles on `/measurements` (no `?add=`).
*
* Pixel 5 is the load-bearing project — every CTA hop originates from
* the mobile-first empty-state surfaces. Desktop Chrome covers the
* regression path through the dashboard quick-entry.
* The browser contract covers the populated-page action, legacy preselection,
* failure retention, success return, and rejection of an external destination.
* Playwright runs every case in both the desktop Chromium and Pixel 5 projects.
*/
test.describe("v1.4.27 — /measurements?add= consumer", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -49,6 +42,57 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
);
});
test("a populated metric action opens the matching preselected capture", async ({
page,
}) => {
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.4,
count: 4,
min: 77.9,
max: 78.8,
mean: 78.3,
median: 78.25,
},
},
bpInTargetPct: null,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
// The action is server-rendered before React can handle client navigation.
// Pin the click to the Insights surface's existing hydration contract so
// it cannot race hydration and get replayed as a no-op.
await expect(
page.locator('[data-slot="insights-tab-strip"]'),
).toHaveAttribute("data-hydrated", "true");
const capture = page.locator('[data-slot="metric-add-reading"]');
await expect(capture).toBeVisible();
await expect(capture).toHaveAttribute(
"href",
"/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight",
);
await capture.click();
await expect(page).toHaveURL(/\/measurements(?:\?|$)/);
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible();
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
});
for (const { param, label } of [
{ param: "WEIGHT", label: /weight|gewicht/i },
{ param: "BLOOD_PRESSURE", label: /blood pressure|blutdruck/i },
@@ -76,6 +120,117 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
});
}
test("keeps the one-shot sheet and entered values when the write fails", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "Write failed" }),
});
return;
}
await route.fallback();
});
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible();
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
await content.locator("#value").fill("78.4");
await content.locator("#notes").fill("Keep this note");
await page
.getByRole("button", { name: /save|speichern/i })
.last()
.click();
await expect(content.getByRole("alert")).toBeVisible();
await expect(content.locator("#value")).toHaveValue("78.4");
await expect(content.locator("#notes")).toHaveValue("Keep this note");
await expect(content).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/measurements");
});
test("returns to the validated metric route only after a successful write", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "contextual-e2e", success: true },
error: null,
}),
});
return;
}
await route.fallback();
});
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
await content.locator("#value").fill("78.4");
await Promise.all([
page.waitForURL((url) => url.pathname === "/insights/weight"),
page
.getByRole("button", { name: /save|speichern/i })
.last()
.click(),
]);
});
test("rejects an external return target after a successful write", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "contextual-e2e", success: true },
error: null,
}),
});
return;
}
await route.fallback();
});
await page.goto(
"/measurements?add=WEIGHT&returnTo=https%3A%2F%2Fevil.example%2Fsteal",
{ waitUntil: "domcontentloaded" },
);
const appOrigin = new URL(page.url()).origin;
const content = page.locator('[data-slot="responsive-sheet-content"]');
await content.locator("#value").fill("78.4");
await page
.getByRole("button", { name: /save|speichern/i })
.last()
.click();
await expect(content).toHaveCount(0);
const settled = new URL(page.url());
expect(settled.origin).toBe(appOrigin);
expect(settled.pathname).toBe("/measurements");
});
test("unknown ?add value is dropped silently and the page renders empty", async ({
page,
}) => {
+144
View File
@@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
test.describe("water capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens globally, waits for success, and retains failed input", async ({
page,
}, testInfo) => {
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
let writeCount = 0;
let confirmSuccess: (() => void) | undefined;
const successConfirmed = new Promise<void>((resolve) => {
confirmSuccess = resolve;
});
await page.route("**/api/nutrients/water", async (route) => {
writeCount += 1;
if (writeCount === 1) {
await successConfirmed;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { amountMl: 250 }, error: null }),
});
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "write failed" }),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
const openWater = async (toastMayOverlap = false) => {
if (testInfo.project.name === "chromium-mobile") {
const capture = page.getByTestId("bottom-nav-capture");
if (toastMayOverlap) {
await capture.dispatchEvent("click");
} else {
await capture.click();
}
const option = page.getByTestId("capture-picker-water");
await expect(option).toBeVisible();
const box = await option.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
if (toastMayOverlap) {
await option.dispatchEvent("click");
} else {
await option.click();
}
} else {
await page.locator('[data-tour-id="dashboard-quick-add"]').click();
await page.getByRole("menuitem", { name: "Log water" }).click();
}
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
if (testInfo.project.name === "chromium-mobile") {
const actions = page
.getByRole("dialog")
.locator(
'[data-slot="water-quick-add-chips"] button, form button[type="submit"]',
);
await expect(actions).toHaveCount(4);
for (const action of await actions.all()) {
await expect(action).toHaveAccessibleName(/.+/);
const actionBox = await action.boundingBox();
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
}
}
};
await openWater();
const amount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await amount.fill("250");
const submit = page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true });
await amount.press("Enter");
await expect(submit).toBeDisabled();
await expect(amount).toHaveValue("250");
await expect(page.getByText("250 mL water added.")).toHaveCount(0);
confirmSuccess?.();
await expect(page.getByText("250 mL water added.")).toBeVisible();
await expect(page.getByRole("heading", { name: "Add water" })).toHaveCount(
0,
);
expect(writeCount).toBe(1);
await openWater(true);
const failedAmount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await expect(failedAmount).toHaveValue("");
await failedAmount.fill("375");
await page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(
page.getByText("Couldn't save that — try again."),
).toBeVisible();
await expect(failedAmount).toHaveValue("375");
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
await expect(page.getByText("375 mL water added.")).toHaveCount(0);
expect(writeCount).toBe(2);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
interface MockWorkout {
id: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
distanceM: null;
activeEnergyKcal: null;
avgHr: null;
maxHr: null;
source: string;
externalId: string;
}
function workout(id: number): MockWorkout {
return {
id: `workout-${id}`,
sportType: "running",
startedAt: "2026-07-20T08:00:00.000Z",
endedAt: "2026-07-20T08:30:00.000Z",
durationSec: 1800,
distanceM: null,
activeEnergyKcal: null,
avgHr: null,
maxHr: null,
source: "APPLE_HEALTH",
externalId: `external-${id}`,
};
}
test.describe("workout history pagination", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("loads canonical rows beyond 100 once without duplicate ids", async ({
page,
}) => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
workout(index + 1),
);
const secondPage = [
workout(100),
...Array.from({ length: 99 }, (_, index) => workout(index + 101)),
];
const thirdPage = [workout(200), workout(201)];
const observedOffsets: number[] = [];
let releaseFirstPage!: () => void;
const firstPageGate = new Promise<void>((resolve) => {
releaseFirstPage = resolve;
});
let releaseSecondPage!: () => void;
const secondPageGate = new Promise<void>((resolve) => {
releaseSecondPage = resolve;
});
await page.route(/\/api\/workouts(?:\?|$)/, async (route) => {
const url = new URL(route.request().url());
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
if (limit !== 100) {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
workouts: firstPage.slice(0, limit),
meta: {
total: 201,
limit,
offset,
droppedDuplicates: 0,
},
},
error: null,
}),
});
return;
}
observedOffsets.push(offset);
if (offset === 0) await firstPageGate;
if (offset === 100) await secondPageGate;
const workouts =
offset === 0 ? firstPage : offset === 100 ? secondPage : thirdPage;
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
workouts,
meta: {
total: 201,
limit,
offset,
droppedDuplicates: 0,
},
},
error: null,
}),
});
});
await page.goto("/insights/workouts");
await expect(page.locator('[data-slot="workouts-loading"]')).toBeVisible();
await expect(page.locator('[data-slot="workout-list-row"]')).toHaveCount(0);
releaseFirstPage();
const rows = page.locator('[data-slot="workout-list-row"]');
await expect(rows).toHaveCount(100);
await expect(page.locator('[data-slot="workouts-loading"]')).toHaveCount(0);
const loadMore = page.getByRole("button", { name: "Load more workouts" });
await loadMore.click();
await expect(
page.getByRole("button", { name: "Loading more workouts" }),
).toBeDisabled();
releaseSecondPage();
await expect(
page.locator('a[href="/insights/workouts/workout-101"]'),
).toHaveCount(1);
await expect(
page.locator('a[href="/insights/workouts/workout-100"]'),
).toHaveCount(1);
await expect(rows).toHaveCount(199);
await page.getByRole("button", { name: "Load more workouts" }).click();
await expect(
page.locator('a[href="/insights/workouts/workout-201"]'),
).toHaveCount(1);
await expect(rows).toHaveCount(201);
await expect(
page.getByRole("button", { name: "Load more workouts" }),
).toHaveCount(0);
expect(observedOffsets).toEqual([0, 100, 200]);
});
});
@@ -0,0 +1,72 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../api-fetch-required.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
ruleTester.run("api-fetch-required", rule, {
valid: [
{
code: "apiGet(endpoint); apiFetchRaw(externalUrl);",
filename: "/repo/src/components/example.tsx",
},
{
code: 'fetch("/api/server-prefetch");',
filename: "/repo/src/app/server-page.tsx",
},
{
code: 'fetch("https://example.test/data");',
filename: "/repo/src/lib/server-only.ts",
},
{
code: "fetch(path, init);",
filename: "/repo/src/lib/api/api-fetch.ts",
},
{
code: 'fetch("/api/mock");',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
],
invalid: [
{
code: "fetch(endpoint);",
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: "window.fetch(`/api/${id}`);",
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: 'globalThis.fetch("https://example.test/data");',
filename: "/repo/src/hooks/use-example.ts",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: '"use client"; self.fetch(url);',
filename: "/repo/src/app/client-page.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: '"use client"; fetch(endpoint);',
filename: "/repo/src/lib/i18n/context.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: 'window["fetch"](endpoint);',
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
],
});
@@ -0,0 +1,64 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import plugin from "../queryKey-factory.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const rule = plugin.rules["queryKey-factory"];
ruleTester.run("queryKey-factory", rule, {
valid: [
{
code: "useQuery({ queryKey: queryKeys.example(id) });",
filename: "/repo/src/components/example.tsx",
},
{
code: "const key = queryKeys.example(id); useQuery({ queryKey: key });",
filename: "/repo/src/hooks/use-example.ts",
},
{
code: 'export const example = () => ["example"];',
filename: "/repo/src/lib/query-keys/example.ts",
},
{
code: 'useQuery({ queryKey: ["fixture"] });',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
{
code: 'useQuery({ queryKey: ["server-prefetch"] });',
filename: "/repo/src/app/server-page.tsx",
},
],
invalid: [
{
code: 'useQuery({ queryKey: ["unguarded-component"] });',
filename: "/repo/src/components/custom-metrics/example.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: 'useMutation({ mutationKey: ["unguarded-hook"] });',
filename: "/repo/src/hooks/use-example.ts",
errors: [{ messageId: "bareArray" }],
},
{
code: '"use client"; useQuery({ queryKey: ["client-app"] });',
filename: "/repo/src/app/previously-unguarded/page.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: '"use client"; useQuery({ queryKey: ["client-lib"] });',
filename: "/repo/src/lib/client-state.tsx",
errors: [{ messageId: "bareArray" }],
},
],
});
+39 -66
View File
@@ -1,35 +1,24 @@
/**
* @fileoverview ESLint rule — client-side `/api/...` calls must route
* through the typed `apiFetch` wrapper.
* @fileoverview ESLint rule — client-side requests must route through the
* typed API-fetch entry points.
*
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
* (message via `readError`, `status`, error-side `meta`). A raw
* `fetch("/api/…")` in a component re-implements that contract by hand
* — the exact drift this wrapper retired — so this rule flags it at
* authoring time and in CI.
* A raw `fetch(...)` in a client module can hide a same-origin API request
* behind a variable, endpoint map, or conditional. Checking only literal
* `/api/...` arguments therefore leaves the envelope and error contract open
* to bypass. This rule rejects every bare Fetch API call in the client surface,
* regardless of argument shape.
*
* Complements `healthlog/safe-fetch-required`: that rule covers
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
* same-origin relative paths; this rule covers exactly those same-origin
* `/api/...` paths inside the client surface.
* Use `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` for envelope
* JSON. Use `apiFetchEnvelope` when success metadata is required, and
* `apiFetchRaw` for deliberate Response-level work such as external requests,
* downloads, streams, beacons, or manual status branching.
*
* Allowed:
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
* distinct identifiers, never match.
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
* mock or assert against `fetch` directly.
*
* The check is a syntactic `CallExpression` match against a bare
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
* `window.fetch(` / `self.fetch(` member forms, where the first
* argument is a string literal or template literal whose head starts
* with `/api/`. Variable-sourced or absolute URLs are the
* safe-fetch-required rule's territory and stay out of scope here.
* Components and hooks are client-facing by convention. Other `src/` modules
* are guarded when they declare top-level `"use client"`; `src/lib/queries/`
* remains an explicit client-transitive root. Server-only modules, tests,
* mocks, and the wrapper implementation are exempt.
*
* @see src/lib/api/api-fetch.ts
* @see eslint-plugins/healthlog/safe-fetch-required.js
*/
"use strict";
@@ -39,20 +28,9 @@
// `String#includes`, mirroring the safe-fetch-required convention.
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// Enforce across the client-facing source roots. `src/lib/` stays out:
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
// through the wrapper like every component. `src/lib/queries/` is the
// one `src/lib/` exception: it holds client-only TanStack-Query hooks
// (every file is `"use client"`), so its `/api/` reads route through the
// wrapper too.
const ENFORCED_ROOTS = [
"src/components/",
"src/app/",
"src/hooks/",
"src/lib/queries/",
];
// Components and hooks are client-facing by convention. Query modules are
// client-transitive even if a future refactor moves the directive to a barrel.
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
@@ -67,12 +45,17 @@ function isTestFile(posix) {
);
}
function isEnforced(filename) {
function isEnforced(filename, sourceCode) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
return sourceCode.ast.body.some(
(statement) =>
statement.type === "ExpressionStatement" &&
statement.directive === "use client",
);
}
/** @type {import("eslint").Rule.RuleModule} */
@@ -81,49 +64,40 @@ const apiFetchRequiredRule = {
type: "problem",
docs: {
description:
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
"Client-side requests must route through the apiFetch wrapper family (src/lib/api/api-fetch.ts).",
},
schema: [],
messages: {
rawApiFetch:
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
"Raw fetch() bypasses the client request contract. Use an apiFetch envelope helper, or apiFetchRaw for deliberate Response-level/external requests.",
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
if (!filename || !isEnforced(filename, sourceCode)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`,
// including their static computed forms (`window["fetch"](...)`).
if (
callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier" &&
callee.property.name === "fetch" &&
callee.object.type === "Identifier" &&
(callee.object.name === "globalThis" ||
callee.object.name === "window" ||
callee.object.name === "self")
) {
return true;
}
return false;
}
function isApiPath(arg) {
if (!arg) return false;
const isApiHead = (head) => head.startsWith("/api/");
// `fetch("/api/…")`
if (arg.type === "Literal" && typeof arg.value === "string") {
return isApiHead(arg.value);
}
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
return isApiHead(arg.quasis[0].value.cooked ?? "");
const propertyName =
!callee.computed && callee.property.type === "Identifier"
? callee.property.name
: callee.computed && callee.property.type === "Literal"
? callee.property.value
: null;
return propertyName === "fetch";
}
return false;
}
@@ -131,7 +105,6 @@ const apiFetchRequiredRule = {
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
if (!isApiPath(node.arguments[0])) return;
context.report({ node, messageId: "rawApiFetch" });
},
};
+43 -81
View File
@@ -1,96 +1,57 @@
/**
* @fileoverview ESLint rule — `queryKey` / `mutationKey` literal-array
* factory enforcement.
* factory enforcement across the complete client tree.
*
* v1.4.41 W-PROCESS-DOCS — promoted from the test-guard substitute
* (`src/lib/__tests__/query-keys.test.ts`, factory-bypass guard added
* in v1.4.40 W-RSC) to a real ESLint rule. The test-guard substitute
* stays in place as a belt-and-braces safety net for repo-walk
* coverage of files the ESLint config might miss; this rule fires at
* authoring time inside the IDE and at lint CI time.
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags
* object-property `queryKey: [ … ]` and `mutationKey: [ … ]` values in
* components, hooks, client query modules, and any source module declaring
* top-level `"use client"`.
*
* The rule flags any object-property `queryKey: [ … ]` or
* `mutationKey: [ … ]` whose value is a bare ArrayExpression — i.e.
* the bypass shape that drove audit-H1 in `.planning/round-v1439-
* arch-qa-frontend.md`. Acceptable shapes:
* Accepted call-site shapes include direct factory calls, identifiers already
* built from the factory, and conditionals selecting factory calls. Tests and
* the factory definition directory are exempt because they intentionally
* construct literal fixtures and tuples.
*
* - `queryKey: queryKeys.something(arg)` — factory call
* - `queryKey: someLocal` — indirect identifier
* - `queryKey: cond ? queryKeys.a() : queryKeys.b()` — conditional via factory
*
* Files outside `WHITELISTED_FILES` are exempt entirely — the
* test-guard substitute holds the "guarded surface" line until a
* future wave extends both the guard and this whitelist together.
*
* @see src/lib/query-keys/ — factory home (do not lint as offender)
* @see src/lib/__tests__/query-keys.test.ts — test-guard substitute
* @see .planning/phase-W-PROCESS-DOCS-v1441-report.md
* @see src/lib/query-keys/
* @see src/lib/__tests__/query-keys.test.ts
*/
"use strict";
// Files that are ALLOWED to declare a literal-array queryKey. Mirrors
// the test-guard substitute's `guardedRoots` allowlist inverted: every
// file under those roots MUST use the factory, every file outside is
// exempt for now. Path matches use `String#includes` semantics against
// the absolute filename — keep entries as posix-style suffix paths.
const FACTORY_HOME_FILES = [
// The factory's own unit-test file constructs literal keys to assert
// the factory output shape.
"src/lib/__tests__/query-keys.test.ts",
];
// The factory definition files are allowed to declare literal keys —
// that's the whole point of the factory. The factory is split into
// per-feature files under this directory (barrel at index.ts).
const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
// Directories under which any `queryKey: [ … ]` literal is an error.
// Extend in lockstep with the test-guard substitute's `guardedRoots`.
const GUARDED_DIRECTORIES = [
"src/components/charts",
"src/components/comparison",
// v1.4.41 W-FRONTEND-FACTORY — auth + notifications migrated to the
// factory. Mirror the test-guard's guardedRoots so IDE/lint CI fail
// at the same boundary the unit-test walker does.
"src/app/auth",
"src/app/notifications",
// v1.4.42 W3-QUERYKEY-LONGTAIL — the settings / medications /
// admin / hooks surface is now factory-only. Every long-tail
// bare-literal flagged by audit-H1 routes through `queryKeys.<entry>()`.
"src/components/settings",
"src/components/medications",
"src/components/admin",
"src/hooks",
];
// Components and hooks are client-facing by convention. Query modules are
// client-transitive even if a future refactor moves the directive to a barrel.
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
// Individual guarded files outside the directories above.
const GUARDED_FILES = [
"src/app/page.tsx",
"src/hooks/use-auth.ts",
// v1.4.41 W-FRONTEND-FACTORY — about-section migrated.
"src/components/settings/about-section.tsx",
// v1.4.42 W3-QUERYKEY-LONGTAIL — medications + targets pages now
// route every read through the factory.
"src/app/medications/page.tsx",
"src/app/medications/[id]/history/page.tsx",
"src/app/targets/page.tsx",
];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isGuarded(filename) {
// Factory home is never an offender even if it shows up under a
// guarded directory by accident.
for (const home of FACTORY_HOME_FILES) {
if (filename.endsWith(home)) return false;
function isTestFile(posix) {
return (
/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isGuarded(filename, sourceCode) {
const posix = toPosix(filename);
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
if (
posix.startsWith(`${FACTORY_HOME_DIRECTORY}/`) ||
posix.includes(`/${FACTORY_HOME_DIRECTORY}/`)
) {
return false;
}
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
for (const dir of GUARDED_DIRECTORIES) {
if (filename.includes(`/${dir}/`)) return true;
}
for (const file of GUARDED_FILES) {
if (filename.endsWith(file)) return true;
}
return false;
if (isTestFile(posix)) return false;
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
return sourceCode.ast.body.some(
(statement) =>
statement.type === "ExpressionStatement" &&
statement.directive === "use client",
);
}
/** @type {import('eslint').Rule.RuleModule} */
@@ -99,7 +60,7 @@ const queryKeyFactoryRule = {
type: "problem",
docs: {
description:
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
"Disallow literal-array `queryKey` / `mutationKey` declarations in client modules. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
recommended: false,
},
messages: {
@@ -110,7 +71,8 @@ const queryKeyFactoryRule = {
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isGuarded(filename)) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
if (!filename || !isGuarded(filename, sourceCode)) {
return {};
}
+65 -15
View File
@@ -13,22 +13,22 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// v1.4.41 — ignore local tool worktrees so a worktree on an older
// commit can't poison `pnpm lint` locally.
// CI never sees this path; the rule is for the dev environment.
// v1.31.4 — ignore local tool worktrees so a worktree on another
// commit cannot poison `pnpm lint` locally.
// CI never sees these paths; the rules protect the dev environment.
".claude/**",
".worktrees/**",
// The project-local ESLint rule plugin is CommonJS (require/module
// .exports) by ESLint convention; it is not application source and
// is not linted by the app's TypeScript ruleset.
"eslint-plugins/**",
"coverage/**",
"playwright-report/**",
"test-results/**",
]),
// v1.4.41 — custom rule that flags any bare-array
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
// files the queryKeys factory currently guards. Promotes the earlier
// v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
// both fail fast on a factory bypass. See
// `eslint-plugins/healthlog/queryKey-factory.js` for the
// whitelist + rationale.
// Factory enforcement for every client module: all components/hooks plus
// `"use client"` app/lib modules. Tests and `src/lib/query-keys/**` own
// literal fixtures/tuples and are exempt inside the rule.
{
plugins: { healthlog: healthlogPlugin },
rules: {
@@ -38,11 +38,11 @@ const eslintConfig = defineConfig([
// requirePublicHost connect-time pin). The wrapper internals and
// test files are exempt; see the rule file for the allowlist.
"healthlog/safe-fetch-required": "error",
// v1.16.4 — same-origin `/api/...` calls in the client surface
// (src/components + src/app) must route through the typed apiFetch
// wrapper (`src/lib/api/api-fetch.ts`): one `.ok` check, one
// envelope unwrap, one ApiError shape. Wrapper internals and test
// files are exempt; see the rule file for the allowlist.
// Every raw fetch in the complete client surface is rejected regardless
// of whether the endpoint is a literal, template, variable, or map
// lookup. Envelope JSON uses the verb helpers; deliberate Response-level
// work and non-API external calls use the explicit `apiFetchRaw` escape
// hatch. Server-only modules, wrapper internals, and tests are exempt.
"healthlog/api-fetch-required": "error",
// v1.26.0 — the design-consistency wave locked the app's colour
// vocabulary onto semantic tokens. Raw Tailwind palette utilities
@@ -77,6 +77,56 @@ const eslintConfig = defineConfig([
"healthlog/spacing-scale": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
// shared `chart-runtime.ts` barrel (see its file header): pointing N
// `next/dynamic` boundaries at N different modules mints a separate
// ~312 KB recharts chunk per boundary, a regression the project already
// paid for once. Direct `from "recharts"` imports outside the barrel's
// own static import graph previously only tripped the bundle-budget
// gate at PR-CI; this rule catches the same mistake at local lint,
// mirroring how `healthlog/safe-fetch-required` scopes its own
// wrapper-internals exemption by file list.
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/components/charts/chart-runtime.ts",
"src/components/admin/host-metrics-chart.tsx",
"src/components/charts/health-chart.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/charts/mood-chart.tsx",
"src/components/charts/nutrient-daily-bar-chart.tsx",
"src/components/charts/scatter-correlation-chart.tsx",
"src/components/custom-metrics/custom-metric-chart.tsx",
"src/components/cycle/bbt-chart.tsx",
"src/components/insights/derived/delta-sparkline.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
"src/components/insights/mood/mood-distribution-chart.tsx",
"src/components/insights/mood/mood-time-of-day-chart.tsx",
"src/components/insights/mood/mood-weekday-chart.tsx",
"src/components/insights/sleep-stage-stacked-bar.tsx",
"src/components/labs/lab-biomarker-chart.tsx",
"src/components/medications/detail/efficacy/efficacy-chart.tsx",
"src/components/medications/dose-strength-curve.tsx",
"src/components/medications/drug-level-chart.tsx",
"src/components/mental-health/assessment-history-chart.tsx",
"src/components/charts/workout-hr-chart.tsx",
"src/components/charts/workout-elevation-chart.tsx",
],
rules: {
"no-restricted-imports": [
"error",
{
paths: [
{
name: "recharts",
message:
"Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.",
},
],
},
],
},
},
]);
export default eslintConfig;
-1
View File
@@ -47,7 +47,6 @@
".next/**",
"**/__mocks__/**",
"e2e/setup/test-helpers.ts",
"src/components/charts/compliance-line-chart.tsx",
"src/lib/logging/index.ts"
],
"ignoreDependencies": [
+18
View File
@@ -16,6 +16,24 @@
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
# Commit-message guard: the project voice rule (CLAUDE.md, "Voice and
# privacy") states that commits are authored by the maintainer's git
# identity alone and carry no tooling co-author trailer or session link.
# Editors and CLIs add those trailers by default, and a message that
# picks one up is only removable from the object store by a history
# rewrite — so the check belongs here, before the commit exists, not in
# a review pass afterwards.
commit-msg:
commands:
no-tooling-trailer:
run: |
if grep -qiE '^(Co-Authored-By:.*(claude|anthropic|copilot|gpt|gemini)|Claude-Session:|Generated with)' "{1}"; then
echo "ERROR: the commit message carries a tooling co-author trailer or session link." >&2
echo "Project policy: commits are authored by the maintainer's git identity alone." >&2
echo "Remove the offending line(s) from the message and commit again." >&2
exit 1
fi
pre-commit:
commands:
gitleaks:
+419 -77
View File
File diff suppressed because it is too large. Load diff
+406 -74
View File
File diff suppressed because it is too large. Load diff
+417 -73
View File
File diff suppressed because it is too large. Load diff
+419 -73
View File
File diff suppressed because it is too large. Load diff
+409 -73
View File
File diff suppressed because it is too large. Load diff
+403 -73
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -231,7 +231,6 @@ const nextConfig: NextConfig = {
serverExternalPackages: [
"@prisma/client",
"pg-boss",
"@prisma/adapter-pg",
"pg",
// Document PDF handling MUST run the real, un-bundled modules. When
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
+8 -18
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.29.4",
"version": "1.32.31",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -22,9 +22,9 @@
"passkey"
],
"private": true,
"packageManager": "pnpm@10.31.0",
"packageManager": "pnpm@11.15.1",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"prepare": "node scripts/prepare.mjs",
"dev": "next dev",
"predev": "node scripts/generate-i18n-boot.mjs",
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
@@ -59,7 +59,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@napi-rs/canvas": "0.1.80",
"@napi-rs/canvas": "1.0.2",
"@node-rs/argon2": "^2.0.2",
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.8.0",
@@ -79,7 +79,7 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.10",
"next": "16.2.11",
"next-themes": "^0.4.6",
"nodemailer": "9.0.3",
"otpauth": "^9.5.1",
@@ -87,7 +87,7 @@
"pg-boss": "^12.26.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.0",
"radix-ui": "^1.6.2",
"react": "19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.7",
@@ -125,25 +125,15 @@
"knip": "^6.16.1",
"lefthook": "^2.1.10",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "5.4.296",
"pdfjs-dist": "6.1.200",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.13.0",
"tailwindcss": "^4",
"testcontainers": "^12.0.3",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"prisma",
"@prisma/engines"
],
"overrides": {
"hono@<4.12.25": "^4.12.25",
"picomatch@<4.0.4": "^4.0.4"
}
}
}
+9 -5
View File
@@ -22,13 +22,13 @@ export default defineConfig({
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
failOnFlakyTests: !!process.env.CI,
// Two retries on CI: under a loaded shared runner, DOM-settle-gated
// assertions (wizard step transitions, list refetch → card provenance,
// disclosure toggles) intermittently sample a mid-transition frame. A
// rotating single test failed each run while 211 passed; a second retry
// absorbs that transient contention without masking a real failure (a true
// break fails all three attempts). The default expect timeout is also
// lifted from 5s → 10s for the same settle headroom.
// retry distinguishes transient contention from a persistent break, while
// failOnFlakyTests keeps either outcome visible to CI. The default expect
// timeout is lifted from 5s → 10s for the same settle headroom.
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
@@ -95,7 +95,7 @@ export default defineConfig({
webServer: process.env.E2E_SKIP_WEB_SERVER
? undefined
: {
command: "pnpm exec next start --port 3000",
command: `mkdir -p .next/standalone/.next/static .next/standalone/public && cp -R .next/static/. .next/standalone/.next/static && cp -R public/. .next/standalone/public && PORT=3000 HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: "http://localhost:3000/api/version",
timeout: 60_000,
reuseExistingServer: !process.env.CI,
@@ -103,6 +103,10 @@ export default defineConfig({
stderr: "pipe",
env: {
...process.env,
// Native document rendering is fail-soft and outside the browser
// suite. Disable it here so queued local thumbnail jobs cannot
// crash the shared server inside Skia during unrelated scenarios.
NATIVE_CANVAS: "off",
// The dashboard RSC wrapper server-prefetches the snapshot into
// the first HTML (HydrationBoundary). Playwright's route mocks
// only see CLIENT fetches, so an SSR-embedded snapshot would
+953 -813
View File
File diff suppressed because it is too large. Load diff
+23 -3
View File
@@ -1,3 +1,23 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
overrides:
"dompurify@<=3.4.10": "^3.4.11"
"postcss@<8.5.10": "^8.5.10"
"@hono/node-server@<1.19.13": "^1.19.13"
"hono@<4.12.25": "^4.12.25"
"picomatch@<4.0.4": "^4.0.4"
"body-parser@<2.3.0": "^2.3.0"
"fast-uri@<3.1.3": "^3.1.3"
"sharp@<0.35.0": "^0.35.3"
allowBuilds:
"@prisma/engines": true
prisma: true
core-js: false
cpu-features: false
esbuild: false
lefthook: false
msw: false
protobufjs: false
sharp: false
ssh2: false
tesseract.js: false
unrs-resolver: false
@@ -0,0 +1,23 @@
-- Persist a short plain-language summary of an uploaded document.
--
-- Adds two nullable columns to `inbound_documents`:
-- - `summary_encrypted` — AES-256-GCM ciphertext of a 3-4 sentence
-- plain-language description of WHAT the document is, generated ONCE in
-- the background right after upload when the user's `documents_auto_ai_read`
-- opt-in is ON (that flag is also the standing AI-egress consent). NULL
-- when auto-read is OFF, no provider is configured, or generation has not
-- run yet.
-- - `summary_generated_at` — when that background summary was generated
-- (NULL until it runs).
--
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
-- encrypted-note convention already in the schema (the `encrypt()` ciphertext
-- string stored UTF-8 as `bytea`, the shape the rotation script's
-- `rotateBytesColumn` already covers). The column is registered in
-- ENCRYPTED_COLUMNS + the rotation script in the same change.
--
-- ADDITIVE ONLY: existing rows keep NULL summaries; the on-demand transient
-- summary route is unchanged.
ALTER TABLE "inbound_documents"
ADD COLUMN IF NOT EXISTS "summary_encrypted" BYTEA,
ADD COLUMN IF NOT EXISTS "summary_generated_at" TIMESTAMP(3);
@@ -0,0 +1,103 @@
-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values.
--
-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the
-- second workout integration that never normalised its sport to HealthLog's
-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in
-- src/lib/validations/workout.ts). `mapActivity()` in
-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or
-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the
-- literal fallback "workout" when neither field was present — straight into
-- `Workout.sport_type`. Existing Strava rows therefore carry values the
-- `/insights/workouts` list can't map to an icon/label, AND the read-time
-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any
-- two non-canonical ("generic") rows as sport-compatible — a same-source
-- brick session (a Strava "Ride" ending 15:00 immediately followed by a
-- Strava "Run" starting 15:03) could cluster into one and silently drop a
-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write
-- path going forward; this migration re-derives every EXISTING Strava
-- `Workout` row from the same table so historical data and the live mapper
-- never drift apart.
--
-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in
-- src/lib/strava/sport-map.ts (one JS-side script run against that exact
-- table, not hand-transcribed). The match expression lower-cases the stored
-- column and strips every character outside `[a-zA-Z0-9]`, mirroring
-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun",
-- "trailrun", and any stray-separator variant all resolve to the same key —
-- and so the pre-fix literal fallback "workout" resolves to the same row as
-- Strava's own "Workout" catch-all.
--
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label.
--
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
-- one of the 20 canonical values, so a re-run touches zero rows once the
-- first run has completed. Safe to re-run after a partial failure.
UPDATE "workouts"
SET "sport_type" = CASE
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g'))
WHEN 'alpineski' THEN 'other'
WHEN 'backcountryski' THEN 'other'
WHEN 'badminton' THEN 'tennis'
WHEN 'basketball' THEN 'basketball'
WHEN 'canoeing' THEN 'rowing'
WHEN 'cricket' THEN 'other'
WHEN 'crossfit' THEN 'crossTraining'
WHEN 'dance' THEN 'dance'
WHEN 'ebikeride' THEN 'cycling'
WHEN 'elliptical' THEN 'elliptical'
WHEN 'emountainbikeride' THEN 'cycling'
WHEN 'golf' THEN 'golf'
WHEN 'gravelride' THEN 'cycling'
WHEN 'handcycle' THEN 'cycling'
WHEN 'highintensityintervaltraining' THEN 'hiit'
WHEN 'hike' THEN 'hiking'
WHEN 'iceskate' THEN 'other'
WHEN 'inlineskate' THEN 'other'
WHEN 'kayaking' THEN 'rowing'
WHEN 'kitesurf' THEN 'other'
WHEN 'mountainbikeride' THEN 'cycling'
WHEN 'nordicski' THEN 'other'
WHEN 'padel' THEN 'tennis'
WHEN 'physicaltherapy' THEN 'mindAndBody'
WHEN 'pickleball' THEN 'tennis'
WHEN 'pilates' THEN 'mindAndBody'
WHEN 'racquetball' THEN 'tennis'
WHEN 'ride' THEN 'cycling'
WHEN 'rockclimbing' THEN 'crossTraining'
WHEN 'rollerski' THEN 'other'
WHEN 'rowing' THEN 'rowing'
WHEN 'run' THEN 'running'
WHEN 'sail' THEN 'other'
WHEN 'skateboard' THEN 'other'
WHEN 'snowboard' THEN 'other'
WHEN 'snowshoe' THEN 'hiking'
WHEN 'soccer' THEN 'soccer'
WHEN 'squash' THEN 'tennis'
WHEN 'stairstepper' THEN 'stairClimber'
WHEN 'standuppaddling' THEN 'rowing'
WHEN 'surfing' THEN 'other'
WHEN 'swim' THEN 'swimming'
WHEN 'tabletennis' THEN 'tennis'
WHEN 'tennis' THEN 'tennis'
WHEN 'trailrun' THEN 'running'
WHEN 'velomobile' THEN 'cycling'
WHEN 'virtualride' THEN 'cycling'
WHEN 'virtualrow' THEN 'rowing'
WHEN 'virtualrun' THEN 'running'
WHEN 'volleyball' THEN 'other'
WHEN 'walk' THEN 'walking'
WHEN 'weighttraining' THEN 'strength'
WHEN 'wheelchair' THEN 'other'
WHEN 'windsurf' THEN 'other'
WHEN 'workout' THEN 'other'
WHEN 'yoga' THEN 'yoga'
ELSE 'other'
END
WHERE "source" = 'STRAVA'
AND "sport_type" NOT IN (
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
'mixedCardio', 'other'
);
@@ -0,0 +1,48 @@
-- Native OIDC SSO session handoff (iOS#49).
--
-- One-time handoff codes bridging the browser-context OIDC callback to the
-- cookie-less native token exchange. The web OIDC flow ends in a session
-- cookie a Bearer-transport native client can never consume; this table lets
-- the callback mint an opaque, PKCE-locked, single-use code that the app
-- exchanges for the standard native token bundle at
-- POST /api/auth/oidc/native/token.
--
-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its
-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge
-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at
-- exchange so a replay (a second presentation after consumption) can revoke
-- exactly that family member — refresh-token reuse-detection reach-back.
-- CreateTable
CREATE TABLE "oidc_native_handoffs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"code_challenge" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"issued_refresh_token_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ip_address" TEXT,
"user_agent" TEXT,
CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash
-- collision can never resolve to two live codes.
CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key"
ON "oidc_native_handoffs" ("code_hash");
-- CreateIndex — per-user audit scans + the FK-cascade delete path.
CREATE INDEX "oidc_native_handoffs_user_id_idx"
ON "oidc_native_handoffs" ("user_id");
-- CreateIndex — the daily cleanup sweep filters on `expires_at`.
CREATE INDEX "oidc_native_handoffs_expires_at_idx"
ON "oidc_native_handoffs" ("expires_at");
-- AddForeignKey — deleting a user cascades their handoff rows away.
ALTER TABLE "oidc_native_handoffs"
ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- v1.30.12 perf — index the two medication-intake read shapes the dashboard
-- "meds today" builder fires that could not index-seek before: the sibling
-- reads filtering (user_id, scheduled_for) without medication_id, and the
-- per-medication last-taken group-by that scanned the whole intake history.
-- Additive; no data change.
-- CreateIndex
CREATE INDEX "medication_intake_events_user_id_scheduled_for_idx" ON "medication_intake_events"("user_id", "scheduled_for");
-- CreateIndex
CREATE INDEX "medication_intake_events_user_id_medication_id_taken_at_idx" ON "medication_intake_events"("user_id", "medication_id", "taken_at");
@@ -0,0 +1,26 @@
-- Record what happened to a document's summary.
--
-- `summary_encrypted` alone cannot tell four outcomes apart: a document nobody
-- ever enqueued, one whose provider is missing, one the safety screen withheld,
-- and one still in flight all leave it NULL. The detail view read that NULL as
-- "still generating" and said so forever.
--
-- Additive and backfill-free: NONE is the default and is the truthful value for
-- every pre-existing row, because none of them were ever attempted. Rows that
-- already carry a summary are moved to READY below.
CREATE TYPE "document_summary_state" AS ENUM (
'NONE',
'PENDING',
'READY',
'WITHHELD',
'UNAVAILABLE'
);
ALTER TABLE "inbound_documents"
ADD COLUMN "summary_state" "document_summary_state" NOT NULL DEFAULT 'NONE';
-- A document that already holds a summary is READY, not NONE.
UPDATE "inbound_documents"
SET "summary_state" = 'READY'
WHERE "summary_encrypted" IS NOT NULL;
@@ -0,0 +1,21 @@
-- Session cookies carry a purpose-generated secret rather than the row's cuid
-- primary key.
--
-- A cuid is built for collision-resistant identity, not for unguessability: it
-- embeds a timestamp and a monotonic counter and does not come from a CSPRNG.
-- A session cookie is a bearer credential and must. The cookie now carries a
-- 32-byte random token (`hls_<hex>`) and only its HMAC-SHA256 hash is stored,
-- matching how api_tokens and refresh_tokens already hold their secrets.
--
-- Additive and nullable ON PURPOSE: every session row that exists at deploy
-- time keeps NULL here and stays resolvable by its id, so the release signs
-- nobody out. Those rows cannot have their sliding expiry extended, so the
-- id-as-credential path drains itself within the 30-day session lifetime with
-- no operator action. Postgres permits many NULLs under a unique index, so the
-- constraint below does not conflict with the backfill-free transition.
-- AlterTable
ALTER TABLE "sessions" ADD COLUMN "token_hash" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
@@ -0,0 +1,14 @@
-- Carton labelling for an inventory container: manufacturer + printed strength.
--
-- The native client's pen list uses these two as its headline and subhead, so
-- a container created on the web could not be shown there without fabricating
-- an empty card. Nothing on the server held them, so the client kept its own
-- copy locally and no back-fill was possible.
--
-- Both are additive and nullable. Every pre-existing row is truthfully NULL:
-- the values were never collected, and a plain supply row (a blister pack, a
-- bottle) has no use for either. No backfill, no default, no rewrite of the
-- table's existing contents.
ALTER TABLE "medication_inventory_items"
ADD COLUMN "manufacturer" TEXT,
ADD COLUMN "dose_strength" TEXT;
@@ -0,0 +1,53 @@
-- The data-arrival spine's durable reaction marker.
--
-- One row per (user, arrival kind, local day), written by the `data-arrival`
-- worker after a SALIENT ingest. A backfill never reaches here: the seam
-- classifier drops historical and future-dated samples before anything is
-- enqueued, so a ten-year import writes nothing to this table.
--
-- The unique index is the point of the table, not decoration. It carries three
-- separate contracts at once:
-- 1. the "just in" marker every reaction surface reads,
-- 2. the at-most-one-generated-line per kind per day claim — the worker
-- inserts with ON CONFLICT DO NOTHING, so a lost race simply means
-- another worker already owns today's line,
-- 3. the audit trail of what the spine actually reacted to.
--
-- `line_encrypted` is AES-256-GCM at rest, following the
-- `insight_narratives.encrypted_content` precedent. It is NULLABLE on purpose:
-- the marker is the feature and the sentence is garnish, so a provider-less
-- install fills the table normally and simply never sets this column.
--
-- Rows are pruned after 14 days by the daily `arrival-reaction-cleanup` job.
CREATE TABLE "arrival_reactions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"local_date" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"ref_id" TEXT,
"line_encrypted" BYTEA,
"generated_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "arrival_reactions_pkey" PRIMARY KEY ("id")
);
-- The once-per-kind-per-local-day claim. Do NOT widen this to include
-- `ref_id`: the day scope IS the throttle.
CREATE UNIQUE INDEX "arrival_reactions_user_id_kind_local_date_key"
ON "arrival_reactions" ("user_id", "kind", "local_date");
-- The reaction surfaces read "today's rows for this user".
CREATE INDEX "arrival_reactions_user_id_local_date_idx"
ON "arrival_reactions" ("user_id", "local_date");
-- The nightly retention sweep deletes by age.
CREATE INDEX "arrival_reactions_created_at_idx"
ON "arrival_reactions" ("created_at");
ALTER TABLE "arrival_reactions"
ADD CONSTRAINT "arrival_reactions_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,52 @@
-- The per-workout Activity Insight paragraph.
--
-- Its own table rather than a column on `workouts` because a workout delete is
-- a HARD delete: the FK below cascades, so the paragraph can never outlive the
-- session it describes. Keeping it out of `workouts` also keeps the generation
-- provenance off the row every list read selects from.
--
-- Rows are written ONLY by the `workout-insight-generate` worker, dispatched by
-- the data-arrival spine when a workout genuinely lands. No read path creates
-- one, so every workout that predates this migration stays without a row — and
-- renders no card — forever, at zero provider cost.
--
-- `paragraph_encrypted` is AES-256-GCM at rest, following the
-- `insight_narratives.encrypted_content` precedent, and is registered in
-- `src/lib/crypto/encrypted-columns.ts` so key rotation covers it.
--
-- `input_hash` fingerprints the deterministic evidence the paragraph was built
-- from together with the prompt version: a device re-post of the same session
-- hashes identically and is refused before any provider is resolved.
CREATE TABLE "workout_insights" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"workout_id" TEXT NOT NULL,
"paragraph_encrypted" BYTEA NOT NULL,
"input_hash" TEXT NOT NULL,
"prompt_version" TEXT NOT NULL,
"provider_type" TEXT,
"locale" TEXT NOT NULL,
"generated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "workout_insights_pkey" PRIMARY KEY ("id")
);
-- One paragraph per workout. This is also the second half of the
-- double-post defence: the singleton queue key collapses a burst, and this
-- constraint makes the claim durable even if two workers do run.
CREATE UNIQUE INDEX "workout_insights_workout_id_key"
ON "workout_insights"("workout_id");
-- The daily-cap count: rows for one user inside a generated_at window.
CREATE INDEX "workout_insights_user_id_generated_at_idx"
ON "workout_insights"("user_id", "generated_at");
ALTER TABLE "workout_insights"
ADD CONSTRAINT "workout_insights_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "workout_insights"
ADD CONSTRAINT "workout_insights_workout_id_fkey"
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- Durable arrival freshness plus ownership and spend linkage for
-- arrival-reaction provider attempts.
ALTER TABLE "arrival_reactions"
ADD COLUMN "arrived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "generation_claim_id" TEXT,
ADD COLUMN "generation_claimed_at" TIMESTAMP(3),
ADD COLUMN "generation_reserved_tokens" INTEGER,
ADD COLUMN "generation_budget_date_key" TEXT,
ADD COLUMN "generation_provider_invoked_at" TIMESTAMP(3);
@@ -0,0 +1,6 @@
-- Preserve the original landing time for markers that predate `arrived_at`.
-- Migration 0260 must add the NOT NULL column with a default; this follow-up
-- restores historical rows to their actual first-arrival timestamp before the
-- application starts serving the release.
UPDATE "arrival_reactions"
SET "arrived_at" = "created_at";
@@ -0,0 +1,34 @@
-- Durable single-spend ownership and local-day capacity reservations for
-- per-workout Activity Insight generation. Existing insight rows remain valid
-- and are counted by the worker until a claim row exists for their workout.
CREATE TABLE "workout_insight_generation_claims" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"workout_id" TEXT NOT NULL,
"local_date" TEXT NOT NULL,
"claim_id" TEXT,
"claimed_at" TIMESTAMP(3),
"provider_invoked_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "workout_insight_generation_claims_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "workout_insight_generation_claims_workout_id_key"
ON "workout_insight_generation_claims"("workout_id");
CREATE UNIQUE INDEX "workout_insight_generation_claims_claim_id_key"
ON "workout_insight_generation_claims"("claim_id");
CREATE INDEX "workout_insight_generation_claims_user_id_local_date_idx"
ON "workout_insight_generation_claims"("user_id", "local_date");
ALTER TABLE "workout_insight_generation_claims"
ADD CONSTRAINT "workout_insight_generation_claims_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "workout_insight_generation_claims"
ADD CONSTRAINT "workout_insight_generation_claims_workout_id_fkey"
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,27 @@
-- Aggregate provenance makes the shared Apple Health `stats:*` identity
-- authority-aware. Existing rows cannot be classified safely from their stored
-- shape, so they remain repairable but are never promoted heuristically.
CREATE TYPE "measurement_aggregation_provenance" AS ENUM (
'HEALTHKIT_STATISTICS',
'EXPORT_XML_SOURCE_MAX',
'LEGACY_UNKNOWN'
);
ALTER TABLE "measurements"
ADD COLUMN "aggregation_provenance" "measurement_aggregation_provenance",
ADD COLUMN "aggregation_contributor_count" INTEGER,
ADD COLUMN "aggregation_selected_source_hash" TEXT;
UPDATE "measurements"
SET "aggregation_provenance" = 'LEGACY_UNKNOWN'
WHERE "source" = 'APPLE_HEALTH'
AND "external_id" LIKE 'stats:%';
-- Revision 1 denotes rows created under the pre-authority parser. New binaries
-- write revision 2 explicitly; the default stays legacy-safe during rollouts.
ALTER TABLE "import_jobs"
ADD COLUMN "parser_revision" INTEGER NOT NULL DEFAULT 1;
UPDATE "import_jobs"
SET "parser_revision" = 1;
@@ -0,0 +1,26 @@
-- WHOOP provider identities are globally owned by one local account.
-- Existing duplicate identities keep the earliest connection; ties are broken
-- by the primary key so every database chooses the same winner.
ALTER TABLE "whoop_connections"
ALTER COLUMN "whoop_user_id" DROP NOT NULL;
WITH ranked_connections AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "whoop_user_id"
ORDER BY "created_at" ASC, "id" ASC
) AS owner_rank
FROM "whoop_connections"
WHERE "whoop_user_id" IS NOT NULL
)
UPDATE "whoop_connections" AS connection
SET
"whoop_user_id" = NULL,
"updated_at" = CURRENT_TIMESTAMP
FROM ranked_connections AS ranked
WHERE connection."id" = ranked."id"
AND ranked.owner_rank > 1;
CREATE UNIQUE INDEX "whoop_connections_whoop_user_id_key"
ON "whoop_connections"("whoop_user_id");
@@ -0,0 +1,11 @@
ALTER TABLE "withings_connections"
ADD COLUMN "webhook_subscription_state" JSONB,
ADD COLUMN "webhook_subscription_retry_at" TIMESTAMP(3);
-- Existing connections have no durable outcome yet. Mark them due so the
-- hourly repair reconciles each category; status 294 is idempotent success.
UPDATE "withings_connections"
SET "webhook_subscription_retry_at" = CURRENT_TIMESTAMP;
CREATE INDEX "withings_connections_webhook_subscription_retry_at_idx"
ON "withings_connections"("webhook_subscription_retry_at");
@@ -0,0 +1,36 @@
-- Durable medication-intake imports keep the validated payload and monotonic
-- progress in Postgres so queue retries and process restarts cannot duplicate
-- intake or inventory side effects. This is additive and preserves all rows.
CREATE TABLE "medication_intake_import_jobs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"pg_boss_job_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"payload" JSONB NOT NULL,
"progress" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"failure_reason" TEXT,
"heartbeat_at" TIMESTAMP(3),
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_intake_import_jobs_pkey" PRIMARY KEY ("id"),
CONSTRAINT "medication_intake_import_jobs_status_check"
CHECK ("status" IN ('queued', 'running', 'done', 'failed')),
CONSTRAINT "medication_intake_import_jobs_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "medication_intake_import_jobs_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "medication_intake_import_jobs_pg_boss_job_id_key"
ON "medication_intake_import_jobs"("pg_boss_job_id");
CREATE INDEX "medication_intake_import_jobs_user_id_created_at_idx"
ON "medication_intake_import_jobs"("user_id", "created_at");
CREATE INDEX "medication_intake_import_jobs_medication_id_created_at_idx"
ON "medication_intake_import_jobs"("medication_id", "created_at");
CREATE INDEX "medication_intake_import_jobs_status_heartbeat_at_idx"
ON "medication_intake_import_jobs"("status", "heartbeat_at");
@@ -0,0 +1,40 @@
-- v1.30.34 — token-bound step-up elevations.
--
-- The Bearer-transport equivalent of `sessions.mfa_verified_at`. A row is
-- minted only after a password or primary-passkey re-proof, is bound to the
-- exact API token that re-proved, is single-use (`consumed_at`), and expires
-- within minutes. Only the HMAC of the opaque secret is stored.
CREATE TABLE "step_up_elevations" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"api_token_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"method" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"consumed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "step_up_elevations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "step_up_elevations_token_hash_key" ON "step_up_elevations"("token_hash");
-- At most ONE redeemable elevation per token, enforced by the database rather
-- than by the mint's delete-then-create ordering. Without it two concurrent
-- mints can interleave so that both rows survive, or the second mint's delete
-- strands the value the first mint already handed to the client.
CREATE UNIQUE INDEX "step_up_elevations_live_per_token_key"
ON "step_up_elevations"("api_token_id") WHERE "consumed_at" IS NULL;
CREATE INDEX "step_up_elevations_user_id_idx" ON "step_up_elevations"("user_id");
CREATE INDEX "step_up_elevations_api_token_id_idx" ON "step_up_elevations"("api_token_id");
CREATE INDEX "step_up_elevations_expires_at_idx" ON "step_up_elevations"("expires_at");
ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Cascade on the token binding: revoking or deleting a token must never leave a
-- redeemable elevation behind.
ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_api_token_id_fkey"
FOREIGN KEY ("api_token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- v1.32.11 — native web-handoff login (iOS #65).
--
-- The `oidc_native_handoffs` table now backs two flows: the original OIDC SSO
-- native leg and the first-party web-handoff login a self-hoster's iOS app runs
-- against its own domain. Both mint the same single-use, PKCE-locked, hashed-at-
-- rest handoff code through the shared core; the `flow` column records which
-- flow minted a row so the consume path can refuse a cross-flow redemption
-- (an `oidc` code presented at the web-login token route, or vice versa).
--
-- Additive and backfill-safe: every existing row is an OIDC handoff, so the
-- column defaults to 'oidc' and no data migration is needed.
ALTER TABLE "oidc_native_handoffs"
ADD COLUMN "flow" TEXT NOT NULL DEFAULT 'oidc';
@@ -0,0 +1,18 @@
-- v1.32.12 — mood rollup timezone re-key.
--
-- The mood rollup writer used to bucket on the UTC truncation of
-- `mood_logged_at`, so a mood logged late in the evening (or after
-- midnight) in a non-UTC timezone landed on the wrong calendar day.
-- The writer now keys every bucket on the canonical per-row
-- `mood_entries."date"` label instead, byte-identical to the live
-- fallback and to the day the user logged the mood in their own zone.
--
-- Data-only migration: no schema change. `MoodEntryRollup` is a derived
-- cache, fully reconstructible from `mood_entries` at any time. Emptying
-- the table drops every stale UTC-keyed row; the app then serves correct
-- data through the live `date`-keyed fallback until the rollups are
-- rebuilt, and the boot-time full-backfill worker
-- (`enqueueBootTimeMoodRollupBackfill` → delete-then-refold) re-mints
-- every mood-owning user's buckets under the new keying. No mood data is
-- touched.
DELETE FROM "mood_entry_rollups";
@@ -0,0 +1,4 @@
-- One-shot Fitbit sleep duplicate repair (stable per-segment sleep keys).
-- Null = repair pending; stamped once the boot-time bounded sleep re-read
-- completes for the connection.
ALTER TABLE "fitbit_connections" ADD COLUMN "sleep_repaired_at" TIMESTAMP(3);
+413 -37
View File
@@ -658,6 +658,10 @@ model User {
// skips the second factor (factor 2 only) within the window; the password
// is still required. Revoked when the second factor is removed.
trustedDevices TrustedDevice[]
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
// receives one single-use elevation good for one MFA-management call.
stepUpElevations StepUpElevation[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
@@ -667,6 +671,9 @@ model User {
// authorization for an MCP client, audience-bound to health:read/health:write.
mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
// browser-context OIDC callback to the cookie-less native token exchange.
oidcNativeHandoffs OidcNativeHandoff[]
auditLogs AuditLog[]
withingsConnection WithingsConnection?
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
@@ -734,6 +741,7 @@ model User {
// pg-boss job state so the polling endpoint can read live per-
// phase progress without round-tripping through pg-boss internals.
importJobs ImportJob[]
medicationIntakeImportJobs MedicationIntakeImportJob[]
// v1.5.0 — persistent measurement rollup table. Carries the
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
// bucketStart) so the analytics + comprehensive read paths bypass
@@ -789,6 +797,12 @@ model User {
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
insightNarratives InsightNarrative[]
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
// row per (kind, local day); the unique index IS the once-per-kind-per-day
// claim every reaction surface reads. Retained 14 days.
arrivalReactions ArrivalReaction[]
workoutInsights WorkoutInsight[]
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
// per user-created reminder ("BP every 7 days", "annual blood panel").
// Server computes the canonical `nextDueAt`; the cron fires through the
@@ -1020,6 +1034,16 @@ model Passkey {
model Session {
id String @id @default(cuid())
userId String @map("user_id")
// v1.30.32 — HMAC-SHA256 of the session cookie's secret. The cookie carries
// a 32-byte CSPRNG token (`hls_<hex>`), never this row's cuid: a cuid is
// collision-resistant, not unguessable (embedded timestamp + counter), and a
// session identifier is a bearer credential. Hash-at-rest mirrors the
// ApiToken / RefreshToken posture — a database read yields no usable cookie.
//
// Nullable for the transition only: rows minted before this column existed
// carry NULL and are still resolvable by id until they expire. See the
// resolution comment in `src/lib/auth/session.ts`.
tokenHash String? @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
ipAddress String? @map("ip_address")
@@ -1573,6 +1597,14 @@ enum SleepStage {
@@map("sleep_stage")
}
enum MeasurementAggregationProvenance {
HEALTHKIT_STATISTICS
EXPORT_XML_SOURCE_MAX
LEGACY_UNKNOWN
@@map("measurement_aggregation_provenance")
}
model Measurement {
id String @id @default(cuid())
userId String @map("user_id")
@@ -1606,6 +1638,18 @@ model Measurement {
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
externalSourceVersion String? @map("external_source_version")
// Authority of an aggregate stored under a `stats:*` identity. NULL for
// ordinary point/sample rows. Native HealthKit statistics outrank the
// source-day estimate derived from export.xml; migrated ambiguous rows are
// explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs
// them.
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
// Distinct source identities contributing to an export.xml source-day
// estimate. Source labels themselves are never persisted.
aggregationContributorCount Int? @map("aggregation_contributor_count")
// SHA-256 of the deterministically selected source identity. Non-identifying
// provenance for repeatability without retaining device/source labels.
aggregationSelectedSourceHash String? @map("aggregation_selected_source_hash")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
@@ -1922,6 +1966,8 @@ model Workout {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
samples WorkoutSamples?
insight WorkoutInsight?
insightGenerationClaim WorkoutInsightGenerationClaim?
/// Ingest dedup. NULL externalId is distinct so two manual
/// entries on the same day never collide here.
@@ -2012,6 +2058,75 @@ model WorkoutSamples {
@@map("workout_samples")
}
// ─── Per-workout Activity Insight (v1.31.0) ─────────────────
//
// The grounded paragraph the workout-detail seam renders. Its OWN model
// rather than a column on `Workout` for one concrete reason: workout deletes
// are HARD deletes (see the note on the `Workout` unique above), so the row
// has to disappear with its workout. A column would go with it too, but a
// separate model also keeps the generation provenance (`inputHash`,
// `promptVersion`, `providerType`) off the hot workout row that every list
// read selects from, and gives the daily-cap count an index of its own.
//
// Written ONLY by the `workout-insight-generate` worker, which the arrival
// spine dispatches when a workout genuinely LANDS. Nothing on a read path
// ever creates one: a workout with no row renders no card, and that includes
// every workout that predates this model.
model WorkoutInsight {
id String @id @default(cuid())
userId String @map("user_id")
workoutId String @unique @map("workout_id")
/// AES-256-GCM ciphertext of the generated paragraph. Follows the
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
/// the status cards use. Registered in `src/lib/crypto/encrypted-columns.ts`
/// and rotated by `scripts/rotate-encryption-key.ts`.
paragraphEncrypted Bytes @map("paragraph_encrypted")
/// Fingerprint of the deterministic evidence the paragraph was generated
/// from, plus the prompt version. A re-sync of the same session hashes
/// identically and costs nothing.
inputHash String @map("input_hash")
promptVersion String @map("prompt_version")
providerType String? @map("provider_type")
/// The UI locale the paragraph was WRITTEN in. Provenance only: a later
/// language switch does not regenerate, because a read path may never
/// trigger a provider call. Stored so that is visible rather than guessed.
locale String
generatedAt DateTime @map("generated_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
/// The per-day cap count reads this: rows for one user in a generatedAt
/// window.
@@index([userId, generatedAt])
@@map("workout_insights")
}
// Durable ownership and local-day capacity reservation for workout insight
// generation. The row is created before expensive evidence work. A live
// `claimId` owns the pre-provider lease; `providerInvokedAt` is the terminal
// boundary after which retries must never spend again.
model WorkoutInsightGenerationClaim {
id String @id @default(cuid())
userId String @map("user_id")
workoutId String @unique @map("workout_id")
localDate String @map("local_date")
claimId String? @unique @map("claim_id")
claimedAt DateTime? @map("claimed_at")
providerInvokedAt DateTime? @map("provider_invoked_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
@@index([userId, localDate])
@@map("workout_insight_generation_claims")
}
// ─── Strain personal anchor cache (v1.10.3) ─────────────────
//
// Server-internal per-(user, day) cache that lets the nightly Strain
@@ -2309,6 +2424,7 @@ model Medication {
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
intakeImportJobs MedicationIntakeImportJob[]
phaseConfig ReminderPhaseConfig?
telegramMessages TelegramReminderMessage[]
doseChanges MedicationDoseChange[]
@@ -2730,6 +2846,12 @@ model MedicationIntakeEvent {
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@index([userId, updatedAt, id])
// v1.30.12 perf — the dashboard "meds today" read fires sibling queries that
// filter `(userId, scheduledFor)` WITHOUT `medicationId` (the composite above
// cannot range-seek those) and a per-medication `_max(takenAt)` group-by that
// scanned the whole intake history. These two let both index-seek instead.
@@index([userId, scheduledFor])
@@index([userId, medicationId, takenAt])
@@map("medication_intake_events")
}
@@ -2852,6 +2974,18 @@ model MedicationInventoryItem {
/// The carton-printed expiry date the user enters at creation.
printedExpiry DateTime? @map("printed_expiry")
purchasedAt DateTime? @map("purchased_at")
/// Marketing-authorisation holder / maker printed on the carton. Free
/// text: the field is a label the user reads back, not a lookup key, and
/// there is no authority list a self-hoster could be held to. Nullable
/// because every pre-existing row predates it and plain supply rows
/// (blisters, bottles) have no use for it.
manufacturer String?
/// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text
/// and deliberately NOT parsed into a number + unit: pens state strength
/// per dose, per ml, or per cartridge depending on the product, and a
/// forced numeric split would lose which of the three it was. Nullable
/// for the same reason as `manufacturer`.
doseStrength String? @map("dose_strength")
/// Free-text note, application-layer max 200 chars.
notes String?
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
@@ -3084,12 +3218,71 @@ model ApiToken {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
stepUpElevations StepUpElevation[]
@@index([userId])
@@index([mcpConnectionId])
@@map("api_tokens")
}
// v1.30.34 — a step-up elevation: the Bearer-transport answer to
// `Session.mfaVerifiedAt`.
//
// The web gates second-factor management on a freshness stamp written onto a
// session row. A token client has no session row, so it had no way to express
// "the human holding this device just re-proved a factor" and the whole
// MFA-management surface was unreachable from a native client.
//
// A row here is that proof, and it is deliberately the weakest credential in
// the tree:
// - it is minted ONLY after a password or primary-passkey re-proof, so
// presenting a stolen token gains nothing;
// - `apiTokenId` binds it to the exact token that re-proved, so a second
// token — the same user's included — cannot redeem it;
// - `consumedAt` makes it single-use, claimed by a conditional UPDATE so two
// concurrent redemptions cannot both win;
// - `expiresAt` caps it at minutes, matching the cookie path's step-up window.
//
// Only the hash of the opaque `hle_` secret is stored, so a table dump yields
// nothing redeemable — the same posture as ApiToken, RefreshToken, and Session.
// Revoking the token strands every elevation bound to it by construction:
// redemption runs behind Bearer resolution, which refuses a revoked token.
model StepUpElevation {
id String @id @default(cuid())
userId String @map("user_id")
// The `ApiToken` row this elevation is bound to. Cascade so a deleted token
// cannot leave a redeemable elevation behind.
apiTokenId String @map("api_token_id")
// HMAC-SHA256 (`hashToken`) of the raw `hle_<64 hex>` secret. Unique so a
// redemption is a single indexed lookup and a collision cannot alias two rows.
tokenHash String @unique @map("token_hash")
// Which factor was re-proved: "password", "totp", "webauthn", or "passkey".
// This IS an authorisation input, not telemetry — see `FRESH_FACTOR_METHODS`
// in `@/lib/auth/step-up`. Only totp / webauthn / passkey satisfy a
// fresh-factor gate; password reaches what a plain cookie session reaches
// and stops there.
method String
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
apiToken ApiToken @relation(fields: [apiTokenId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([apiTokenId])
@@index([expiresAt])
// NOTE: migration 0267 additionally creates a PARTIAL unique index —
// `(api_token_id) WHERE consumed_at IS NULL` — so at most one elevation per
// token is ever redeemable. Prisma cannot express a partial index, and a
// plain `@@unique([apiTokenId])` would be wrong: it would also forbid minting
// a second elevation after the first was legitimately consumed. The
// constraint therefore lives only in the migration, and the mint below
// handles its unique violation explicitly.
@@map("step_up_elevations")
}
// MCP OAuth connection — the revocable, theft-detectable anchor for a remote
// connector's refresh-token chain (Phase 3 security fix, H2).
//
@@ -3214,6 +3407,49 @@ model RefreshToken {
@@map("refresh_tokens")
}
// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context
// OIDC callback to the cookie-less native token exchange. The raw value
// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback);
// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored,
// so a database dump reconstructs nothing. Single-use via the guarded consume
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
// the token pair the first exchange issued (refresh-token reuse-detection
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
// v1.32.11 — this table now backs BOTH native handoff flows: the original
// OIDC SSO leg (`flow = "oidc"`) and the first-party web-handoff login the iOS
// app uses on a self-hosted domain (`flow = "web_login"`, iOS #65). The mint +
// consume core is shared (`src/lib/auth/native-handoff.ts`); the `flow`
// discriminator is written at mint and CHECKED at exchange, so an oidc code can
// never be redeemed at the web-login token route and vice versa.
model OidcNativeHandoff {
id String @id @default(cuid())
userId String @map("user_id")
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
/// Which handoff flow minted this code — `oidc` (the SSO native leg) or
/// `web_login` (the first-party web-handoff, iOS #65). Defaults to `oidc` so
/// every pre-existing row keeps its meaning. The consume path filters on it,
/// making a cross-flow redemption structurally impossible (not merely
/// unobserved).
flow String @default("oidc") @map("flow")
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
/// s256(code_verifier) against it (constant-time) before consuming.
codeChallenge String @map("code_challenge")
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
/// hashToken of the refresh token issued at exchange — replay containment
/// revokes exactly this family member (+ its paired access token).
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
createdAt DateTime @default(now()) @map("created_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("oidc_native_handoffs")
}
// ─── Withings ──────────────────────────────────────────────
model WithingsConnection {
@@ -3239,11 +3475,17 @@ model WithingsConnection {
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
/// payload is versioned and validated before use; the timestamp mirrors the
/// earliest transient/pending category retry for indexed hourly discovery.
webhookSubscriptionState Json? @map("webhook_subscription_state")
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([webhookSubscriptionRetryAt])
@@map("withings_connections")
}
@@ -3293,7 +3535,7 @@ model WithingsOAuthState {
model WhoopConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
whoopUserId String @map("whoop_user_id")
whoopUserId String? @unique @map("whoop_user_id")
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
@@ -3413,6 +3655,10 @@ model FitbitConnection {
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// Set once the one-shot sleep repair re-reads the bounded sleep history and
/// collapses the duplicates the earlier volatile segment key left behind.
/// Null = repair pending.
sleepRepairedAt DateTime? @map("sleep_repaired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -4145,6 +4391,9 @@ model ImportJob {
/// kick-off endpoint short-circuit a re-upload of the exact same
/// file back to the existing job id without re-queueing.
uploadSha256 String? @map("upload_sha256")
/// Parser semantics revision. Existing archives migrated at revision 1 can
/// be deliberately reprocessed when the current parser revision advances.
parserRevision Int @default(1) @map("parser_revision")
/// Apple's `ExportDate` attribute string-formatted, parsed during
/// the unpack phase. NULL until the parser sees it.
exportedAt DateTime? @map("exported_at")
@@ -4171,6 +4420,33 @@ model ImportJob {
@@map("import_jobs")
}
// Durable medication-intake CSV imports. The validated, normalized payload
// stays in Postgres so retries never depend on request-process memory.
model MedicationIntakeImportJob {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
pgBossJobId String? @unique @map("pg_boss_job_id")
status String @default("queued")
payload Json
progress Json @default("{}")
result Json?
failureReason String? @map("failure_reason")
heartbeatAt DateTime? @map("heartbeat_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([medicationId, createdAt])
@@index([status, heartbeatAt])
@@map("medication_intake_import_jobs")
}
// ─── Rate Limiting ──────────────────────────────────────────
model RateLimit {
@@ -5186,7 +5462,8 @@ model InsightNarrative {
userId String @map("user_id")
/// "week" | "month".
period String
/// Resolved UI locale of the prose ("de" | "en").
/// Resolved UI locale of the prose — any shipped UI locale, not just de/en:
/// the prose is generated per locale, so the row is keyed by it.
locale String
/// UTC YYYY-MM-DD boundary the narrative was generated for.
dateKey String @map("date_key")
@@ -5207,6 +5484,62 @@ model InsightNarrative {
@@map("insight_narratives")
}
// ─── Data-arrival spine (v1.31.0) ─────────────────────────────
//
// One row per (user, arrival kind, local day). The row is written by the
// `data-arrival` worker after a SALIENT ingest — never by a backfill, which
// the seam classifier drops before anything is enqueued.
//
// The unique index is load-bearing twice over: it is the "just in" marker the
// reaction surfaces read and the durable identity whose newest same-day
// revision owns generation. A strictly newer arrival atomically invalidates
// stale generation state; equal or older deliveries are deduplicated.
model ArrivalReaction {
id String @id @default(cuid())
userId String @map("user_id")
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
kind String
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
localDate String @map("local_date")
/// Timestamp of the newest sample that triggered it.
occurredAt DateTime @map("occurred_at")
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
/// this advances when a later same-kind arrival refreshes the daily marker.
arrivedAt DateTime @default(now()) @map("arrived_at")
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
refId String? @map("ref_id")
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
/// unless — a line is generated: the marker is the feature, the sentence is
/// garnish, and a provider-less install never fills this in. Follows the
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
lineEncrypted Bytes? @map("line_encrypted")
generatedAt DateTime? @map("generated_at")
/// Opaque owner token and lease for pre-provider setup. A stale worker may
/// reclaim only while `generationProviderInvokedAt` is null.
generationClaimId String? @map("generation_claim_id")
generationClaimedAt DateTime? @map("generation_claimed_at")
/// The token reservation is written in the same transaction as the
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
generationReservedTokens Int? @map("generation_reserved_tokens")
generationBudgetDateKey String? @map("generation_budget_date_key")
/// Durable provider boundary. Once set, this marker is terminal unless the
/// encrypted line is committed; no retry may invoke a provider again.
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// One mutable marker per kind and local day. Do not include `refId`: newer
// same-day referents replace this row atomically.
@@unique([userId, kind, localDate])
// The reaction surfaces read "today's rows for this user".
@@index([userId, localDate])
// The nightly retention sweep deletes by age.
@@index([createdAt])
@@map("arrival_reactions")
}
// ─── Cycle Tracking (v1.15.0) ─────────────────────────────────
//
// Self-hosted reproductive-health tracking: period + flow, symptom
@@ -6039,6 +6372,31 @@ enum InboundDocumentStatus {
@@map("inbound_document_status")
}
/// Outcome of a document's plain-language summary. Recorded so the detail view
/// states what happened rather than claiming generation is in progress for a
/// job that was never enqueued.
///
/// WITHHELD deserves a note: the outbound safety screen tripped, so the model's
/// prose is discarded and NEVER shown. Only the fact of the refusal is kept, and
/// it is deliberately not terminal — the user can ask again, which is the whole
/// reason this is a state and not a stored refusal string.
enum DocumentSummaryState {
/// Never attempted — the opt-in was off at upload, or the document predates
/// the feature. The default, so every existing row is correct.
NONE
/// Enqueued or running.
PENDING
/// A summary was produced and is stored in `summaryEncrypted`.
READY
/// Produced, then blocked by the outbound safety screen. Text discarded.
WITHHELD
/// Attempted and could not produce one — no vision provider, spent budget,
/// withdrawn consent, an unreadable file, or a provider error.
UNAVAILABLE
@@map("document_summary_state")
}
/// The FHIR resource an extracted fact maps to (STATED status only): a
/// diagnosis → Condition, a measured value → Observation, a medication the
/// patient is/was taking → MedicationStatement.
@@ -6061,9 +6419,9 @@ enum ExtractedFactStatus {
}
model InboundDocument {
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
/// User-given label. Deliberately plaintext (like `filename`) so the library
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
@@ -6071,18 +6429,18 @@ model InboundDocument {
/// cleartext at rest / in backups — the same exposure the pre-existing
/// plaintext `filename` already carries. Accepted to keep server-side search
/// and sort.
title String?
title String?
/// Original filename as uploaded (sanitised, optional).
filename String?
filename String?
/// Magic-byte-sniffed MIME type the document was stored as.
mimeType String @map("mime_type")
mimeType String @map("mime_type")
/// Stored byte length of the original (pre-encryption) document.
byteSize Int @map("byte_size")
byteSize Int @map("byte_size")
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
/// shared with the note columns. The original is sensitive and never
/// stored in the clear or logged.
contentEncrypted Bytes @map("content_encrypted")
contentEncrypted Bytes @map("content_encrypted")
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
/// upload for same-user duplicate detection. Nullable: rows stored before
/// the vault release are not backfilled (only new uploads dedupe). A
@@ -6091,40 +6449,58 @@ model InboundDocument {
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
/// ON inbound_documents (user_id, content_sha256)
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
contentSha256 String? @map("content_sha256") @db.VarChar(64)
contentSha256 String? @map("content_sha256") @db.VarChar(64)
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
/// detour). Explicit column, never header-sniffed; the default keeps every
/// pre-existing row correct.
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
/// "local"). Diagnostic only. Null on a store-only document.
providerType String? @map("provider_type")
providerType String? @map("provider_type")
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
/// transcribed by the model — never inferred, never user-set.
reportDate DateTime? @map("report_date")
reportDate DateTime? @map("report_date")
/// Effective filing date the library sorts, groups, and range-filters on
/// (editable). Distinct from `reportDate`, which is the model-transcribed
/// value. On store it defaults to the upload day (now) when the user does
/// not supply one, so a stored row's filing date is never NULL and display
/// == sort == filter. It stays user-editable and MAY be cleared back to
/// NULL via PATCH (the list then sorts such rows last).
documentDate DateTime? @map("document_date")
documentDate DateTime? @map("document_date")
/// Failure reason when status = FAILED (a short code, never the document).
errorReason String? @map("error_reason")
errorReason String? @map("error_reason")
/// AES-256-GCM ciphertext of a short (3-4 sentence) plain-language summary
/// of WHAT the document is, generated ONCE in the background right after
/// upload when the user's `documentsAutoAiRead` opt-in is ON (that flag is
/// also the standing AI-egress consent). Absent when auto-read is OFF, no
/// provider is configured, or generation has not run yet. Descriptive only —
/// never a diagnosis. `encrypt()`-string-as-UTF-8 BYTEA, the shared Bytes
/// codec every other note column uses; PHI, never logged.
summaryEncrypted Bytes? @map("summary_encrypted")
/// When the background summary was generated (null until it runs). Surfaced
/// as meta beside the summary in the document detail view.
summaryGeneratedAt DateTime? @map("summary_generated_at")
/// What actually happened to this document's summary, so the detail view can
/// tell the four outcomes apart instead of reading a null column as "still
/// generating". `summaryEncrypted` alone cannot: a document nobody ever
/// enqueued, one whose provider is missing, one the safety screen withheld,
/// and one mid-flight all leave it null. NONE is the default, which keeps
/// every pre-existing row correct — none of them were ever attempted.
summaryState DocumentSummaryState @default(NONE) @map("summary_state")
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
/// array). Absent until the document is indexed; cascades on delete.
contentIndex DocumentContentIndex?
contentIndex DocumentContentIndex?
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
/// Absent until the background thumbnail job runs; a side table (not a
/// column) so the list/detail `omit: { contentEncrypted }` queries never
/// drag the blob into the SELECT. Cascades on delete.
thumbnail DocumentThumbnail?
thumbnail DocumentThumbnail?
/// v1.29.x (S7) — coach-conversation attachments (m:n via
/// `CoachConversationDocument`). Deleting this document cascades its join rows
/// away (its text leaves every conversation's context) but never the
@@ -6132,7 +6508,7 @@ model InboundDocument {
chatAttachments CoachConversationDocument[]
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
/// Cascades: deleting this document removes it from every share it was on.
shareLinks ClinicianShareLinkDocument[]
shareLinks ClinicianShareLinkDocument[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -6240,14 +6616,14 @@ model DocumentConditionLink {
/// versioned key (no schema column), and key rotation re-tokenises from the
/// decrypted `textEncrypted`.
model DocumentContentIndex {
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
/// AES-256-GCM ciphertext of the normalised extracted text (capped, first
/// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared
/// with the Coach columns. The plaintext is recoverable server-side so
/// rotation can re-tokenise; the list query never selects it.
textEncrypted Bytes @map("text_encrypted")
textEncrypted Bytes @map("text_encrypted")
/// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM
/// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or
/// de-accented), stored additionally alongside `textEncrypted` so a document
@@ -6256,22 +6632,22 @@ model DocumentContentIndex {
/// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before
/// P4 carry NULL until re-indexed, and the chat route falls back to the
/// normalised `textEncrypted` when it is absent. The list query never selects it.
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
/// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind
/// index. One-way: opaque without the server-held index subkey. GIN-indexed
/// (raw SQL in the migration; Prisma cannot express a GIN index).
searchTokens String[] @default([]) @map("search_tokens")
searchTokens String[] @default([]) @map("search_tokens")
/// Provenance of the indexed text: "vision" (provider transcription of the
/// stored original), "text-ocr" (browser-OCR text posted by the client),
/// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr"
/// (server-side OCR of a scanned document — deferred follow-up). Free String;
/// adding a source value needs no migration.
source String
source String
/// Provider type that produced the text (diagnostic only), or null on the
/// text-ocr path.
providerType String? @map("provider_type")
providerType String? @map("provider_type")
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
tokenizerVersion String @map("tokenizer_version")
tokenizerVersion String @map("tokenizer_version")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -6292,23 +6668,23 @@ model DocumentContentIndex {
/// source, so the preview is metadata-free by construction. Owner-scoped
/// serve route only; cascades on delete.
model DocumentThumbnail {
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
/// key-rotation walk covers it. A scanned medical preview is PHI — never
/// stored in the clear or logged. The list query never selects it.
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
/// size the tile without decoding the blob.
width Int
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
byteSize Int @map("byte_size")
byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
sourceMime String @map("source_mime")
sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+67 -15
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.29.4";
/* @sw-version-fallback */ "v1.32.31";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -151,6 +151,26 @@ self.addEventListener("fetch", (event) => {
// Only handle same-origin GET requests
if (request.method !== "GET" || url.origin !== self.location.origin) return;
// One-shot auth navigations must reach the server EXACTLY ONCE. The OIDC
// login + callback endpoints are top-level browser navigations (not
// `fetch`/XHR), so with navigation preload enabled (see `activate`) the
// browser has ALREADY dispatched the request by the time this handler runs.
// Returning early WITHOUT `event.respondWith()` — the general `/api/` path
// below does exactly that — makes the browser ALSO run its own default
// navigation fetch, so the request is sent twice. The OIDC authorization
// code is single-use: the first callback redeems it and sets the session,
// the second fails at the IdP and redirects to
// `/auth/login?error=oidc_failed` on an otherwise-successful sign-in.
// Consuming the preload response here (or fetching once when there is none)
// settles the event with a single network request. These responses are
// never cached.
if (url.pathname.startsWith("/api/auth/")) {
event.respondWith(
(async () => (await event.preloadResponse) || fetch(request))(),
);
return;
}
// API calls. Allowlisted safe GET reads use network-first so the installed
// PWA always renders fresh server data online (and falls back to the last
// cached copy only when the network fails); everything else (auth,
@@ -202,9 +222,12 @@ async function cacheFirst(request, cacheName) {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_STATIC_ENTRIES);
await bestEffortCacheWrite(
cacheName,
request,
response.clone(),
MAX_STATIC_ENTRIES,
);
}
return response;
}
@@ -225,7 +248,6 @@ async function cacheFirst(request, cacheName) {
* refusal discipline the idempotency cache uses.
*/
async function networkFirstApi(request) {
const cache = await caches.open(DATA_CACHE);
try {
const response = await fetch(request);
@@ -236,17 +258,21 @@ async function networkFirstApi(request) {
.text()
.catch(() => undefined);
if (isCacheableApiResponse(response, bodyText)) {
const toStore = new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
await cache.put(request, toStore);
trimCache(DATA_CACHE, MAX_DATA_ENTRIES);
await bestEffortCacheWrite(
DATA_CACHE,
request,
new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
MAX_DATA_ENTRIES,
);
}
return response;
} catch {
// Network failed (offline) — serve the last cached copy if we have one.
const cache = await caches.open(DATA_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
@@ -303,9 +329,14 @@ async function networkFirst(event, cacheName) {
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (response.ok && isCacheableNavigation(request, response)) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_PAGE_ENTRIES);
event.waitUntil(
bestEffortCacheWrite(
cacheName,
request,
response.clone(),
MAX_PAGE_ENTRIES,
),
);
}
return response;
} catch {
@@ -336,6 +367,27 @@ async function networkFirst(event, cacheName) {
}
}
/**
* Cache a successful network response without making CacheStorage
* availability part of the fetch contract. Quota, eviction, or trim failures
* are intentionally swallowed: caching may improve a later request, but it
* must never replace this request's already-successful network response.
*/
async function bestEffortCacheWrite(
cacheName,
request,
response,
maxEntries,
) {
try {
const cache = await caches.open(cacheName);
await cache.put(request, response);
await trimCache(cacheName, maxEntries);
} catch {
// CacheStorage is an optional optimization.
}
}
/**
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
* insertion order, so one read + a single pass over the excess prefix
+7 -1
View File
@@ -4,11 +4,17 @@ One-shot operator and build scripts. These are not part of the running applicati
## How to run them
From a source checkout:
```bash
pnpm dlx tsx scripts/<file>.ts
```
Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck.
The production image intentionally removes npm, pnpm, and Corepack. Only
container-safe scripts named by an operator runbook use its pinned
`healthlog-tsx` launcher; scripts that import the full application dependency
graph must run from a source checkout. `scripts/` is excluded from the project
typecheck.
## What lives here
+38
View File
@@ -7,6 +7,8 @@
* exit-code behaviour is best left to a smoke test in CI (deferred to
* v1.4.43).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { checkEnv, parseEnvFile } from "../check-env";
@@ -252,3 +254,39 @@ describe("checkEnv — optional groups (informational)", () => {
expect(apnsKeyId.required).toBe(false);
});
});
describe("environment inventory", () => {
it("lists every optional AI runtime variable in compose, both examples, and the manifest", () => {
const repositoryRoot = join(__dirname, "../..");
const variableNames = [
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"INSIGHTS_MAX_TOKENS",
];
const compose = readFileSync(
join(repositoryRoot, "docker-compose.yml"),
"utf8",
);
const exampleEnvironments = [
readFileSync(join(repositoryRoot, ".env.example"), "utf8"),
readFileSync(join(repositoryRoot, ".env.production.example"), "utf8"),
];
const manifest = JSON.parse(
readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"),
) as {
groups: Array<{ variables: Array<{ name: string }> }>;
};
const manifestNames = manifest.groups.flatMap((group) =>
group.variables.map((variable) => variable.name),
);
for (const variableName of variableNames) {
expect(compose).toMatch(new RegExp(`^\\s*${variableName}:`, "m"));
for (const exampleEnvironment of exampleEnvironments) {
expect(exampleEnvironment).toMatch(
new RegExp(`^# ${variableName}=`, "m"),
);
}
expect(manifestNames).toContain(variableName);
}
});
});
+143
View File
@@ -0,0 +1,143 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
import integrationConfig from "../../vitest.integration.config.mts";
import unitConfig from "../../vitest.config.mts";
function repositoryRoot(): string {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
return join(dirname(testPath), "../..");
}
function readRepoFile(path: string): string {
return readFileSync(join(repositoryRoot(), path), "utf8");
}
type WorkflowStep = {
if?: string;
uses?: string;
with?: Record<string, string | number>;
};
type WorkflowJob = {
env?: Record<string, string>;
steps?: WorkflowStep[];
};
type Workflow = {
jobs: Record<string, WorkflowJob>;
};
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("Playwright CI determinism", () => {
it("fails CI when a retry resolves a flaky test", async () => {
vi.stubEnv("CI", "true");
// Re-import after stubbing CI so config conditionals are evaluated as CI.
const { default: config } = await import("../../playwright.config");
expect(config.retries).toBe(2);
expect(config.failOnFlakyTests).toBe(true);
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
expect(config.webServer).toMatchObject({
command: expect.stringContaining(
`${JSON.stringify(process.execPath)} .next/standalone/server.js`,
),
env: expect.objectContaining({
NATIVE_CANVAS: "off",
}),
});
});
it("retains the Playwright report after every workflow outcome", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const upload = workflow.jobs.e2e?.steps?.find((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
);
expect(upload).toBeDefined();
expect(upload?.if).toBe("always()");
expect(upload?.with).toMatchObject({
name: "playwright-report",
path: "playwright-report/",
"retention-days": 7,
"if-no-files-found": "ignore",
});
});
it("uses production-valid secrets and plain-HTTP cookies", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const env = workflow.jobs.e2e?.env;
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
});
});
describe("local worktree isolation", () => {
it("keeps nested worktree suites out of the root unit run", () => {
expect(unitConfig.test?.exclude ?? []).toContain(".worktrees/**");
});
});
describe("integration environment isolation", () => {
it("registers a worker setup bridge and authoritative test secrets", () => {
const test = integrationConfig.test;
expect(test?.setupFiles ?? []).toContain(
"./tests/integration/environment-setup.ts",
);
expect(test?.env).toMatchObject({
TZ: "UTC",
ENCRYPTION_KEY:
"0000000000000000000000000000000000000000000000000000000000000000",
ENCRYPTION_KEYS: "",
ENCRYPTION_ACTIVE_KEY_ID: "",
API_TOKEN_HMAC_KEY:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SESSION_SECRET: "integration-test-session-secret-32-bytes",
});
expect(
existsSync(
join(repositoryRoot(), "tests/integration/environment-setup.ts"),
),
).toBe(true);
});
});
describe("container dependency installation", () => {
it("copies the prepare helper before the Git-free frozen install", () => {
const dockerfile = readRepoFile("Dockerfile");
expect(dockerfile).toContain(
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
"RUN pnpm install --frozen-lockfile --prod=false",
);
});
});
describe("production dependency advisory floors", () => {
it("pins vulnerable transitive ranges to compatible patched versions", () => {
const workspace = parse(readRepoFile("pnpm-workspace.yaml")) as {
overrides?: Record<string, string>;
};
expect(workspace.overrides).toMatchObject({
"dompurify@<=3.4.10": "^3.4.11",
"postcss@<8.5.10": "^8.5.10",
"@hono/node-server@<1.19.13": "^1.19.13",
"hono@<4.12.25": "^4.12.25",
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const path of temporaryDirectories.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
type GitMarker = "absent" | "directory" | "file";
type PrepareResult = {
invocationLog: string | null;
result: SpawnSyncReturns<string>;
};
function runPrepareFixture(
gitMarker: GitMarker,
lefthookStatus = 0,
): PrepareResult {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
const root = join(dirname(testPath), "../..");
const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-"));
temporaryDirectories.push(directory);
const packageJson = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
) as {
scripts: { prepare: string };
};
const prepareHelper = join(root, "scripts/prepare.mjs");
if (existsSync(prepareHelper)) {
mkdirSync(join(directory, "scripts"));
cpSync(prepareHelper, join(directory, "scripts/prepare.mjs"));
}
if (gitMarker === "directory") {
mkdirSync(join(directory, ".git"));
} else if (gitMarker === "file") {
writeFileSync(
join(directory, ".git"),
"gitdir: ../shared/.git/worktrees/fixture\n",
);
}
const binDirectory = join(directory, "bin");
const invocationLog = join(directory, "lefthook.log");
mkdirSync(binDirectory);
const lefthook = join(
binDirectory,
process.platform === "win32" ? "lefthook.cmd" : "lefthook",
);
writeFileSync(
lefthook,
process.platform === "win32"
? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n'
: '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n',
);
if (process.platform !== "win32") chmodSync(lefthook, 0o755);
const result = spawnSync(packageJson.scripts.prepare, {
cwd: directory,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
FIXTURE_LEFTHOOK_LOG: invocationLog,
FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus),
},
shell: true,
});
return {
result,
invocationLog: existsSync(invocationLog)
? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n")
: null,
};
}
describe("package prepare hook installation", () => {
it("enables command-shim execution on Windows only", () => {
const testPath = expect.getState().testPath;
if (!testPath)
throw new Error("Vitest did not expose the current test path");
const source = readFileSync(
join(dirname(testPath), "../prepare.mjs"),
"utf8",
);
expect(source).toContain('shell: process.platform === "win32"');
});
it("skips hook installation only when Git metadata is absent", () => {
const { invocationLog, result } = runPrepareFixture("absent");
expect(result.status, result.stderr).toBe(0);
expect(invocationLog).toBeNull();
});
it.each(["directory", "file"] as const)(
"installs hooks when .git is a %s",
(gitMarker) => {
const { invocationLog, result } = runPrepareFixture(gitMarker);
expect(result.status, result.stderr).toBe(0);
expect(invocationLog).toBe("install --force\n");
},
);
it.each(["directory", "file"] as const)(
"propagates Lefthook failure when .git is a %s",
(gitMarker) => {
const { invocationLog, result } = runPrepareFixture(gitMarker, 42);
expect(invocationLog).toBe("install --force\n");
expect(result.status).toBe(42);
},
);
});
+473
View File
@@ -0,0 +1,473 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
function readRepoFile(path: string): string {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
return readFileSync(join(dirname(testPath), "../..", path), "utf8");
}
type WorkflowStep = {
name?: string;
run?: string;
"continue-on-error"?: boolean;
};
type WorkflowJob = {
needs?: string | string[];
outputs?: Record<string, string>;
steps?: WorkflowStep[];
uses?: string;
with?: Record<string, string>;
};
type Workflow = {
on?: {
workflow_call?: { inputs?: Record<string, { required?: boolean }> };
};
jobs: Record<string, WorkflowJob>;
};
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const path of temporaryDirectories.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
function loadWorkflow(name: string): Workflow {
return parse(readRepoFile(join(".github/workflows", name))) as Workflow;
}
function stepScript(
workflow: Workflow,
jobName: string,
stepName: string,
): string {
const step = workflow.jobs[jobName]?.steps?.find(
(candidate) => candidate.name === stepName,
);
if (!step?.run) {
throw new Error(`Missing ${jobName}/${stepName} run step`);
}
return step.run;
}
function runReleasePolicy(
overrides: Record<string, string> = {},
): SpawnSyncReturns<string> {
const directory = mkdtempSync(join(tmpdir(), "healthlog-release-policy-"));
temporaryDirectories.push(directory);
const binDirectory = join(directory, "bin");
mkdirSync(binDirectory);
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ version: overrides.FIXTURE_PACKAGE_VERSION ?? "1.31.4" }),
);
const gitPath = join(binDirectory, "git");
writeFileSync(
gitPath,
`#!/bin/sh
if [ "$1" = "fetch" ]; then exit 0; fi
if [ "$1" = "merge-base" ] && [ "$FIXTURE_MAIN_ANCESTRY" = "yes" ]; then exit 0; fi
exit 1
`,
);
chmodSync(gitPath, 0o755);
const ghPath = join(binDirectory, "gh");
writeFileSync(
ghPath,
`#!/bin/sh
if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
`,
);
chmodSync(ghPath, 0o755);
const workflow = loadWorkflow("docker-publish.yml");
return spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "release-policy", "Validate release policy"),
],
{
cwd: directory,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
GITHUB_EVENT_NAME: "push",
GITHUB_REF_TYPE: "tag",
GITHUB_REF_NAME: "v1.31.4",
GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567",
GITHUB_REPOSITORY: "MBombeck/HealthLog",
GH_TOKEN: "fixture-token",
FIXTURE_MAIN_ANCESTRY: "yes",
FIXTURE_PRIOR_CI: "yes",
...overrides,
},
},
);
}
function runExactPull(resolvedDigest: string): {
result: SpawnSyncReturns<string>;
commands: string;
} {
const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-"));
temporaryDirectories.push(directory);
const dockerPath = join(directory, "docker");
const commandLog = join(directory, "commands.log");
writeFileSync(
dockerPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$FIXTURE_COMMAND_LOG"
if [ "$1" = "pull" ]; then exit 0; fi
if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then
printf '\"%s\"\\n' "$FIXTURE_RESOLVED_DIGEST"
exit 0
fi
exit 1
`,
);
chmodSync(dockerPath, 0o755);
const workflow = loadWorkflow("post-publish-verify.yml");
const result = spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "verify", "Pull exact image"),
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env.PATH ?? ""}`,
IMAGE_REF: "ghcr.io/mbombeck/healthlog",
IMAGE_TAG: "v1.31.4",
IMAGE_DIGEST:
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
FIXTURE_COMMAND_LOG: commandLog,
FIXTURE_RESOLVED_DIGEST: resolvedDigest,
},
},
);
return {
result,
commands: readFileSync(commandLog, "utf8"),
};
}
function runPromotion(
overrides: Record<string, string> = {},
): SpawnSyncReturns<string> {
const directory = mkdtempSync(join(tmpdir(), "healthlog-promotion-"));
temporaryDirectories.push(directory);
const sleepPath = join(directory, "sleep");
writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n");
chmodSync(sleepPath, 0o755);
const curlPath = join(directory, "curl");
writeFileSync(
curlPath,
`#!/bin/sh
printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_STATUS"
`,
);
chmodSync(curlPath, 0o755);
const workflow = loadWorkflow("docker-publish.yml");
return spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "promote", "Trigger Coolify deploy"),
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env.PATH ?? ""}`,
COOLIFY_AUTO_DEPLOY: "on",
COOLIFY_WEBHOOK: "https://coolify.example/deploy?uuid=fixture",
COOLIFY_TOKEN: "fixture-token",
IMAGE_TAG: "v1.31.4",
IMAGE_DIGEST:
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
BUILD_SHA: "0123456789ab",
FIXTURE_HTTP_STATUS: "200",
...overrides,
},
},
);
}
describe("release container inputs", () => {
it("pins every Node stage to one reviewed multi-arch digest", () => {
const dockerfile = readRepoFile("Dockerfile");
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
expect(fromLines).toHaveLength(3);
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
new Set([
"FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2",
]),
);
});
it("pins pnpm for builds and removes package managers from runtime", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
};
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
expect(dockerfile).not.toContain("pnpm@latest");
expect(
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
).toHaveLength(2);
expect(dockerfile).toContain(
"rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack",
);
expect(dockerfile).toContain("tsx@4.23.1");
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
);
expect(dockerfile).toContain("dotenv@17.4.2");
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv",
);
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma",
);
});
it("keeps linked worktrees out of the Docker build context", () => {
const dockerignore = readRepoFile(".dockerignore");
expect(dockerignore.split(/\r?\n/)).toContain(".worktrees");
});
it("bundles Prisma's adapter and verifies only runtime externals", () => {
const dockerfile = readRepoFile("Dockerfile");
const nextConfig = readRepoFile("next.config.ts");
expect(dockerfile).not.toContain("/opt/pg-boss");
expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"');
expect(dockerfile).toMatch(
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
);
expect(dockerfile).not.toContain("require.resolve('@prisma/adapter-pg')");
expect(dockerfile).not.toContain("require.resolve('@prisma/client')");
expect(nextConfig).not.toMatch(
/serverExternalPackages:\s*\[[\s\S]*["']@prisma\/adapter-pg["']/,
);
});
});
describe("release pre-build policy", () => {
it("accepts a strict SemVer tag that matches package.json on main with prior CI", () => {
const result = runReleasePolicy();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Release policy passed");
});
it.each(["v1.2", "v01.2.3", "v1.2.3-rc.1", "1.2.3"])(
"rejects non-strict release tag %s",
(tag) => {
const result = runReleasePolicy({ GITHUB_REF_NAME: tag });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("strict SemVer");
},
);
it("rejects a tag that does not match package.json", () => {
const result = runReleasePolicy({ FIXTURE_PACKAGE_VERSION: "1.31.3" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("package.json version");
});
it("rejects a release commit outside main ancestry", () => {
const result = runReleasePolicy({ FIXTURE_MAIN_ANCESTRY: "no" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("ancestor of origin/main");
});
it("rejects a release commit without successful prior quality CI", () => {
const result = runReleasePolicy({ FIXTURE_PRIOR_CI: "no" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("successful prior Security & Quality");
});
it("gates every image build on the release policy", () => {
const workflow = loadWorkflow("docker-publish.yml");
const needs = workflow.jobs.build?.needs;
expect(Array.isArray(needs) ? needs : [needs]).toContain("release-policy");
});
});
describe("exact post-publish verification", () => {
it("passes the published ref, tag, and digest into the blocking verifier", () => {
const workflow = loadWorkflow("docker-publish.yml");
const merge = workflow.jobs.merge;
const verify = workflow.jobs["verify-published-image"];
expect(merge?.outputs).toEqual({
image_ref: "${{ steps.published.outputs.image_ref }}",
image_tag: "${{ steps.published.outputs.image_tag }}",
image_digest: "${{ steps.published.outputs.image_digest }}",
build_sha: "${{ steps.published.outputs.build_sha }}",
});
expect(verify?.uses).toBe("./.github/workflows/post-publish-verify.yml");
expect(verify?.needs).toBe("merge");
expect(verify?.with).toEqual({
image_ref: "${{ needs.merge.outputs.image_ref }}",
image_tag: "${{ needs.merge.outputs.image_tag }}",
image_digest: "${{ needs.merge.outputs.image_digest }}",
build_sha: "${{ needs.merge.outputs.build_sha }}",
});
});
it("requires exact image identity inputs for every invocation", () => {
const workflow = loadWorkflow("post-publish-verify.yml");
const inputs = workflow.on?.workflow_call?.inputs;
expect(inputs?.image_ref?.required).toBe(true);
expect(inputs?.image_tag?.required).toBe(true);
expect(inputs?.image_digest?.required).toBe(true);
expect(inputs?.build_sha?.required).toBe(true);
});
it("pulls the exact tag and digest when the registry resolves correctly", () => {
const digest =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const { result, commands } = runExactPull(digest);
expect(result.status, result.stderr).toBe(0);
expect(commands).toContain("pull ghcr.io/mbombeck/healthlog:v1.31.4");
expect(commands).toContain(
"pull ghcr.io/mbombeck/healthlog@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
});
it("fails closed when the published tag resolves to another digest", () => {
const { result } = runExactPull(
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("digest mismatch");
});
it("keeps pull, migration, start, version, and health checks blocking", () => {
const workflow = loadWorkflow("post-publish-verify.yml");
const steps = workflow.jobs.verify?.steps ?? [];
const requiredSteps = [
"Pull exact image",
"Run migrations",
"Start exact image",
"Verify exact version",
"Verify health",
];
for (const name of requiredSteps) {
const step = steps.find((candidate) => candidate.name === name);
expect(step, `missing ${name}`).toBeDefined();
expect(step?.["continue-on-error"], name).not.toBe(true);
}
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
"${IMAGE_REF}@${IMAGE_DIGEST}",
);
const startScript = stepScript(workflow, "verify", "Start exact image");
expect(startScript).toContain("${IMAGE_REF}@${IMAGE_DIGEST}");
expect(startScript).toMatch(/API_TOKEN_HMAC_KEY=[0-9a-f]{64}(?:\s|\\)/);
const versionScript = stepScript(
workflow,
"verify",
"Verify exact version",
);
expect(versionScript).toContain('EXPECTED_VERSION="${IMAGE_TAG#v}"');
expect(versionScript).toContain('jq -e --arg expected "$EXPECTED_VERSION"');
expect(stepScript(workflow, "verify", "Verify health")).toContain(
"jq -e '.status == \"ok\"'",
);
});
});
describe("Coolify promotion policy", () => {
it("waits for exact-image verification and keeps enabled promotion blocking", () => {
const workflow = loadWorkflow("docker-publish.yml");
const promotion = workflow.jobs.promote;
const needs = promotion?.needs;
const trigger = promotion?.steps?.find(
(step) => step.name === "Trigger Coolify deploy",
);
expect(Array.isArray(needs) ? needs : [needs]).toEqual(
expect.arrayContaining(["merge", "verify-published-image"]),
);
expect(trigger).toBeDefined();
expect(trigger?.["continue-on-error"]).not.toBe(true);
expect(
workflow.jobs.merge?.steps?.some(
(step) => step.name === "Trigger Coolify deploy",
),
).toBe(false);
});
it("accepts an enabled promotion only after a successful webhook response", () => {
const result = runPromotion();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Coolify deploy queued");
});
it.each([
["webhook", { COOLIFY_WEBHOOK: "" }],
["token", { COOLIFY_TOKEN: "" }],
])("rejects enabled promotion with missing %s credentials", (_name, env) => {
const result = runPromotion(env);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("COOLIFY_AUTO_DEPLOY=on");
});
it("fails enabled promotion when Coolify rejects the webhook", () => {
const result = runPromotion({ FIXTURE_HTTP_STATUS: "503" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("returned HTTP 503");
});
it("allows explicit disabled promotion without credentials", () => {
const result = runPromotion({
COOLIFY_AUTO_DEPLOY: "off",
COOLIFY_WEBHOOK: "",
COOLIFY_TOKEN: "",
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("COOLIFY_AUTO_DEPLOY=off");
});
});
@@ -0,0 +1,138 @@
/**
* Remove the phantom Apple-Health mirror medications minted by the unstable
* externalId bug (a fresh key per sync sweep => one duplicate row per sweep).
*
* SAFETY:
* - Dry-run by default. Deletes ONLY when called with `--execute`.
* - Targets ONLY rows that match ALL of: the given name, externalSource =
* APPLE_HEALTH, ZERO intake events, and ZERO schedules. A row with any
* intake history or any schedule is never touched. Real medications
* (different name, or with intake/schedule history) can never match.
* - Each delete replicates the DELETE /api/medications/{id} route exactly:
* revoke medication-scoped tokens, deleteMedicationCategory, cascade
* delete, audit row. Caches are busted per affected user at the end (and
* otherwise clear on TTL / the next write).
*
* STOP THE SOURCE FIRST: while the iOS Apple-Health medication mirror is still
* enabled on the device, every sync re-mints these rows and cleanup will undo
* itself. Turn the toggle off (or ship the iOS fix) before running --execute.
*
* Run (inside the app container):
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts # dry-run
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --execute # delete
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --name "Blutdruckmittel" --execute
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { deleteMedicationCategory } from "@/lib/medication-category";
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const name = argValue("--name") ?? "Blutdruckmittel";
const execute = process.argv.includes("--execute");
// Candidate set: name + Apple-Health provenance only. Intake / schedule
// counts are checked per row below and are the hard safety gate.
const candidates = await prisma.medication.findMany({
where: { name, externalSource: "APPLE_HEALTH" },
select: { id: true, userId: true, externalId: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
const deletable: { id: string; userId: string }[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const m of candidates) {
const [intakes, schedules] = await Promise.all([
prisma.medicationIntakeEvent.count({
where: { medicationId: m.id, deletedAt: null },
}),
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
]);
if (intakes > 0 || schedules > 0) {
skipped.push({
id: m.id,
reason: `has ${intakes} intake(s) / ${schedules} schedule(s) — PRESERVED`,
});
continue;
}
deletable.push({ id: m.id, userId: m.userId });
}
console.log(
`\n=== cleanup plan for name "${name}" (externalSource APPLE_HEALTH) ===`,
);
console.log(`candidates found: ${candidates.length}`);
console.log(`safe to delete: ${deletable.length}`);
console.log(`preserved (intake/schedule present): ${skipped.length}`);
if (skipped.length > 0) {
console.log("\nPreserved rows:");
for (const s of skipped) console.log(` ${s.id} — ${s.reason}`);
}
console.log("\nRows to delete:");
for (const d of deletable) console.log(` ${d.id}`);
if (!execute) {
console.log(
"\nDRY RUN — nothing was deleted. Re-run with --execute to remove the rows above.",
);
return;
}
console.log(`\n--execute: deleting ${deletable.length} row(s)...`);
const affectedUsers = new Set<string>();
let deleted = 0;
for (const { id, userId } of deletable) {
// Mirror the DELETE route: revoke medication-scoped tokens (none expected
// on a phantom row, harmless if absent), category cleanup, cascade delete,
// audit row.
await prisma.apiToken.updateMany({
where: {
userId,
revoked: false,
permissions: { has: `medication:${id}:ingest` },
},
data: { revoked: true },
});
await deleteMedicationCategory(id);
await prisma.medication.delete({ where: { id } });
await auditLog("medication.delete", {
userId,
details: {
medicationId: id,
name,
reason: "apple_health_phantom_cleanup",
},
});
affectedUsers.add(userId);
deleted += 1;
}
for (const userId of affectedUsers) {
invalidateUserMedications(userId, { evict: true });
}
console.log(
`\nDone. Deleted ${deleted} row(s) across ${affectedUsers.size} user(s).`,
);
console.log(
"Caches were busted for the affected users; a page refresh reflects the change.",
);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
@@ -0,0 +1,104 @@
/**
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
* canonical SI, in place. Companion to
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
*
* SAFETY:
* - Dry-run by default. Writes ONLY when called with `--execute`.
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
* unit is unrecognised, is never touched.
* - Each row is rewritten to the canonical `{ value, unit }` the same
* resolver the CSV importer + MCP write path use produces, so the number is
* re-expressed, never re-labelled in place (which would corrupt it).
* - Affected users' measurement caches are busted at the end.
*
* This repairs the storage LABEL + value for the recognised-alias legacy rows
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
* but whose value was a foreign unit — that class is not mechanically
* identifiable (see the diagnose script).
*
* Run (inside the app container):
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
async function main(): Promise<void> {
const execute = process.argv.includes("--execute");
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const convertibleGroups = groups.filter((g) => {
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
});
if (convertibleGroups.length === 0) {
console.log(
"Nothing to convert — no recognised non-canonical units found.",
);
return;
}
const affectedUsers = new Set<string>();
let converted = 0;
for (const g of convertibleGroups) {
const canonical = getUnitForType(g.type);
const rows = await prisma.measurement.findMany({
where: { type: g.type, unit: g.unit },
select: { id: true, userId: true, value: true, unit: true },
});
for (const row of rows) {
const resolved = resolveToCanonicalUnit(
g.type,
row.value,
row.unit as string,
);
if (!resolved) continue; // defensive; group already filtered
converted += 1;
affectedUsers.add(row.userId);
console.log(
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
);
if (execute) {
await prisma.measurement.update({
where: { id: row.id },
data: { value: resolved.value, unit: canonical },
});
}
}
}
if (execute) {
for (const userId of affectedUsers) {
invalidateUserMeasurements(userId, { evict: true });
}
console.log(
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
"Rollups recompute on the next read/backfill.",
);
} else {
console.log(
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
"would be converted. Re-run with --execute to write.",
);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+121
View File
@@ -0,0 +1,121 @@
/**
* Read-only diagnostic for the phantom Apple-Health medication mirror.
*
* Prints the shape of every medication matching a name (default
* "Blutdruckmittel"): its provenance (externalSource / externalId), dose,
* asNeeded flag, createdAt, and its intake-event + schedule counts. Nothing is
* written. Use it to confirm the root cause before running any cleanup:
*
* - externalSource = APPLE_HEALTH with many DISTINCT externalId values
* (address-shaped or otherwise mutually different) => the iOS mirror is
* minting a fresh key per sync, so idempotency never matches.
* - externalSource NULL => the rows are not mirror rows (a different path).
* - createdAt spread => one row per sync sweep vs a single burst.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts --name "Blutdruckmittel"
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const name = argValue("--name") ?? "Blutdruckmittel";
const meds = await prisma.medication.findMany({
where: { name },
select: {
id: true,
userId: true,
name: true,
dose: true,
asNeeded: true,
externalSource: true,
externalId: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
if (meds.length === 0) {
console.log(`No medications found with name "${name}".`);
return;
}
const rows = [];
for (const m of meds) {
const [intakes, schedules] = await Promise.all([
prisma.medicationIntakeEvent.count({
where: { medicationId: m.id, deletedAt: null },
}),
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
]);
rows.push({
id: m.id,
user: m.userId.slice(0, 8),
source: m.externalSource ?? "NULL",
externalId: m.externalId ?? "NULL",
dose: m.dose,
asNeeded: m.asNeeded,
intakes,
schedules,
createdAt: m.createdAt.toISOString(),
});
}
console.log(`\n=== ${rows.length} medication(s) named "${name}" ===\n`);
console.table(rows);
const distinctExternalIds = new Set(
meds.map((m) => m.externalId).filter((v): v is string => v !== null),
);
const bySource = new Map<string, number>();
for (const m of meds) {
const key = m.externalSource ?? "NULL";
bySource.set(key, (bySource.get(key) ?? 0) + 1);
}
const created = meds.map((m) => m.createdAt.getTime());
const withIntake = rows.filter((r) => r.intakes > 0).length;
const withSchedule = rows.filter((r) => r.schedules > 0).length;
console.log("\n=== summary ===");
console.log(`total rows: ${rows.length}`);
console.log(
`externalSource breakdown: ${[...bySource.entries()]
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`,
);
console.log(`distinct externalId: ${distinctExternalIds.size}`);
console.log(`rows with >=1 intake: ${withIntake}`);
console.log(`rows with >=1 schedule: ${withSchedule}`);
console.log(
`createdAt range: ${new Date(Math.min(...created)).toISOString()} .. ${new Date(Math.max(...created)).toISOString()}`,
);
console.log(
"\nSample externalId values (first 5):\n" +
meds
.slice(0, 5)
.map((m) => ` ${m.externalId ?? "NULL"}`)
.join("\n"),
);
console.log(
"\nRead-only. No rows were modified. If the shape confirms the phantom set,\n" +
"run scripts/cleanup-apple-health-phantom-meds.ts (dry-run first).",
);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
@@ -0,0 +1,76 @@
/**
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
*
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
* one historical leak was the MCP `log_measurement` tool, which persisted a
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
* column differs from its type's canonical unit is such a legacy row, and it
* mislabels every display surface that trusts the column.
*
* This groups measurements by `(type, unit)` and prints every group whose
* `unit` is not the canonical unit for its type, flagging whether the stored
* unit is a RECOGNISED alias (mechanically convertible by the companion
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
*
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
* into the old kg-labelled entry form). Those are indistinguishable from real
* canonical readings; the reporter edits them via the record list.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
async function main(): Promise<void> {
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const mismatches = groups.filter((g) => {
if (g.unit === null) return false;
return g.unit !== getUnitForType(g.type);
});
if (mismatches.length === 0) {
console.log("No non-canonical measurement units found. Storage is clean.");
return;
}
let convertible = 0;
let unknown = 0;
console.log(
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
);
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
const canonical = getUnitForType(g.type);
// Probe convertibility with a neutral value — only the recognition matters.
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
if (recognised) convertible += g._count._all;
else unknown += g._count._all;
console.log(
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
`rows=${g._count._all} [${status}]`,
);
}
console.log(
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
);
console.log(
"Convert the recognised aliases with:\n" +
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+16 -1
View File
@@ -31,7 +31,7 @@
},
{
"name": "API_TOKEN_HMAC_KEY",
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars."
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens AND web session cookies. 64 hex chars. Rotating it signs every browser session out on top of invalidating every issued token."
}
]
},
@@ -117,6 +117,21 @@
}
]
},
{
"name": "AI Provider",
"description": "Optional runtime policy and output tuning for user-configured AI providers. Each variable stands alone and has a safe default when unset.",
"required": false,
"variables": [
{
"name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"purpose": "Local-AI SSRF opt-in. Unset rejects private/internal hosts; set an exact comma-separated host allowlist or \"true\" to allow any private host."
},
{
"name": "INSIGHTS_MAX_TOKENS",
"purpose": "Daily-briefing output-token ceiling. Default 2500, clamped to 500-8000."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
+17
View File
@@ -0,0 +1,17 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
if (!existsSync(".git")) {
console.log("Skipping Lefthook install: Git metadata is absent");
} else {
const result = spawnSync("lefthook", ["install", "--force"], {
stdio: "inherit",
shell: process.platform === "win32",
});
if (result.error) {
console.error(`Failed to run Lefthook: ${result.error.message}`);
process.exitCode = 1;
} else {
process.exitCode = result.status ?? 1;
}
}
+12 -19
View File
@@ -64,32 +64,25 @@
*
* SELF-CONTAINED by design (v1.16.0)
* ----------------------------------
* The production standalone image ships neither the project
* `node_modules` tree nor the `@/` path alias, so the previous
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
* module" inside the container. This script therefore depends on
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
* the environment (no dotenv). Inside the production image `pg` resolves
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
* `pg@8.x` there); in a repo checkout it resolves from the project
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
* standalone image strips it) and pins `pg` for any environment where
* neither resolution path exists.
* The production standalone image does not expose the project package
* manager. It provides a pinned `healthlog-tsx` maintenance launcher, while
* `pg` resolves from the standalone runtime tree. In a repo checkout, run the
* same script through `pnpm dlx --package pg --package tsx tsx ...`.
*
* Usage
* -----
* Usage inside the production container
* -------------------------------------
* # dry-run (DATABASE_URL must point at the target database)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
* healthlog-tsx scripts/repair-intake-anomalies.ts
*
* # scoped dry-run
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
* healthlog-tsx scripts/repair-intake-anomalies.ts --user <id>
*
* # apply
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*
* # create the proposed schedule revisions + startsOn fixes (defect 5)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
* healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
*/
import { Client, types } from "pg";
@@ -425,7 +418,7 @@ async function main(): Promise<void> {
if (!process.env.DATABASE_URL) {
console.error(
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
`the command: DATABASE_URL='postgresql://…' healthlog-tsx …)`,
);
process.exit(1);
}
+2 -3
View File
@@ -14,9 +14,8 @@
* BACKUP_S3_SECRET_KEY=... \
* BACKUP_S3_REGION=auto \
* BACKUP_ENCRYPTION_KEY=<hex64> \
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
*/
import "dotenv/config";
import { writeFileSync } from "node:fs";
import {
loadOffhostConfig,
@@ -82,7 +81,7 @@ async function main() {
?.slice("--user-id=".length);
if (!key) {
console.error(
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
"Usage: pnpm dlx tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
);
process.exit(1);
}
Loaded 100 of 1537 files, more files were not shown because too many files have changed in this diff. Show more