Compare commits

..
60 Commits
Author SHA1 Message Date
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
248 changed files with 16895 additions and 1910 deletions

No files matched your search

+402
View File
@@ -2,6 +2,408 @@
## [Unreleased]
## [1.32.23] — 2026-07-24
Sync status now tells the truth for the connected wearables and CGM sources. A
few paths could report a sync as healthy when it had actually failed, which
reset the failure streak, hid the reconnect prompt, and let a dead grant get
retried on every hourly tick. This release closes those gaps so a failed sync
reads as failed, an interrupted history import is retried instead of marked
complete, and a couple of sources start pulling data sooner.
- **A dead WHOOP token no longer reports a successful sync.** When the stored
grant is revoked, the connection stays parked with its reconnect prompt and
the last-synced time stays put, instead of flipping back to connected and
hammering the revoked token every hour. This holds on both the manual sync and
the hourly background path.
- **An interrupted WHOOP history import is retried, not marked done.** A
full-history backfill that stops partway is no longer stamped complete, so the
gap is retried rather than frozen in place. To pull deep history after
connecting, run a full sync from Settings. It walks back to 2020.
- **Withings ECG now syncs on its own schedule.** A watch-only account with no
scale previously never had its ECG recordings pulled. An hourly check now
brings them in without waiting on a webhook.
- **Withings activity and sleep write failures now surface.** A failed write no
longer reads as a clean sync; it shows up on the integration's status instead
of leaving a green state over missing data.
- **Oura, Polar, and Nightscout sync errors that previously vanished now
surface.** A write that fails partway through is recorded on the integration
status rather than being silently dropped, and each error is classified and
redacted by the source that owns it.
- **Strava history import starts at connect time.** Deep history begins
importing right after you connect instead of waiting for the next server
restart.
- **WHOOP cycle data uses a wider catch-up window.** A day's strain and energy
figures that are still being revised are re-fetched for a week, matching the
other WHOOP collections, so a late revision is not missed.
## [1.32.22] — 2026-07-24
The concurrent-edit protection that reached the layout surfaces now covers the
rest of the preference records too. Notification preferences, module toggles,
coach preferences, and the mood-tag layout all get the same guard, so a save
based on an older read stops and reloads instead of quietly reverting a change
another writer just made. Two more races are closed on the server side: a stock
correction can no longer overwrite a dose consumption that ran at the same
moment, and a privacy-mode change is now respected by an insight generation that
was already running. Older clients that do not send the new marker keep working
exactly as before.
- **Notification preferences no longer clobber a concurrent change.** The
server merges each category into one stored record. A save based on an older
read now reloads instead of writing the whole record back without the change
another device just made. This is what closes the case where a web save could
silently switch off the app-managed medication reminder flag and bring back a
duplicate reminder.
- **Module toggles, coach preferences, and the mood-tag layout get the same
guard.** Each is one stored record that more than one surface can write; a
stale save is now stopped and reloaded rather than reverting a newer one. The
coach-preferences save covers both the tuning options and the excluded-metric
list together, so a conflict leaves both untouched.
- **A stock correction can no longer race a dose consumption.** Correcting the
remaining count on a container now runs under the same per-medication lock a
logged dose takes, so the two serialize instead of one overwriting the other.
- **A privacy-mode change is respected by an in-flight insight generation.** A
briefing that was already generating when you switch the privacy mode no
longer writes its old-scope output over the cache the switch cleared. It stops
short of the write and the next run regenerates under the new mode.
- **Older clients are unaffected.** A request that does not carry the new base
marker keeps the previous save behavior, so nothing needs updating to keep
working. The new contract is documented in the published API spec.
## [1.32.21] — 2026-07-24
Insights and medications layout saves, and the coach about-me note, are now
protected against a concurrent edit overwriting a newer one. This is the same
protection the dashboard layout already had. If you save a layout in one place
while something else saved a change to the same record a moment earlier, your
save now stops and reloads the current state instead of quietly reverting that
other change. Older clients that do not send the new marker keep working exactly
as before.
- **Insights layout saves no longer clobber a concurrent edit.** Saving the
overview arrangement and saving the pill order are two separate surfaces that
write the same record. If one lands while the other was based on an older
read, the later save now reloads and asks you to redo it rather than reverting
the first.
- **Medications layout saves get the same guard.** The card/table view toggle
and the manual order both write one record, so a stale toggle can no longer
resurrect an order you just saved, and the reverse holds too.
- **The coach about-me note is guarded as well.** A save based on an older read
is stopped and reloaded instead of overwriting a newer version of the note.
- **Older clients are unaffected.** A request that does not carry the new base
marker keeps the previous save behavior unchanged, so nothing needs updating
to keep working.
- **The dashboard layout contract is now in the published API spec.** It was
missing from the OpenAPI file even though it was the first surface to get this
protection.
## [1.32.20] — 2026-07-24
The medication-adherence insight now passes through the same safety screen as
every other insight card. It was the one card of eight that showed the model's
text without running that screen first. It already held back a reply that came
back broken or cut off, but a dose-change instruction or an invented figure
could still reach it. It now runs the same check the other seven cards run, and
anything that trips the check falls back to the plain, data-grounded line the
card shows when no model is set up.
- **The medication-adherence card is screened like the rest.** A reply that
reads as a dose change, or that cites a made-up risk figure, is held back and
the card shows its plain fallback. A normal grounded summary still shows as
before.
- **Two background notification lines can no longer print a raw model reply.**
The "just in" reaction line and the proactive coach nudge now catch a reply
that arrived as structured data or got cut off partway, and use their fixed
wording instead of printing it as is.
- **Smaller cleanups in the same area.** A stray marker left behind by a
cut-off coach reply is now stripped from the text, and an unreachable branch
in the adherence figure was removed.
## [1.32.19] — 2026-07-24
After you logged a dose, the Today view could keep showing it as still due
until you reloaded the page. The Today hero and the dashboard read from a cache
that refreshes on a timer, and a write made from another screen marked that
cache stale without actually re-reading it. The change reached the server, but
the screen you came back to still showed the old state for up to two minutes,
or until a hard refresh.
- **A dose you take now clears from Today at once.** This covers the "Take all
due" button on the medications page, editing or deleting a logged dose, and
importing intake history. The Today view and the dashboard now re-read the
moment the write lands instead of waiting for the timer.
- **A preventive-care reminder marked done leaves the Today rail immediately.**
Marking one off, editing it, or deleting it updates the Today view straight
away rather than after a refresh.
- **A lab reading you add updates its cards right away.** Adding a reading
refreshes that marker's assessment on the same page and the "what changed
since your last panel" card, instead of leaving them on their earlier text.
- **A briefing you regenerate shows up on Today without a reload.** The new
briefing now reaches the Today hero and the dashboard as soon as it is ready.
- **Saving a dashboard layout keeps its safeguard against a conflicting edit.**
A visit to the home page could leave the layout editor without the token that
guards a save, which quietly turned off the protection added in v1.32.16
against two edits overwriting each other. The editor now always loads its own
copy, so the guard stays on.
Thanks to @lutzkind, whose run of reports surfaced the stale Today view.
Refs #581
## [1.32.18] — 2026-07-24
When a Fitbit token was revoked or expired, the hourly sync noticed the failure
but then stamped the same cycle as a success. The connection flipped back to
connected, the sync position moved forward, and after you reconnected the
catch-up window could no longer reach the days that were missed. So the data
was skipped for good, and in the meantime the failing token was retried every
hour.
- **A revoked Fitbit token no longer reports a successful sync.** A failed token
refresh now fails the whole cycle. The connection stays parked until you
reconnect, the sync position holds instead of advancing past real data, and
the once-per-failure alert stops repeating every hour.
- **Data after a reconnect is no longer skipped.** Because the position is held
back on a failed cycle, the next successful sync still covers the days that
were missed rather than starting after them.
- **A write failure during a sync holds its place too.** If rows were fetched
but could not be saved, the cycle keeps its position so the next run fetches
them again instead of letting them fall outside the catch-up window.
- **An interrupted history backfill no longer marks itself complete.** A partial
first-time import is retried until a full pass finishes, so the one-year
history walk fills in rather than freezing halfway.
If your Fitbit history already has a gap from before this release: reconnect
Fitbit if prompted, then open Settings, Integrations, Fitbit and run a full
sync. It re-walks the last year and fills the gap in place. Gaps older than a
year are outside the backfill window and cannot be recovered.
## [1.32.17] — 2026-07-24
The mood correlations on the Insights page could line up a day off. Your mood
is dated by the calendar day you logged it in your own timezone, but the
metrics it was matched against were still grouped by the UTC day. If you live
away from UTC, an evening reading slid onto the next UTC day, so a mood and a
reading from the same evening were paired as if a day apart. A regression from
v1.32.12, when the mood side moved to local days on its own.
- **Mood now pairs with metrics from the same local day.** The blood pressure,
weight, and resting pulse series that mood is correlated against are grouped
by the day you logged them in your own timezone, so both sides of the pairing
share one calendar. An evening entry no longer drifts onto the next day.
- **Non-UTC users get the correct same-day figures.** For anyone west of UTC an
evening reading used to pair with the wrong day's mood, turning a same-day
relationship into a one-day-apart one. Those correlations and their scatter
charts now reflect the day the data was actually recorded. UTC users see no
change.
## [1.32.16] — 2026-07-24
Changing which tiles and charts appear on your dashboard could get undone if
you also toggled a hero score ring at about the same moment. Both actions save
the whole layout, and the ring change was built from an older copy, so
whichever reached the server last won. Thanks to @lutzkind for the clear
report.
- **A saved layout now stays saved.** Your dashboard save is treated as the
authoritative one. If another change arrives after it but was based on an
older version of the layout, the server declines that write rather than
letting it overwrite your work. The ring toggle also carries only its own
fields, so it can no longer pull stale tiles along with it.
- **The two saves take turns instead of racing.** While a layout save is in
flight the score ring controls are held, and while a ring change is saving
the Save button waits, so the two writes can no longer overlap on the server.
- **A change underneath you reloads instead of getting lost.** In the rare case
where another tab saved first, the layout refetches from the server and a
small note asks you to make your change again against the fresh copy.
Refs #581.
## [1.32.15] — 2026-07-24
Your Polar training sessions now sync into HealthLog, alongside the recovery,
sleep, and daily activity that Polar already brought in. Thanks to
@StefanIndustries, whose report asked for this after the vitals sync landed.
- **Polar exercises arrive as workouts.** Every run, ride, swim, or gym session
you upload to Polar Flow shows up in your workout history with its sport,
start time, duration, distance, calories, and heart rate. The raw Polar sport
label and training load ride along in the background so nothing is lost. The
sync reads your last 30 days of uploads once an hour, so a session synced
late from your watch is still caught.
- **A Polar session does not double up with your other sources.** If the same
workout also reaches HealthLog through Apple Health or another connection, the
two are matched and only one is shown. A Polar watch counts as a direct
heart-rate recording, so it ranks above a Strava re-upload of the same run but
below your primary on-wrist wearables. You can change that order under
Settings, Sources.
- **The two Polar halves stay independent.** A hiccup fetching your training
sessions no longer holds back the recovery and sleep sync, and the reverse
holds too, so a problem on one side never masks the other.
## [1.32.14] — 2026-07-24
When the Coach removes a number it could not check against your data, the reply
now reads clearly and tells you what happened.
- **A withheld figure reads as a quiet omission.** The Coach checks every number
in its answer against the figures it was actually shown, and holds back any it
cannot verify so a drifted value never reaches you as if it were real. That
held-back number used to show up as an internal `[unverified]` marker in the
middle of the sentence. It now appears as a short editorial mark, and a small
muted line under the message tells you that some figures could not be checked
and were left out. The line is worded for each of the six languages and stays
out of the way until there is something to say.
- **Coach action cards and the tool trace survive a reload.** A suggested
reminder, a confirm-to-create action card, and the "what I looked at" tool
trace were saved with the message but dropped when the conversation was
reopened, so they quietly vanished on reload. They are now restored with the
rest of the message, so reopening a thread shows the same cards and trace it
showed the first time.
## [1.32.13] — 2026-07-24
An Insights assessment card no longer shows the raw model output when a response
comes back incomplete. If the underlying text was cut off, the card now falls
back to its computed summary instead of printing the unfinished data.
- **A cut-off assessment stays hidden.** The per-metric assessment cards, like
the one for resting pulse, ask the model for a small structured reply and then
read the summary out of it. When that reply arrived truncated, the reader
could see the whole envelope on the card, including the property name, the
braces, the quotes, and the escaped line breaks. The card now recognises an
incomplete or malformed reply and serves the deterministic summary it already
falls back to when no model text is available, so the raw output never reaches
you.
- **Plain-text assessments are untouched.** A model that answers in an ordinary
sentence rather than the structured shape is still shown as written, so this
change only affects broken replies and leaves normal ones exactly as before.
- **The same guard covers the other cards.** Medication compliance and the
derived-score assessments read their summary the same way, so they now hold
back a broken reply too rather than persisting it as the day's text.
Thanks to @lutzkind for reporting this one.
## [1.32.12] — 2026-07-24
A mood you log late in the evening now lands on the day you logged it, in your
own timezone, both on the dashboard and in the Coach's mood view.
- **Your late-night mood stays on the right day.** The pre-computed mood history
used to file each entry by its UTC date, so a mood logged after dark in a
timezone that runs ahead of or behind UTC could jump to the neighbouring day
once the nightly rebuild ran. Every mood now files under the calendar day it
was logged in your own timezone, matching the raw entry list.
- **The dashboard and the Coach agree.** The daily mood series behind the
dashboard tile and the Coach's mood view now read the same day for an entry as
the entry list does, so a value no longer shifts by a day when the faster
pre-computed path takes over from the live one.
- **The correction applies itself.** On upgrade the pre-computed mood history is
cleared and rebuilt under the corrected keying. While it rebuilds, mood reads
fall back to the live calculation, which was already correct, so there is no
window where the wrong day is shown.
## [1.32.11] — 2026-07-24
Signing in to the iOS app on a self-hosted domain now runs through your own web
login, so saved passwords and passkeys behave the way they should.
- **Sign in through your own domain.** On a self-hosted instance the app now
opens your instance's real web login page inside a secure in-app browser.
Because the login happens on your own origin, your password manager files the
credentials under the right site and passkeys can run at all, which the older
native form could not do on a custom domain.
- **A single-use handoff, not a token in a link.** Once you have logged in, the
server hands the app a one-time code that lives for about ninety seconds and
can be redeemed only once. The access and refresh tokens never ride a URL. The
whole exchange mirrors the existing native single sign-on handoff and reuses
its protections, including the per-request PKCE binding.
- **The login has to happen inside the flow.** The app receives a code only when
you actually authenticate during that browser session. A login left open in
the background from before cannot complete the handoff by itself, so a stray
link can never quietly sign the app in as you.
- **Your second factor still applies.** An account with an authenticator app or
a security key completes that step on the web page before any code is issued,
exactly as it does everywhere else.
## [1.32.9] — 2026-07-23
The Coach output guard now remembers the numbers you were actually shown across
a whole conversation, so figures you legitimately carry forward stop getting
marked unverified while an invented one still gets caught.
- **A figure from earlier in the chat is no longer flagged when you bring it
back up.** The guard now keeps a running record of every number the Coach was
given this turn and in earlier turns, plus your goal figures, your medication
doses, and the reference bands it was shown. A value it can trace to one of
those is left alone even a few turns later. A value the Coach only wrote in an
earlier reply is never trusted on that basis, so a number that once slipped
through cannot quietly become authoritative later.
- **An educational sentence keeps its numbers.** A line that states a general
guideline, like the note that adults usually need seven to nine hours of
sleep, is recognised as a reference and left intact instead of having its
numbers stripped. This works in every supported language.
- **A dose you are actually on reads as a restatement, a different one does
not.** When the Coach says to keep taking a dose, the reminder is allowed only
when the amount matches a dose on your schedule. A wrong maintenance amount, or
any wording that nudges the dose up or down, is still held back.
- **Fabricated risk numbers are caught in more languages.** The screen that
blocks an invented clinical risk figure or a named risk-engine result now
reads French, Spanish, Italian, and Polish to the same depth it already read
English and German, including a percentage written out in words.
## [1.32.8] — 2026-07-23
Health records written through the app now carry where they came from, and a
HealthKit sync can say what set it off.
- **A dose logged from the app is recorded as an app entry.** Every intake used
to be stored as a web entry no matter how it arrived. The server now reads the
transport it authenticated the call with and tags the dose to match, so a dose
logged in the native app reads as an app entry while one logged in the browser
stays a web entry. A reminder confirmation, an import, and an Apple Health
mirror keep their own labels. The value is derived on the server and can never
be set from the request body.
- **The dose history shows how each dose was recorded.** The per-dose history
returns a source label on every intake, so a client can show at a glance
whether a dose came from the web, the app, a reminder, an import, or an Apple
Health mirror. Rows written before the label existed report as unknown rather
than guessing.
- **A HealthKit sync can report what woke it.** The batch upload accepts an
optional trigger tag reading foreground, background, or push. It is recorded
for diagnostics only and never changes which readings are stored or how they
are deduplicated, so sending it or leaving it off produces the same result.
## [1.32.7] — 2026-07-23
The Coach output guard now reads numbers the way you actually write them, so a
correct figure stops getting marked unverified while an invented one still gets
caught.
- **A number you reformatted is no longer flagged.** The guard recognises a
rounded restatement, a delta narrated as a drop, an adherence rate written as
a percent, minutes read back as hours, a reformatted date, and a
thousands-separated step count, each checked against the figure the server
actually gave the Coach. A drifted number, like an average of 128 quoted back
as 138, is still caught.
- **A blood-pressure claim can no longer borrow a weight number.** Every figure
is matched inside its own unit family, and a raw reading never earns the
looser rounding reserved for a headline average, so a fabricated average
cannot slip through on a nearby sample.
- **A correct clinical-risk refusal streams through untouched.** Naming ASCVD or
a ten-year risk to explain that a clinician computes it now passes. What still
blocks is an asserted figure or a categorical verdict, whether it carries
digits ("your risk is about 14%"), spelled-out words ("roughly twelve
percent"), or no number at all ("SCORE2 would put you in the high-risk band").
- **A dose you already take reads as support.** "Keep taking your 7.5 mg as
prescribed" passes, while anything that moves the dose stays blocked.
- **A figure removed as unverifiable leaves the sentence intact.** The strip
touches only the exact token, so a year like 2023 is never clipped by a
flagged 23.
Refs #587, #591.
## [1.32.6] — 2026-07-23
Hardening and data-integrity fixes that were reviewed earlier and are now folded
+1429 -522
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Speichern",
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
"cancel": "Abbrechen",
"delete": "Löschen",
"undo": "Rückgängig",
@@ -360,6 +361,9 @@
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
},
"native": {
"signingInToApp": "Anmeldung bei der HealthLog-App"
},
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
@@ -451,6 +455,7 @@
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
"layoutUsingDefaults": "Standard-Layout aktiv",
"layoutCustomized": "Eigenes Layout aktiv",
@@ -2347,6 +2352,7 @@
"send": "Senden",
"stop": "Stopp",
"thinking": "Denke nach…",
"unverifiedFiguresNotice": "Einige Zahlen ließen sich nicht mit deinen Daten abgleichen und wurden weggelassen.",
"composerPlaceholder": "Frag mich etwas…",
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
"fabLabel": "Coach öffnen",
@@ -5541,7 +5547,7 @@
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
"polar": "Polar",
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivität zu synchronisieren.",
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen, tägliche Aktivität und Trainingseinheiten zu synchronisieren.",
"polarViewData": "Schlaf & Erholung ansehen",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"polarCredentials": "API-Zugangsdaten",
+7 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Save",
"conflictReloaded": "Reloaded from the server. Please make your change again.",
"cancel": "Cancel",
"delete": "Delete",
"undo": "Undo",
@@ -350,6 +351,9 @@
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
"errorFailed": "Single sign-on failed. Please try again."
},
"native": {
"signingInToApp": "Signing in to the HealthLog app"
},
"mfa": {
"title": "Two-factor authentication",
"totpHint": "Enter the 6-digit code from your authenticator app.",
@@ -441,6 +445,7 @@
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
"layoutResetSuccess": "Reset to default layout",
"layoutUsingDefaults": "Using default layout",
"layoutCustomized": "Custom layout active",
@@ -2337,6 +2342,7 @@
"send": "Send",
"stop": "Stop",
"thinking": "Thinking…",
"unverifiedFiguresNotice": "Some figures couldn't be checked against your data and were left out.",
"composerPlaceholder": "Ask me anything…",
"nudgeBubbleLabel": "New message from the Coach — open chat",
"fabLabel": "Open the Coach",
@@ -5531,7 +5537,7 @@
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.",
"polar": "Polar",
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, daily activity, and training sessions.",
"polarViewData": "View your sleep & recovery",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
"polarCredentials": "API Credentials",
+7 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Guardar",
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
"cancel": "Cancelar",
"delete": "Eliminar",
"undo": "Deshacer",
@@ -362,6 +363,9 @@
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
},
"native": {
"signingInToApp": "Iniciando sesión en la app de HealthLog"
},
"mfa": {
"title": "Autenticación de dos factores",
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
@@ -453,6 +457,7 @@
"layoutReset": "Restablecer valores por defecto",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
"layoutResetSuccess": "Restablecido al diseño por defecto",
"layoutUsingDefaults": "Diseño por defecto activo",
"layoutCustomized": "Diseño personalizado activo",
@@ -2349,6 +2354,7 @@
"send": "Enviar",
"stop": "Detener",
"thinking": "Pensando…",
"unverifiedFiguresNotice": "Algunas cifras no se pudieron contrastar con tus datos y se omitieron.",
"composerPlaceholder": "Pregúntame algo …",
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
"fabLabel": "Abrir el coach",
@@ -5543,7 +5549,7 @@
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
"polar": "Polar",
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y actividad diaria.",
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño, actividad diaria y sesiones de entrenamiento.",
"polarViewData": "Ver tu sueño y recuperación",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"polarCredentials": "Credenciales de API",
+7 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Enregistrer",
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
"cancel": "Annuler",
"delete": "Supprimer",
"undo": "Annuler",
@@ -364,6 +365,9 @@
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
},
"native": {
"signingInToApp": "Connexion à l'application HealthLog"
},
"mfa": {
"title": "Authentification à deux facteurs",
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
@@ -455,6 +459,7 @@
"layoutReset": "Rétablir les valeurs par défaut",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
"layoutResetSuccess": "Mise en page par défaut restaurée",
"layoutUsingDefaults": "Mise en page par défaut active",
"layoutCustomized": "Mise en page personnalisée active",
@@ -2351,6 +2356,7 @@
"send": "Envoyer",
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
"unverifiedFiguresNotice": "Certains chiffres n'ont pas pu être vérifiés avec tes données et ont été omis.",
"composerPlaceholder": "Pose ta question …",
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
"fabLabel": "Ouvrir le coach",
@@ -5545,7 +5551,7 @@
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
"polar": "Polar",
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.",
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil, l'activité et les séances d'entraînement.",
"polarViewData": "Voir votre sommeil et récupération",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"polarCredentials": "Identifiants API",
+7 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Salva",
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
"cancel": "Annulla",
"delete": "Elimina",
"undo": "Annulla",
@@ -354,6 +355,9 @@
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
},
"native": {
"signingInToApp": "Accesso all'app HealthLog"
},
"mfa": {
"title": "Autenticazione a due fattori",
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
@@ -445,6 +449,7 @@
"layoutReset": "Ripristina i valori predefiniti",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
"layoutResetSuccess": "Layout predefinito ripristinato",
"layoutUsingDefaults": "Layout predefinito attivo",
"layoutCustomized": "Layout personalizzato attivo",
@@ -2341,6 +2346,7 @@
"send": "Invia",
"stop": "Interrompi",
"thinking": "Sto pensando…",
"unverifiedFiguresNotice": "Alcuni numeri non è stato possibile verificarli con i tuoi dati e sono stati omessi.",
"composerPlaceholder": "Chiedimi pure …",
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
"fabLabel": "Apri il coach",
@@ -5535,7 +5541,7 @@
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.",
"polar": "Polar",
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno, l'attività giornaliera e le sessioni di allenamento.",
"polarViewData": "Vedi sonno e recupero",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"polarCredentials": "Credenziali API",
+7 -1
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Zapisz",
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
"cancel": "Anuluj",
"delete": "Usuń",
"undo": "Cofnij",
@@ -348,6 +349,9 @@
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
},
"native": {
"signingInToApp": "Logowanie do aplikacji HealthLog"
},
"mfa": {
"title": "Uwierzytelnianie dwuskładnikowe",
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
@@ -439,6 +443,7 @@
"layoutReset": "Przywróć wartości domyślne",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
"layoutResetSuccess": "Przywrócono układ domyślny",
"layoutUsingDefaults": "Aktywny układ domyślny",
"layoutCustomized": "Aktywny układ niestandardowy",
@@ -2335,6 +2340,7 @@
"send": "Wyślij",
"stop": "Zatrzymaj",
"thinking": "Myślę…",
"unverifiedFiguresNotice": "Niektórych liczb nie udało się zweryfikować z Twoimi danymi, więc je pominięto.",
"composerPlaceholder": "Zapytaj mnie …",
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
"fabLabel": "Otwórz trenera",
@@ -5529,7 +5535,7 @@
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
"polar": "Polar",
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.",
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu, dzienną aktywność i treningi.",
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"polarCredentials": "Dane API",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.6",
"version": "1.32.23",
"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",
@@ -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";
+12
View File
@@ -3415,10 +3415,22 @@ model RefreshToken {
// (`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")
+1 -1
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.32.6";
/* @sw-version-fallback */ "v1.32.23";
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/*`
@@ -0,0 +1,171 @@
/**
* Structural guard on the "stale Today hero / dashboard after a write" class.
*
* The daily reads — `dailyDigest()` (Today hero) and `dashboardSnapshot()`
* (dashboard) — run with `refetchOnMount: false` and a 120 s poll. A mutation
* that invalidates one of the daily-reads-bearing bundles
* (`medicationDependentKeys` / `measurementDependentKeys` / `moodDependentKeys`)
* with the DEFAULT `refetchType: "active"` only refetches MOUNTED queries — so a
* write made from a surface where the daily reads are UNMOUNTED (the medications
* page, a detail page, the mood/measurement forms) marks them stale but never
* refetches them. Returning to `/` then serves the pre-write cache until the
* poll ticks or the tab is hard-reloaded.
*
* The blessed contract is `refetchInactiveDailyReads(queryClient)` (or its
* wrapper `invalidateMedicationReads`), which forces the two inactive reads to
* refetch. This class has recurred THREE times (v1.16.11 snapshot, v1.29.1
* digest, v1.32.19 "Take all due") because the bundle cannot express
* `refetchType`, so every new call site re-decides it by hand. This guard makes
* a fourth recurrence fail CI.
*
* Like the Bearer-scope guard, this is a tripwire, not a proof. It cannot show
* an allowlisted exception is correct — only that no NEW site invalidates a
* daily bundle without the paired refetch, and no PAIRED site loses its refetch,
* without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** The factory itself DEFINES the bundles + helpers; it is never a call site. */
const FACTORY = "lib/query-keys/index.ts";
/**
* A direct-bundle invalidation call site: `invalidateKeys(<qc>, <bundle>)` or
* `invalidateKeys(<qc>, [...<bundle>, <localKey>])`. Captures every form the
* codebase actually uses (bare bundle, or spread into an array with an extra
* per-surface key). Sites routed through `invalidateMedicationReads` do NOT
* match — the wrapper carries the refetch internally, so they are compliant by
* construction and correctly invisible here.
*/
const INVALIDATION =
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g;
// Match the CALL form only (open paren), never the bare import specifier —
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
// count and let a file drop one paired call while still "passing".
const REFETCH = /refetchInactiveDailyReads\(/g;
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function count(s: string, re: RegExp): number {
return (s.match(re) ?? []).length;
}
/**
* Files that invalidate a daily bundle WITHOUT pairing the inactive refetch,
* deliberately. Frozen: a new entry is a conscious decision that must land in a
* reviewed diff, exactly like the Bearer-scope mint allowlist. Each entry is a
* surface where the missing refetch is intentional, grouped by reason.
*/
const NO_REFETCH_ALLOWLIST = [
// The digest is ACTIVE on the dashboard where the quick-add lives, so the
// default active invalidation already refetches it (audit B, §1: "not the
// bug").
"components/dashboard/medication-intake-quick-add.tsx",
// Injection-site metadata saved AFTER an already-recorded (and already
// refetched) take — it does not change any dose's due/taken state.
"components/medications/glp1-medication-card.tsx",
"components/medications/medication-card.tsx",
// Pull-to-refresh: a current-surface read gesture on the medications page,
// not a hero-affecting write (audit B, B-5).
"app/medications/page-client.tsx",
// Medication configuration / metadata surfaces — rename, dose, schedule
// times, phase config, push preferences, API tokens, the create wizard.
// None records an intake; a schedule edit's next-due shift converges via the
// standard poll from a deep-config surface. Tighten in a later pass if a
// config surface is shown to strand the rail.
"components/medications/scheduling/schedule-times-editor.tsx",
"components/medications/sections/api-tokens-row.tsx",
"components/medications/sections/notifications-section.tsx",
"components/medications/sections/phase-config-sheet.tsx",
"components/medications/sections/settings-section.tsx",
"components/medications/wizard/medication-wizard-dialog.tsx",
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/fitbit-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/nightscout-card.tsx",
"components/settings/integrations/oauth-provider-card.tsx",
"components/settings/integrations/whoop-card.tsx",
"components/settings/integrations/withings-card.tsx",
].sort();
describe("daily-reads refetch guard", () => {
it("the blessed helper pairs the bundle invalidation with the inactive refetch", () => {
const src = read(FACTORY);
// The one canonical entry point every intake surface routes through.
expect(src).toMatch(/export async function invalidateMedicationReads\(/);
// It must do BOTH — invalidate the bundle AND force the inactive refetch.
const body = src.slice(
src.indexOf("export async function invalidateMedicationReads("),
);
expect(body).toMatch(
/invalidateKeys\(\s*queryClient,\s*medicationDependentKeys\s*\)/,
);
expect(body).toMatch(/refetchInactiveDailyReads\(queryClient\)/);
});
it("every bundle-invalidation site pairs a refetch, except the frozen allowlist", () => {
// For each file that invalidates a daily bundle, the count of paired
// refetches must be at least the count of invalidations. A count rule (not
// a line-proximity heuristic) catches BOTH a new unpaired file AND a new
// unpaired call site slipped into an already-compliant file (its refetch
// count would then fall below its invalidation count).
const nonCompliant: string[] = [];
for (const rel of sourceFiles()) {
if (rel === FACTORY) continue;
const s = read(rel);
const invalidations = count(s, INVALIDATION);
if (invalidations === 0) continue;
const refetches = count(s, REFETCH);
if (refetches < invalidations) nonCompliant.push(rel);
}
// The set of deliberately-unpaired sites must be EXACTLY the allowlist:
// a new intake mutation that forgets the refetch appears here but not in
// the allowlist → fail; a paired site that loses its refetch drops into
// this set → fail; a deliberate new exception must be added above.
expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST);
});
it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => {
// Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to
// a bare `invalidateKeys(medicationDependentKeys)` is caught here as well
// as by the count assertion above.
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
];
for (const rel of routedThroughHelper) {
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
}
const pairInPlace = [
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history-add-dialog.tsx",
];
for (const rel of pairInPlace) {
expect(read(rel)).toMatch(REFETCH);
}
});
});
@@ -0,0 +1,69 @@
/**
* Structural guard — every Prisma migration owns a unique numeric prefix.
*
* Migrations are named `NNNN_description`, and the four-digit prefix is the
* ordering key. When two short-lived branches each grab the next free number
* and both merge, the tree ends up with two `0268_*` directories. Prisma
* applies them in lexical order, but the collision is a silent
* merge-time hazard: a reviewer reading `git log` sees one number twice, and a
* later renumber has to touch history. This test fails the build the moment a
* prefix repeats, so the collision is caught at PR CI rather than after merge.
*/
import { readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { describe, it, expect } from "vitest";
const MIGRATIONS_DIR = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../prisma/migrations",
);
const NUMBERED_MIGRATION = /^(\d+)_/;
/**
* One grandfathered collision that predates this guard. `0025_refresh_tokens`
* and `0025_user_locale_drift_fix` are both long applied on every deployed
* database — a migration directory name is the primary key in the
* `_prisma_migrations` ledger, so renaming either would orphan the applied
* record and force a re-run on every tenant. It is frozen as history; the
* guard grandfathers exactly this prefix and refuses every NEW one.
*/
const GRANDFATHERED_PREFIXES = new Set(["0025"]);
describe("prisma migrations — numeric prefixes are unique", () => {
it("has no two migration directories sharing a numeric prefix", () => {
const prefixes = new Map<string, string[]>();
for (const entry of readdirSync(MIGRATIONS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const match = NUMBERED_MIGRATION.exec(entry.name);
if (!match) continue;
const prefix = match[1];
const bucket = prefixes.get(prefix) ?? [];
bucket.push(entry.name);
prefixes.set(prefix, bucket);
}
const collisions = [...prefixes.entries()].filter(
([prefix, names]) =>
names.length > 1 && !GRANDFATHERED_PREFIXES.has(prefix),
);
expect(
collisions,
collisions.length > 0
? `Duplicate migration prefixes:\n${collisions
.map(([prefix, names]) => ` ${prefix}: ${names.join(", ")}`)
.join("\n")}`
: undefined,
).toEqual([]);
});
it("finds the migration set (guards against a broken directory path)", () => {
const numbered = readdirSync(MIGRATIONS_DIR, {
withFileTypes: true,
}).filter((e) => e.isDirectory() && NUMBERED_MIGRATION.test(e.name));
expect(numbered.length).toBeGreaterThan(0);
});
});
@@ -58,6 +58,7 @@ import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
@@ -26,6 +26,7 @@ import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
import type { WideEvent } from "@/lib/logging/types";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
@@ -35,6 +35,7 @@ import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
@@ -32,6 +32,7 @@ import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
@@ -45,6 +45,7 @@ import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
@@ -55,6 +55,7 @@ import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
@@ -50,6 +50,7 @@ import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
@@ -40,6 +40,7 @@ import { requireAdmin, HttpError } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
@@ -36,6 +36,7 @@ describe("GET /api/admin/users", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRequireAdmin.mockResolvedValue({
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
@@ -0,0 +1,174 @@
/**
* v1.32.22 (M4) — `PUT /api/auth/me/coach-prefs` optimistic concurrency.
*
* A full-replace endpoint hydrated from a stale GET (two tabs / two devices)
* clobbers silently; the base token 409s that. The guarded write covers BOTH
* `coachPrefsJson` AND the mirrored `insightsExcludeMetrics` column in one
* conditional update, so a 409 leaves both untouched together. A tokenless PUT
* keeps the prior unconditional write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/coach-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/auth/me/coach-prefs", () => {
it("returns a SAVED user's prefs plus the optimistic-concurrency token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
coachPrefsJson: { tone: "concise" },
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("concise");
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("returns the PURE default with NO token for a never-saved user", async () => {
// Mirrors the insights-layout never-saved GET: nothing to guard yet, and
// the first save is the tokenless unconditional write (backward-compat).
vi.mocked(prisma.user.findUnique).mockResolvedValue({
coachPrefsJson: null,
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { tone: string; updatedAt?: string };
};
expect(env.data.tone).toBe("warm");
expect(env.data.updatedAt).toBeUndefined();
});
});
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
it("guards both columns in one conditional write and echoes the token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0] as {
where: { id: string; updatedAt: Date };
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect((call.where.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
// Both mirror columns ride the SAME guarded update.
expect(call.data.coachPrefsJson).toBeTruthy();
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("interleaved writers: a stale token 409s and leaves BOTH columns untouched", async () => {
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({
excludeMetrics: ["weight"],
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("coach_prefs_conflict");
// Nothing written — the single guarded update matched no row.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] };
};
expect(call.data.insightsExcludeMetrics).toEqual(["weight"]);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+38 -6
View File
@@ -19,6 +19,11 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
coachPrefsSchema,
parseCoachPrefs,
@@ -32,9 +37,18 @@ export const GET = apiHandler(async () => {
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { coachPrefsJson: true },
select: { coachPrefsJson: true, updatedAt: true },
});
return apiSuccess(parseCoachPrefs(row?.coachPrefsJson));
const prefs = parseCoachPrefs(row?.coachPrefsJson);
// v1.32.22 (M4) — a never-saved user gets the PURE default with NO token
// (mirrors the insights-layout never-saved GET). There is nothing to guard
// against yet, and the first save is just the tokenless unconditional write.
// A saved row carries `updatedAt` (additive sibling key; the prefs shape is a
// fixed key set, so it cannot collide) so the client can echo it.
if (row?.coachPrefsJson == null) {
return apiSuccess(prefs);
}
return apiSuccess({ ...prefs, updatedAt: row.updatedAt?.toISOString() });
});
export const PUT = apiHandler(async (req: Request) => {
@@ -46,7 +60,15 @@ export const PUT = apiHandler(async (req: Request) => {
throw new HttpError(422, "coach-prefs.body.invalid_json");
}
const parsed = coachPrefsSchema.safeParse(body ?? {});
// v1.32.22 (M4) — pull the optimistic-concurrency base token off before the
// parse. This is a full-replace endpoint, so the classic two-tab / two-device
// stale-replace clobbers silently: the form was hydrated from a GET minutes
// ago. The token closes that window.
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = coachPrefsSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.coach-prefs.put.invalid" },
@@ -63,13 +85,23 @@ export const PUT = apiHandler(async (req: Request) => {
// Coach snapshot share a single source of truth. Keeps the user's
// privacy contract symmetric across the two surfaces without
// requiring a separate Insights settings sheet.
await prisma.user.update({
where: { id: user.id },
// v1.32.22 (M4) — the guarded write covers BOTH columns in one conditional
// update so the mirror-column contract (v1.4.36 W3 T3) stays unsplittable: a
// 409 leaves `coachPrefsJson` AND `insightsExcludeMetrics` untouched together.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: {
coachPrefsJson: parsed.data,
insightsExcludeMetrics: parsed.data.excludeMetrics,
},
conflict: {
action: "auth.me.coach-prefs.conflict",
errorCode: "coach_prefs_conflict",
message: "Coach preferences changed since they were loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "auth.me.coach-prefs.put" },
@@ -83,5 +115,5 @@ export const PUT = apiHandler(async (req: Request) => {
clusterCount: parsed.data.dataClusters?.length ?? null,
},
});
return apiSuccess(parsed.data);
return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt });
});
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
cycleProfile: {
findUnique: vi.fn(),
@@ -198,6 +199,7 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { mood: false } },
});
expect(auditLog).toHaveBeenCalledWith(
@@ -220,6 +222,7 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: false, sleep: false } },
});
});
@@ -234,6 +237,7 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: true } },
});
});
@@ -310,7 +314,100 @@ describe("PATCH /api/auth/me/modules", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { sleep: false, labs: false } },
});
});
});
/**
* v1.32.22 (M3) — optimistic concurrency. The base token is stripped BEFORE
* the `.strict()` schema parse, so it never trips strict validation; a stale
* token 409s and writes nothing; a tokenless PATCH keeps the unconditional
* write.
*/
describe("PATCH /api/auth/me/modules — optimistic concurrency", () => {
it("guards on the base token and skips the unconditional write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("modules_conflict");
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("strips the token pre-parse: a token ALONE does not trip the strict schema", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
// 200, not 422 — `baseUpdatedAt` was removed before the strict parse.
expect(res.status).toBe(200);
});
it("still 422s an unknown key even alongside a valid token (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+32 -5
View File
@@ -36,6 +36,11 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
@@ -49,7 +54,13 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "auth.me.modules.get" } });
const modules = await resolveModuleMap(user.id);
return apiSuccess({ modules });
// v1.32.22 (M3) — surface the optimistic-concurrency token. `resolveModuleMap`
// returns no row metadata, so read `updatedAt` on its own.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { updatedAt: true },
});
return apiSuccess({ modules, updatedAt: row?.updatedAt?.toISOString() });
});
export const PATCH = apiHandler(async (req: Request) => {
@@ -75,7 +86,14 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "modules.body.invalid_json");
}
const parsed = modulePrefsPatchSchema.safeParse(body ?? {});
// v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the
// parse: `modulePrefsPatchSchema` is `.strict()` and must stay strict, so
// the transport token can never reach it (it would 422 as an unknown key).
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = modulePrefsPatchSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.modules.patch.invalid_shape" },
@@ -105,10 +123,19 @@ export const PATCH = apiHandler(async (req: Request) => {
changed.push(key);
}
await prisma.user.update({
where: { id: user.id },
// v1.32.22 (M3) — guard the write on the client's base token; a stale base
// 409s and writes nothing. Tokenless requests keep the unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { modulePreferencesJson: merged },
conflict: {
action: "auth.me.modules.conflict",
errorCode: "modules_conflict",
message: "Module preferences changed since they were loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
const modules = await resolveModuleMap(user.id);
@@ -123,5 +150,5 @@ export const PATCH = apiHandler(async (req: Request) => {
meta: { changed },
});
return apiSuccess({ modules });
return apiSuccess({ modules, updatedAt: guarded.updatedAt });
});
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
@@ -234,10 +235,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
measurementReminder: {
clientManaged: false,
},
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -365,6 +369,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -427,10 +432,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
measurementReminder: {
clientManaged: false,
},
// v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -476,6 +484,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
@@ -531,6 +540,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: expect.objectContaining({
medication: {
@@ -588,3 +598,113 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 (M1) — optimistic concurrency (issue #581 family). A PATCH carrying
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
* still carrying that token. The headline case: a concurrent web PATCH of a
* DIFFERENT category can no longer silently revert the iOS `clientManaged`
* flag (the double-reminder class). A tokenless PATCH keeps the prior
* unconditional write — the iOS-compat arm.
*/
describe("PATCH /api/auth/me/notification-prefs — optimistic concurrency", () => {
function mkTokenedPatch(body: unknown, baseUpdatedAt?: string): Request {
return mkPatch(
baseUpdatedAt !== undefined
? { ...(body as object), baseUpdatedAt }
: body,
);
}
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ notificationPrefs: null } as never)
// post-write fresh-token read
.mockResolvedValueOnce({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch(
{ medication: { clientManaged: true } },
"2026-07-24T10:00:00.000Z",
),
);
expect(res.status).toBe(200);
// Conditional write, no unconditional fallback.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("regression: a stale-token web write does NOT revert the iOS clientManaged flag", async () => {
// iOS set medication.clientManaged = true (row now carries it). A web card
// PATCH based on a pre-iOS read of a DIFFERENT category arrives with a stale
// token: it must 409 and write NOTHING, so clientManaged survives.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ mood: { reminderHour: 9 } }, "2026-07-24T08:00:00.000Z"),
);
expect(res.status).toBe(409);
const env = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(env.data).toBeNull();
expect(env.meta?.errorCode).toBe("notification_prefs_conflict");
// The guarded write matched no row; nothing was written, so the persisted
// clientManaged flag is untouched.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("compat: the SAME second write TOKENLESS still lands (old clients keep last-write-wins)", async () => {
// Deliberate: a tokenless client keeps the pre-guard race. This pins that
// the clobber for old clients is a documented choice, not an accident.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ medication: { clientManaged: true } }, "not-a-date"),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -34,6 +34,11 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
notificationPrefsSchema,
parseNotificationPrefs,
@@ -51,9 +56,14 @@ export const GET = apiHandler(async () => {
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
// v1.32.22 (M1) — `updatedAt` rides the response as the optimistic-
// concurrency token so a client can echo it on its next PATCH.
select: { notificationPrefs: true, updatedAt: true },
});
return apiSuccess({
...parseNotificationPrefs(row?.notificationPrefs ?? null),
updatedAt: row?.updatedAt?.toISOString(),
});
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
});
export const PATCH = apiHandler(async (req: Request) => {
@@ -79,7 +89,17 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "notification-prefs.body.invalid_json");
}
const parsed = notificationPrefsSchema.safeParse(body ?? {});
// v1.32.22 (M1) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse so it never pollutes `changedCategories`
// (`Object.keys(parsed.data)`) or the schema. The race that matters: iOS
// sets `medication.clientManaged = true`, a web card PATCH based on a
// pre-iOS read deep-merges the whole blob WITHOUT it, silently reverting the
// flag (the double-reminder class). With the token, that stale write 409s.
const taken = takeBaseToken(body ?? {});
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = notificationPrefsSchema.safeParse(taken.rest ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
@@ -103,10 +123,21 @@ export const PATCH = apiHandler(async (req: Request) => {
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
// v1.32.22 (M1) — guard the deep-merge write on the client's base token. A
// stale base 409s and writes NOTHING, so a concurrent web PATCH can't revert
// a category (e.g. `medication.clientManaged`) another writer just set from a
// fresher read. A tokenless request keeps the prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { notificationPrefs: toJson(merged) },
conflict: {
action: "user.notification_prefs.conflict",
errorCode: "notification_prefs_conflict",
message: "Notification preferences changed since they were loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
// Capture which categories the PATCH actually touched so the audit
// trail records the user's intent, not just the full resolved row.
@@ -130,5 +161,5 @@ export const PATCH = apiHandler(async (req: Request) => {
},
});
return apiSuccess(merged);
return apiSuccess({ ...merged, updatedAt: guarded.updatedAt });
});
@@ -0,0 +1,111 @@
/**
* Structural invariants for the native web-handoff surface (iOS #65).
*
* These are tripwires, not proofs — they assert the load-bearing constructions
* the red-team relied on stay in place: no `redirect_uri` parameter, one
* compile-time callback constant, a trailing-slash public-path entry, the
* cookie-only admin / step-up boundaries, and that the new routes never resolve
* a Bearer token or mint an ApiToken.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const SRC = join(process.cwd(), "src");
const read = (rel: string) => readFileSync(join(SRC, rel), "utf8");
/**
* Strip block + line comments so an invariant asserts against CODE, not against
* a doc comment that legitimately names the thing it forbids (e.g. "there is no
* `redirect_uri` parameter").
*/
function code(rel: string): string {
return read(rel)
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
const ROUTE_FILES = [
"app/api/auth/native/login/route.ts",
"app/api/auth/native/complete/route.ts",
"app/api/auth/native/token/route.ts",
];
describe("T6 — open redirect / redirect_uri removed by construction", () => {
it("no new route or handoff module references a redirect_uri parameter", () => {
const files = [
...ROUTE_FILES,
"lib/auth/native-web-handoff.ts",
"lib/auth/native-handoff.ts",
];
// Comments legitimately state "there is no redirect_uri parameter"; the
// invariant is that no CODE reads or builds one.
for (const rel of files) {
expect(code(rel)).not.toMatch(/redirect_uri/);
}
});
it("the callback scheme is a single compile-time constant", () => {
// Defined exactly once, as a string literal, with no interpolation.
const mod = read("lib/auth/native-web-handoff.ts");
expect(mod).toMatch(
/NATIVE_WEB_HANDOFF_REDIRECT_URI\s*=\s*"healthlog:\/\/login-callback"/,
);
// The routes build the Location only through the helper, never a bespoke
// scheme string in code (doc comments naming the scheme are fine).
for (const rel of ROUTE_FILES) {
expect(code(rel)).not.toMatch(/"healthlog:\/\//);
}
});
});
describe("A4 — public-path allowlist carries the trailing slash", () => {
it("proxy admits exactly /api/auth/native/ (slash), never the bare prefix", () => {
const proxy = read("proxy.ts");
expect(proxy).toMatch(/"\/api\/auth\/native\/"/);
// The slashless literal must not appear as its own allowlist entry.
expect(proxy).not.toMatch(/"\/api\/auth\/native"(?!\/)/);
});
});
describe("T12 — the new routes resolve no Bearer and mint no ApiToken", () => {
it("no native route resolves a Bearer token or hand-rolls a tokenHash lookup", () => {
for (const rel of ROUTE_FILES) {
const src = read(rel);
expect(src).not.toMatch(/resolveBearerToken/);
expect(src).not.toMatch(/apiToken\.findUnique/);
}
});
it("no native route creates an ApiToken", () => {
for (const rel of ROUTE_FILES) {
const src = read(rel);
expect(src).not.toMatch(/apiToken\.create\(|issueApiToken\(/);
}
});
});
describe("T11/T13 — cookie-only boundaries and no bundle-in-browser", () => {
it("the token route never sets a cookie itself", () => {
// The bundle rides the JSON body; a Set-Cookie here would be the browser leak.
expect(read("app/api/auth/native/token/route.ts")).not.toMatch(
/cookies\.set|Set-Cookie/,
);
});
it("requireAdmin and requireFreshMfa remain cookie-only (getSession)", () => {
const handler = read("lib/api-handler.ts");
// Both resolve via getSession() (cookie-only) — restated as a tripwire so a
// future edit that threads a Bearer into either trips this test.
const requireAdmin = handler.slice(
handler.indexOf("export async function requireAdmin"),
handler.indexOf("export async function requireAdmin") + 400,
);
expect(requireAdmin).toMatch(/getSession\(\)/);
const requireFresh = handler.slice(
handler.indexOf("export async function requireFreshMfa"),
handler.indexOf("export async function requireFreshMfa") + 400,
);
expect(requireFresh).toMatch(/getSession\(\)/);
});
});
@@ -0,0 +1,193 @@
/**
* GET /api/auth/native/complete — the freshness-bound code mint (iOS #65).
*
* The load-bearing test is the mandatory-refusal one (red-team A3): a STALE
* session (createdAt < startedAt) is refused with a specific reason AND mints no
* code / row. This file is the mutation-check target for the freshness gate
* (flip the comparison in the route → the stale-refusal case goes RED).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY = "0".repeat(64);
vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
const validateSessionWithCreatedAt = vi.fn();
vi.mock("@/lib/auth/session", () => ({
validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v),
SESSION_COOKIE_NAME: "healthlog_session",
}));
// Keep the real shared core (buildHandoffCallbackUrl is used by
// native-web-handoff) but stub the mint so we can assert called / not-called.
vi.mock("@/lib/auth/native-handoff", async (importActual) => {
const actual =
await importActual<typeof import("@/lib/auth/native-handoff")>();
return { ...actual, mintNativeHandoff: vi.fn() };
});
vi.mock("@/lib/db", () => ({
prisma: { session: { delete: vi.fn().mockResolvedValue({}) } },
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
import { prisma } from "@/lib/db";
import {
NATIVE_HANDOFF_STATE_COOKIE,
encodeNativeHandoffState,
} from "@/lib/auth/native-web-handoff";
const STARTED_AT = new Date("2026-07-24T10:00:00.000Z");
/** Build a request carrying a valid encrypted state cookie + a session cookie. */
function makeRequest(opts: {
withState?: boolean;
stateValue?: string;
withSession?: boolean;
}): NextRequest {
const r = new NextRequest("http://localhost/api/auth/native/complete");
if (opts.stateValue !== undefined) {
r.cookies.set(NATIVE_HANDOFF_STATE_COOKIE, opts.stateValue);
} else if (opts.withState !== false) {
r.cookies.set(
NATIVE_HANDOFF_STATE_COOKIE,
encodeNativeHandoffState({
appCodeChallenge: "a".repeat(43),
startedAt: STARTED_AT.toISOString(),
}),
);
}
if (opts.withSession !== false) {
r.cookies.set("healthlog_session", "hls_sessionsecret");
}
return r;
}
function sessionCreatedAt(createdAt: Date) {
return {
session: {
id: "sess-1",
createdAt,
expiresAt: new Date(Date.now() + 1000000),
},
user: { id: "user-1", username: "alice" },
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "1.2.3.4",
} as never);
vi.mocked(mintNativeHandoff).mockResolvedValue({
code: "hlh_" + "z".repeat(43),
handoffId: "ho-1",
});
});
describe("GET /api/auth/native/complete", () => {
it("fresh session → mints a web_login code and redirects to the scheme", async () => {
// createdAt AFTER startedAt: the user authenticated inside this flow.
validateSessionWithCreatedAt.mockResolvedValue(
sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)),
);
const res = await GET(makeRequest({}));
const location = res.headers.get("location")!;
expect(location.startsWith("healthlog://login-callback?code=hlh_")).toBe(
true,
);
// The token pair never rides the URL — only the code.
expect(location).not.toContain("token");
expect(location).not.toContain("refresh");
expect(mintNativeHandoff).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
appCodeChallenge: "a".repeat(43),
flow: "web_login",
}),
);
// State cookie deleted + scaffold session destroyed + session cookie cleared.
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBe("");
expect(prisma.session.delete).toHaveBeenCalledWith({
where: { id: "sess-1" },
});
expect(res.cookies.get("healthlog_session")?.value).toBe("");
});
it("A3 — STALE session (createdAt < startedAt) is REFUSED and mints nothing", async () => {
validateSessionWithCreatedAt.mockResolvedValue(
sessionCreatedAt(new Date(STARTED_AT.getTime() - 1000)),
);
const res = await GET(makeRequest({}));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=stale_session",
);
// No code minted, no scaffold session destroyed.
expect(mintNativeHandoff).not.toHaveBeenCalled();
expect(prisma.session.delete).not.toHaveBeenCalled();
// The single-use state cookie is still cleared on the failure branch.
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBe("");
});
it("no state cookie → invalid_state, no mint", async () => {
validateSessionWithCreatedAt.mockResolvedValue(
sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)),
);
const res = await GET(makeRequest({ withState: false }));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=invalid_state",
);
expect(mintNativeHandoff).not.toHaveBeenCalled();
});
it("tampered / garbage state cookie → invalid_state (auth tag fails)", async () => {
validateSessionWithCreatedAt.mockResolvedValue(
sessionCreatedAt(new Date(STARTED_AT.getTime() + 1000)),
);
const res = await GET(makeRequest({ stateValue: "v1.not-a-real-blob" }));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=invalid_state",
);
expect(mintNativeHandoff).not.toHaveBeenCalled();
});
it("no valid session → no_session, no mint", async () => {
validateSessionWithCreatedAt.mockResolvedValue(null);
const res = await GET(makeRequest({ withSession: false }));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=no_session",
);
expect(mintNativeHandoff).not.toHaveBeenCalled();
});
it("rate-limited → scheme error, no mint", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "1.2.3.4",
} as never);
const res = await GET(makeRequest({}));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=rate_limited",
);
expect(mintNativeHandoff).not.toHaveBeenCalled();
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* GET /api/auth/native/complete
*
* Completion leg of the first-party web-handoff login (iOS #65). After the user
* signs in on the instance's own web login page (inside the app's
* `ASWebAuthenticationSession`), the page navigates the browser here as a
* top-level GET. This endpoint validates the web session, enforces the FRESHNESS
* BINDING, mints a single-use handoff code, and redirects to the compiled-in
* custom scheme carrying ONLY the opaque code.
*
* Security-critical invariants:
* - `userId` is resolved SOLELY from the DB-validated session cookie — the
* request carries no identity field; the app challenge lives in the
* tamper-authenticated state cookie.
* - FRESHNESS: `session.createdAt >= state.startedAt`. The session completing the
* flow must have been minted AFTER the flow started — i.e. the user actually
* authenticated inside this ASWebAuthenticationSession. A pre-existing browser
* session can NEVER silently complete a handoff. Both timestamps are DB-clock
* values, so there is no app/DB skew (red-team A1). `createdAt` is read from a
* dedicated projection (`validateSessionWithCreatedAt`) so the comparison can
* never be silently dropped (red-team A3).
* - The token pair NEVER rides the URL — only the opaque code does.
* - Every failure redirects to `healthlog://login-callback?error=<reason>` and
* deletes the state cookie; no code or row is minted on any failure branch.
*/
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import {
validateSessionWithCreatedAt,
SESSION_COOKIE_NAME,
} from "@/lib/auth/session";
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
import {
NATIVE_HANDOFF_STATE_COOKIE,
NATIVE_HANDOFF_STATE_COOKIE_PATH,
buildWebHandoffCallbackUrl,
decodeNativeHandoffState,
type NativeHandoffErrorReason,
} from "@/lib/auth/native-web-handoff";
export const dynamic = "force-dynamic";
/**
* The state cookie is path-scoped to `/api/auth/native`; RFC 6265 keys cookies
* by name+domain+path, so the delete must repeat that path or it silently
* targets `/` and leaves the single-use blob alive.
*/
function deleteStateCookie(response: NextResponse): void {
response.cookies.delete({
name: NATIVE_HANDOFF_STATE_COOKIE,
path: NATIVE_HANDOFF_STATE_COOKIE_PATH,
});
}
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.native.complete" } });
// Every failure — and success — deletes the single-use state cookie.
const fail = (reason: NativeHandoffErrorReason): NextResponse => {
const response = NextResponse.redirect(
buildWebHandoffCallbackUrl({ error: reason }),
);
deleteStateCookie(response);
return response;
};
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:native:complete",
20,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
annotate({ meta: { reason: "rate_limited" } });
return fail("rate_limited");
}
// (1) The encrypted, tamper-authenticated state cookie. A missing, forged, or
// tampered blob (AES-256-GCM auth tag) fails to decode → no mint.
const state = decodeNativeHandoffState(
req.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value,
);
if (!state) {
annotate({ meta: { reason: "invalid_state" } });
return fail("invalid_state");
}
// (2) A genuinely authenticated web session — validated against the database
// (row + expiry), never "a cookie is present". Projects `createdAt` so the
// freshness comparison below is structurally always available.
const authed = await validateSessionWithCreatedAt(
req.cookies.get(SESSION_COOKIE_NAME)?.value,
);
if (!authed) {
annotate({ meta: { reason: "no_session" } });
return fail("no_session");
}
// (3) FRESHNESS BINDING. The session must have been minted AFTER the flow
// started. A stale (pre-existing) session is refused and mints NOTHING.
const startedAt = new Date(state.startedAt);
if (authed.session.createdAt.getTime() < startedAt.getTime()) {
annotate({ meta: { reason: "stale_session" } });
return fail("stale_session");
}
// (4) Mint the single-use, PKCE-locked, flow-tagged handoff code. `userId` is
// the server-resolved session identity; the app's S256 challenge (from the
// tamper-authenticated cookie) locks the code to the app instance that
// started the flow.
const ua = req.headers.get("user-agent");
const { code } = await mintNativeHandoff({
userId: authed.user.id,
appCodeChallenge: state.appCodeChallenge,
flow: "web_login",
ipAddress: ip,
userAgent: ua,
});
await auditLog("auth.native.handoff_minted", {
userId: authed.user.id,
ipAddress: ip,
});
annotate({ action: { name: "auth.native.handoff_minted" } });
const response = NextResponse.redirect(buildWebHandoffCallbackUrl({ code }));
deleteStateCookie(response);
// (5) Destroy the scaffold web session (design §2.6, recommended). The flow's
// product is the native bundle; the browser sheet's residual login is not
// something the user asked for, and the freshness rule already makes a
// retained session worthless for any FUTURE flow. Best-effort — a delete
// hiccup never fails the handoff.
await prisma.session
.delete({ where: { id: authed.session.id } })
.catch(() => {});
response.cookies.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: 0,
path: "/",
});
return response;
});
@@ -0,0 +1,95 @@
/**
* GET /api/auth/native/login — authorize entry (iOS #65).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY = "0".repeat(64);
vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
// Only `$queryRaw` (the DB clock) is used here; return a fixed instant.
// Hoisted so the mock factory (also hoisted) can reference it.
const { DB_NOW } = vi.hoisted(() => ({
DB_NOW: new Date("2026-07-24T10:00:00.000Z"),
}));
vi.mock("@/lib/db", () => ({
prisma: { $queryRaw: vi.fn().mockResolvedValue([{ now: DB_NOW }]) },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import {
NATIVE_HANDOFF_STATE_COOKIE,
decodeNativeHandoffState,
} from "@/lib/auth/native-web-handoff";
const VALID_CHALLENGE = "a".repeat(43);
function req(query = `code_challenge=${VALID_CHALLENGE}`): NextRequest {
return new NextRequest(`http://localhost/api/auth/native/login?${query}`);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "1.2.3.4",
} as never);
});
describe("GET /api/auth/native/login", () => {
it("valid challenge → 302 to the web login page with flow=native + state cookie", async () => {
const res = await GET(req());
const location = res.headers.get("location")!;
expect(location).toContain("/auth/login?flow=native");
// No custom scheme on the success path.
expect(location.startsWith("healthlog://")).toBe(false);
const cookie = res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE);
expect(cookie?.value).toBeTruthy();
expect(cookie?.path).toBe("/api/auth/native");
expect(cookie?.httpOnly).toBe(true);
// The state round-trips: challenge bound, startedAt is the DB clock.
const state = decodeNativeHandoffState(cookie!.value);
expect(state?.appCodeChallenge).toBe(VALID_CHALLENGE);
expect(state?.startedAt).toBe(DB_NOW.toISOString());
});
it("invalid (short) challenge → scheme error, no state cookie", async () => {
const res = await GET(req("code_challenge=tooshort"));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=invalid_request",
);
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBeFalsy();
// No DB clock read on the reject path.
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
it("missing challenge → scheme error", async () => {
const res = await GET(req(""));
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=invalid_request",
);
});
it("rate-limited → scheme error, no state cookie, no DB write", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "1.2.3.4",
} as never);
const res = await GET(req());
expect(res.headers.get("location")).toBe(
"healthlog://login-callback?error=rate_limited",
);
expect(prisma.$queryRaw).not.toHaveBeenCalled();
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* GET /api/auth/native/login
*
* Authorize entry for the first-party web-handoff login (iOS #65). The iOS app,
* on a self-hosted domain, opens this URL inside an `ASWebAuthenticationSession`
* with its PKCE `code_challenge`. This endpoint validates the challenge, folds
* it plus a DB-clock `startedAt` into an AES-256-GCM state cookie, and redirects
* to the instance's own web login at `/auth/login?flow=native` so the login runs
* in the real web origin (fixing password autofill + passkeys for self-hosters).
*
* Pre-auth surface: writes NO database rows (anonymous flooding costs the caller
* a cookie in their own jar). Every error branch redirects to the compiled-in
* custom scheme with a machine-readable `error=<reason>` — an error redirect
* carries no code or session, so it leaks nothing.
*/
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import {
NATIVE_HANDOFF_STATE_COOKIE,
NATIVE_HANDOFF_STATE_COOKIE_PATH,
NATIVE_HANDOFF_STATE_TTL_MS,
buildWebHandoffCallbackUrl,
encodeNativeHandoffState,
nativeHandoffDbNow,
type NativeHandoffErrorReason,
} from "@/lib/auth/native-web-handoff";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (req: NextRequest) => {
annotate({ action: { name: "auth.native.login" } });
// The whole route is a native start — every failure redirects to the scheme.
const loginError = (reason: NativeHandoffErrorReason): NextResponse =>
NextResponse.redirect(buildWebHandoffCallbackUrl({ error: reason }));
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:native:login",
10,
15 * 60 * 1000,
);
if (!rl.allowed) return loginError("rate_limited");
// A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is
// structurally unsupported — the exchange only ever verifies S256.
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
if (!isValidChallenge(appCodeChallenge)) {
annotate({ meta: { reason: "invalid_native_request" } });
return loginError("invalid_request");
}
// Stamp `startedAt` from the DB clock so it is directly comparable to
// `Session.createdAt` (also DB-side) at completion, with no app/DB skew.
const startedAt = await nativeHandoffDbNow();
const response = NextResponse.redirect(
new URL("/auth/login?flow=native", req.url),
);
response.cookies.set(
NATIVE_HANDOFF_STATE_COOKIE,
encodeNativeHandoffState({
appCodeChallenge,
startedAt: startedAt.toISOString(),
}),
{
httpOnly: true,
secure: shouldEmitSecureCookie(),
// The completion is a top-level GET navigated by the page after login;
// Lax carries the cookie on that same-site top-level navigation.
sameSite: "lax",
maxAge: Math.floor(NATIVE_HANDOFF_STATE_TTL_MS / 1000),
// Only the completion endpoint ever reads it — the delete repeats this path.
path: NATIVE_HANDOFF_STATE_COOKIE_PATH,
},
);
return response;
});
@@ -0,0 +1,236 @@
/**
* POST /api/auth/native/token — web-handoff exchange (iOS #65).
*
* Mirrors the OIDC native-token route tests, with the `web_login` flow gate.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/native-client", () => ({
isCookielessNativeCaller: vi.fn(() => true),
}));
vi.mock("@/lib/auth/native-handoff", () => ({
consumeNativeHandoff: vi.fn(),
stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
import {
consumeNativeHandoff,
stampIssuedRefreshToken,
} from "@/lib/auth/native-handoff";
import { finishLogin } from "@/lib/auth/login-response";
import { auditLog } from "@/lib/auth/audit";
const VALID_BODY = {
code: "hlh_" + "a".repeat(43),
codeVerifier: "a".repeat(43),
};
function makeRequest(
body: unknown = VALID_BODY,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/auth/native/token", {
method: "POST",
headers: {
"content-type": "application/json",
"x-client-type": "native",
...headers,
},
body: typeof body === "string" ? body : JSON.stringify(body),
});
}
function nativeBundleResponse() {
return NextResponse.json({
data: {
user: { id: "user-1", username: "alice" },
token: "hlk_access",
tokenExpiresAt: new Date().toISOString(),
refreshToken: "hlr_refresh",
refreshTokenExpiresAt: new Date().toISOString(),
},
error: null,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "1.2.3.4",
} as never);
vi.mocked(isCookielessNativeCaller).mockReturnValue(true);
});
describe("POST /api/auth/native/token", () => {
it("429 when rate-limited (before any state is touched)", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "1.2.3.4",
} as never);
const res = await POST(makeRequest());
expect(res.status).toBe(429);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("401 and never consumes when the transport is not cookie-less native", async () => {
vi.mocked(isCookielessNativeCaller).mockReturnValue(false);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("401 when the caller presents no native marker (browser UA path)", async () => {
// isCookielessNativeCaller true, but no x-client-type / native UA →
// isNativeClientRequest false → rejected before any state.
const res = await POST(makeRequest(VALID_BODY, { "x-client-type": "web" }));
expect(res.status).toBe(401);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("422 on a malformed body", async () => {
const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" }));
expect(res.status).toBe(422);
expect(consumeNativeHandoff).not.toHaveBeenCalled();
});
it("consumes with the web_login flow gate", async () => {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "not_found",
} as never);
await POST(makeRequest());
expect(consumeNativeHandoff).toHaveBeenCalledWith(
VALID_BODY.code,
VALID_BODY.codeVerifier,
"web_login",
);
});
it("returns ONE identical generic 401 across every invalid-code class", async () => {
const statuses = [
{ status: "not_found" },
{ status: "replayed", userId: "user-1" },
{ status: "expired" },
{ status: "pkce_mismatch" },
{ status: "race_lost" },
] as const;
const bodies: string[] = [];
for (const s of statuses) {
vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
bodies.push(await res.text());
}
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
const res = await POST(makeRequest());
expect(res.status).toBe(401);
bodies.push(await res.text());
expect(new Set(bodies).size).toBe(1);
});
it("does not mint on the deleted-user branch", async () => {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
await POST(makeRequest());
expect(finishLogin).not.toHaveBeenCalled();
});
it("200 on success: mints via the web_handoff source, stamps, audits, no mfaVerified", async () => {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "alice",
onboardingCompletedAt: new Date(),
} as never);
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
const res = await POST(makeRequest());
expect(res.status).toBe(200);
const finishArg = vi.mocked(finishLogin).mock.calls[0][0];
expect(finishArg.source).toBe("login.web_handoff");
// Never set — the handoff login can't satisfy step-up.
expect(finishArg.mfaVerified).toBeUndefined();
expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh");
expect(auditLog).toHaveBeenCalledWith(
"auth.login",
expect.objectContaining({
userId: "user-1",
details: { transport: "native", method: "web_handoff" },
}),
);
const body = await res.json();
expect(body.data.refreshToken).toBe("hlr_refresh");
});
it("is NOT blocked by OIDC_ONLY", async () => {
const prev = process.env.OIDC_ONLY;
process.env.OIDC_ONLY = "true";
try {
vi.mocked(consumeNativeHandoff).mockResolvedValue({
status: "ok",
userId: "user-1",
handoffId: "ho-1",
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "alice",
onboardingCompletedAt: new Date(),
} as never);
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
const res = await POST(makeRequest());
expect(res.status).toBe(200);
} finally {
if (prev === undefined) delete process.env.OIDC_ONLY;
else process.env.OIDC_ONLY = prev;
}
});
});
+160
View File
@@ -0,0 +1,160 @@
/**
* POST /api/auth/native/token
*
* The cookie-less native leg of the first-party web-handoff login (iOS #65). The
* app presents the one-time handoff code it received on
* `healthlog://login-callback?code=` plus its PKCE `codeVerifier`; on success it
* receives the SAME native token bundle password login issues
* (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`).
*
* Byte-for-byte the posture of `POST /api/auth/oidc/native/token`, with the
* `flow: "web_login"` gate on the shared consume:
* - It IS the credential mint: no session or Bearer is required to reach it.
* - The transport gate (`isCookielessNativeCaller` + `isNativeClientRequest`)
* makes the endpoint structurally incapable of minting a web session or handing
* a 60-day refresh token into a browser context.
* - `userId` is derived SOLELY from the server-side handoff row.
* - A single generic 401 covers not-found / expired / used / PKCE-mismatch /
* cross-flow / deleted-user so a code-guesser learns nothing.
* - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy
* `requireFreshMfa` step-up.
*/
import { NextRequest } from "next/server";
import { apiError, safeJson } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
import { isNativeClientRequest } from "@/lib/auth/issue-token";
import { finishLogin } from "@/lib/auth/login-response";
import {
consumeNativeHandoff,
stampIssuedRefreshToken,
} from "@/lib/auth/native-handoff";
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
export const dynamic = "force-dynamic";
/** The single generic rejection for every invalid-code class. */
function invalidCode() {
return apiError("Invalid or expired code", 401);
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.native.token" } });
// (1) Rate limit — the MFA-verify surface's posture, incl. the
// trust-violation collapse semantics.
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:native:token",
10,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(rl),
});
}
// (2) Transport gate — this endpoint only ever hands a native token bundle. A
// browser (Mozilla UA or an inbound session cookie) is rejected, AND the
// caller must present an explicit native marker, so the cookie branch of
// `finishLogin` is structurally unreachable.
if (
!isCookielessNativeCaller(request.headers) ||
!isNativeClientRequest(request.headers)
) {
annotate({ action: { name: "auth.native.token.wrong_transport" } });
return invalidCode();
}
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = nativeHandoffTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const { code, codeVerifier } = parsed.data;
// (4) Lookup + flow check + replay/expiry/PKCE + atomic single-use consume.
// The `web_login` flow gate rejects an OIDC-minted code as `not_found`.
const result = await consumeNativeHandoff(code, codeVerifier, "web_login");
if (result.status !== "ok") {
switch (result.status) {
case "not_found":
annotate({ action: { name: "auth.native.token.not_found" } });
break;
case "replayed":
annotate({
action: { name: "auth.native.handoff_replay" },
meta: { revoked_issued_pair: result.revokedIssuedPair },
});
break;
case "expired":
annotate({ action: { name: "auth.native.token.expired" } });
break;
case "pkce_mismatch":
annotate({ action: { name: "auth.native.pkce_failed" } });
break;
case "race_lost":
annotate({ action: { name: "auth.native.token.race_lost" } });
break;
}
return invalidCode();
}
// (5) Load the user (may have been deleted mid-flight).
const user = await prisma.user.findUnique({
where: { id: result.userId },
select: { id: true, username: true, onboardingCompletedAt: true },
});
if (!user) {
annotate({ action: { name: "auth.native.token.user_missing" } });
return invalidCode();
}
// (6) Mint the native bundle. Step (2) guarantees the cookie-less-native
// branch of `finishLogin` (24h access + 60d rotating refresh). `mfaVerified`
// is deliberately unset — the handoff login can never satisfy step-up.
const ua = request.headers.get("user-agent");
const response = await finishLogin({
user,
request,
ip,
userAgent: ua,
source: "login.web_handoff",
});
// (7) Stamp the issued refresh token's hash for replay-containment reach-back,
// then audit. Best-effort — the code is already single-use-consumed.
try {
const bundle = (await response.clone().json()) as {
data?: { refreshToken?: unknown };
};
const refreshToken = bundle.data?.refreshToken;
if (typeof refreshToken === "string") {
await stampIssuedRefreshToken(result.handoffId, refreshToken);
}
} catch {
// Non-fatal — see comment above.
}
await auditLog("auth.login", {
userId: user.id,
ipAddress: ip,
details: { transport: "native", method: "web_handoff" },
});
return response;
});
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.32.21 (R5a) — optimistic concurrency on `PUT /api/coach/about-me`
* (issue #581 family). The write guards on `UserHealthProfile.updatedAt`:
*
* - a stale base token → 409 `about_me_conflict`, no write;
* - a matched base token → conditional update, fresh token echoed;
* - a zero-match against a MISSING row → create wins (nothing to clobber);
* - a tokenless request → the prior unconditional upsert (iOS-compat arm).
*
* Mirrors the mock scaffolding of `route-module-gate.test.ts`.
*/
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
}));
const { requireModuleEnabled } = vi.hoisted(() => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled }));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number, meta?: unknown) => ({
data: null,
error,
status,
meta,
}),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
getClientIp: () => "127.0.0.1",
returnAllZodIssues: (_e: unknown, status: number) => ({
data: null,
error: "validation",
status,
}),
safeJson: async (req: Request) => ({ data: await req.json(), error: null }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
const { profile } = vi.hoisted(() => ({
profile: {
upsert: vi.fn(),
updateMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({ prisma: { userHealthProfile: profile } }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => Buffer.from(s),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextForUser: vi.fn(async () => ({
aboutMe: null,
conditions: null,
allergies: null,
coachFocus: null,
})),
getPendingQuestionsForUser: vi.fn(async () => []),
setPendingQuestionsForUser: vi.fn(),
}));
vi.mock("@/lib/ai/coach/self-context-questions", () => ({
deriveClarifyingQuestions: vi.fn(async () => ({
questions: [],
source: "none",
})),
}));
import { PUT } from "../route";
type Envelope = {
data: { updatedAt?: string } | null;
error: string | null;
status: number;
meta?: { errorCode?: string };
};
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
function putReq(baseUpdatedAt?: string): Request {
return new Request("http://localhost/api/coach/about-me", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
aboutMe: "I run daily",
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
}),
});
}
beforeEach(() => {
vi.clearAllMocks();
requireModuleEnabled.mockResolvedValue({ enabled: true });
});
describe("about-me — optimistic concurrency", () => {
it("guards on the base token and echoes the advanced token", async () => {
const base = new Date("2026-07-24T10:00:00.000Z");
const advanced = new Date("2026-07-24T10:05:00.000Z");
profile.updateMany.mockResolvedValue({ count: 1 });
profile.findUnique.mockResolvedValue({ updatedAt: advanced });
const res = await put(putReq(base.toISOString()));
expect(res.status).toBe(200);
// Conditional write on the exact base token — never the upsert.
expect(profile.updateMany).toHaveBeenCalledTimes(1);
const whereArg = profile.updateMany.mock.calls[0]?.[0]?.where as {
userId: string;
updatedAt: Date;
};
expect(whereArg.userId).toBe("u1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(base.toISOString());
expect(profile.upsert).not.toHaveBeenCalled();
expect(res.data?.updatedAt).toBe(advanced.toISOString());
});
it("a stale base token 409s and clobbers nothing (existing row advanced)", async () => {
profile.updateMany.mockResolvedValue({ count: 0 });
// The row exists but its token advanced → real conflict, not a delete.
profile.findUnique.mockResolvedValue({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
});
const res = await put(putReq("2026-07-24T09:00:00.000Z"));
expect(res.status).toBe(409);
expect(res.data).toBeNull();
expect(res.meta?.errorCode).toBe("about_me_conflict");
// No write happened: neither the upsert nor a create ran.
expect(profile.upsert).not.toHaveBeenCalled();
expect(profile.create).not.toHaveBeenCalled();
});
it("a zero-match against a MISSING row creates instead of 409ing", async () => {
profile.updateMany.mockResolvedValue({ count: 0 });
profile.findUnique.mockResolvedValue(null);
profile.create.mockResolvedValue({
updatedAt: new Date("2026-07-24T12:00:00.000Z"),
});
const res = await put(putReq("2026-07-24T09:00:00.000Z"));
expect(res.status).toBe(200);
expect(profile.create).toHaveBeenCalledTimes(1);
expect(res.data?.updatedAt).toBe("2026-07-24T12:00:00.000Z");
});
it("keeps the unconditional upsert when the token is omitted (iOS compat)", async () => {
profile.upsert.mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
});
const res = await put(putReq());
expect(res.status).toBe(200);
expect(profile.upsert).toHaveBeenCalledTimes(1);
expect(profile.updateMany).not.toHaveBeenCalled();
expect(res.data?.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
it("422s a malformed base token without touching the row", async () => {
const res = await put(putReq("not-a-date"));
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
expect(profile.updateMany).not.toHaveBeenCalled();
expect(profile.upsert).not.toHaveBeenCalled();
});
});
+60 -9
View File
@@ -35,6 +35,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { takeBaseToken, invalidBaseTokenError } from "@/lib/optimistic-lock";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
@@ -110,12 +111,21 @@ export const PUT = apiHandler(async (req: Request) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
const { data: rawBody, error: jsonError } = await safeJson(req, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = aboutMePutSchema.safeParse(body);
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse (issue #581 family). The token guards on
// `UserHealthProfile.updatedAt`, which — unlike the layout endpoints on the
// shared `User` row — moves only when THIS surface (or the `/adopt`
// sub-route) writes, so it is noise-free.
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = aboutMePutSchema.safeParse(taken.rest);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
@@ -165,12 +175,53 @@ export const PUT = apiHandler(async (req: Request) => {
: {}),
};
const row = await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
// v1.32.21 (R5a) — optimistic concurrency on `UserHealthProfile.updatedAt`.
// - no base token → today's upsert (backward-compat arm, byte-identical);
// - base present + row still carries it → conditional update, echo fresh;
// - base present + zero match → the row either vanished (deleted since the
// read → create wins, nothing to clobber) or advanced (real conflict →
// 409, no write).
let updatedAtIso: string;
if (base === undefined) {
const row = await prisma.userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, ...update },
update,
select: { updatedAt: true },
});
updatedAtIso = row.updatedAt.toISOString();
} else {
const guarded = await prisma.userHealthProfile.updateMany({
where: { userId: user.id, updatedAt: base },
data: update,
});
if (guarded.count === 0) {
const existing = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
if (existing) {
annotate({
action: { name: "coach.about_me.conflict" },
meta: { base_updated_at: base.toISOString() },
});
return apiError("Self-context changed since it was loaded", 409, {
errorCode: "about_me_conflict",
});
}
const created = await prisma.userHealthProfile.create({
data: { userId: user.id, ...update },
select: { updatedAt: true },
});
updatedAtIso = created.updatedAt.toISOString();
} else {
const fresh = await prisma.userHealthProfile.findUnique({
where: { userId: user.id },
select: { updatedAt: true },
});
updatedAtIso = (fresh?.updatedAt ?? new Date()).toISOString();
}
}
// Read back the effective state (covers omitted fields) and derive
// the clarifying questions. An entirely empty self-context clears
@@ -222,7 +273,7 @@ export const PUT = apiHandler(async (req: Request) => {
allergies: ctx.allergies,
coachFocus: ctx.coachFocus,
pendingQuestions,
updatedAt: row.updatedAt.toISOString(),
updatedAt: updatedAtIso,
maxChars: ABOUT_ME_MAX_CHARS,
fieldMaxChars: ABOUT_ME_FIELD_MAX_CHARS,
});
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
// v1.32.16 (issue #581) — the guarded optimistic-concurrency write.
updateMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
@@ -741,3 +743,101 @@ describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layo
expect(body.data.widgets[0].visible).toBe(false);
});
});
/**
* v1.32.16 — optimistic concurrency (issue #581). A Save that carries the
* `baseUpdatedAt` it was made against writes CONDITIONALLY on the stored row
* still carrying that token, so a committed Save can never be silently
* reverted by a later request that started from an older snapshot. A full
* layout with every top-level field present skips the preserve-read, so the
* only `findUnique` here is the post-write token read.
*/
describe("dashboard widgets — optimistic concurrency (issue #581)", () => {
// A full layout carrying every preserved field so `needsPreserveRead`
// is false and the route takes the guarded write path directly.
function fullLayoutBody(baseUpdatedAt?: string) {
return {
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
comparisonBaseline: "none",
chartOverlayPrefs: {},
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
};
}
it("guards the write on the base token and returns the advanced token", async () => {
const base = new Date("2026-07-24T10:00:00.000Z");
const advanced = new Date("2026-07-24T10:05:00.000Z");
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
// The post-write token read.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
updatedAt: advanced,
} as never);
const res = await callPut(makeReq(fullLayoutBody(base.toISOString())));
expect(res.status).toBe(200);
// Wrote conditionally on the exact base token — never unconditionally.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(base.toISOString());
expect(prisma.user.update).not.toHaveBeenCalled();
// The response echoes the advanced token so the client can rebase.
const body = (await res.json()) as { data: { updatedAt: string } };
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
it("rejects a stale base token with 409 and writes nothing", async () => {
// The guarded update matches zero rows — someone advanced updatedAt.
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await callPut(
makeReq(fullLayoutBody("2026-07-24T09:00:00.000Z")),
);
expect(res.status).toBe(409);
const body = (await res.json()) as {
data: null;
error: string;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("dashboard_layout_conflict");
// The conditional write matched no row; there is NO unconditional
// fallback, so the stored layout is left exactly as it was.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await callPut(makeReq(fullLayoutBody("not-a-date")));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the client omits the token", async () => {
// Backward-compatible path for older web builds / the native client.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await callPut(makeReq(fullLayoutBody()));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
const body = (await res.json()) as { data: { updatedAt: string } };
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
});
+57 -10
View File
@@ -15,6 +15,11 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
resolveDashboardLayout,
serializeDashboardLayout,
@@ -29,6 +34,7 @@ import {
layoutNeedsPreserveRead,
mergePreservedLayoutFields,
type DashboardLayout,
type DashboardLayoutWithToken,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -150,14 +156,25 @@ const layoutSchema = z.object({
.array(z.enum(HERO_RING_IDS))
.max(MAX_HERO_RING_ORDER)
.optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
// field, and pulling it pre-Zod keeps the domain schema pure.
});
async function buildDashboardLayout(userId: string): Promise<DashboardLayout> {
async function buildDashboardLayout(
userId: string,
): Promise<DashboardLayoutWithToken> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { dashboardWidgetsJson: true },
// v1.32.16 (issue #581) — `updatedAt` rides the cached layout so GET
// hands the client the optimistic-concurrency token alongside the
// layout it applies to. Cached together (and busted on every write)
// so the (layout, token) pair a client reads is always consistent.
select: { dashboardWidgetsJson: true, updatedAt: true },
});
return resolveDashboardLayout(row?.dashboardWidgetsJson);
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
return { ...layout, updatedAt: row?.updatedAt?.toISOString() };
}
export const GET = apiHandler(async () => {
@@ -167,7 +184,7 @@ export const GET = apiHandler(async () => {
// hits the Settings → Dashboard save button, which invalidates this
// cache via `invalidateUserDashboardWidgets()`.
const layout = await cached(
caches.dashboardWidgets as ServerCache<DashboardLayout>,
caches.dashboardWidgets as ServerCache<DashboardLayoutWithToken>,
user.id,
() => buildDashboardLayout(user.id),
annotate,
@@ -178,11 +195,20 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE anything else touches it (issue #581). A malformed token 422s
// without a write; an absent token takes the unconditional backward-compat
// arm inside `guardedUserUpdate` below.
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const body = taken.rest;
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
// on write. The 27 ids (16 web + 11 iOS-only) all validate + persist;
// only a genuinely-unknown id (a retired tile, or a typo) is filtered
@@ -310,6 +336,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
// serializer fills the gap, so the assertion is safe here exactly as it was
// on the previous per-field cast.
// v1.32.16 (issue #581) — the concurrency token was already split off the
// body pre-Zod (`takeBaseToken`), so `parsed.data` is pure layout and can
// never leak the token into the stored blob.
let toSerialize = parsed.data as Partial<DashboardLayout>;
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
const needsRangePointsPreserve =
@@ -358,12 +387,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
await prisma.user.update({
where: { id: user.id },
data: {
dashboardWidgetsJson: toJson(normalized),
// v1.32.16 (issue #581) / v1.32.21 (R5a) — optimistic concurrency through
// the shared `guardedUserUpdate` helper. When the client sends the
// `baseUpdatedAt` it read the layout at, the write is CONDITIONAL on the
// stored row still carrying that token, so a Save that already committed
// can never be silently reverted by a later request (e.g. an instant
// hero-ring PUT) that started from the pre-Save snapshot. A stale base
// returns 409 and writes NOTHING. Clients that omit the token (older web
// builds, the native client) keep the prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { dashboardWidgetsJson: toJson(normalized) },
conflict: {
action: "dashboard.widgets.conflict",
errorCode: "dashboard_layout_conflict",
message: "Dashboard layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
const freshUpdatedAt = guarded.updatedAt;
annotate({
action: { name: "dashboard.widgets.update" },
@@ -374,7 +417,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// next dashboard mount paints the new layout.
invalidateUserDashboardWidgets(user.id);
return apiSuccess(normalized);
// Echo the advanced token so the client can base its next write on it.
return apiSuccess({
...normalized,
updatedAt: freshUpdatedAt,
} satisfies DashboardLayoutWithToken);
});
export const DELETE = apiHandler(async () => {
@@ -371,12 +371,42 @@ describe("POST — numeric grounding", () => {
.join("");
// 999 is not in the document (which says 160) → soft-stripped.
expect(reply).not.toContain("999");
expect(reply).toContain("[unverified]");
expect(reply).toContain("[…]");
// The persisted assistant turn carries the corrected prose.
const assistantWrite = vi
.mocked(appendMessage)
.mock.calls.find((c) => c[0].role === "assistant");
expect(assistantWrite?.[0].content).toContain("[unverified]");
expect(assistantWrite?.[0].content).toContain("[…]");
// v1.32.14 — the fenced path rides a MINIMAL provenance envelope so the
// per-message notice renders + survives reload: count only, no windows/metrics.
expect(assistantWrite?.[0].metricSource).toEqual({
windows: [],
metrics: [],
unverifiedFigures: 1,
});
});
it("persists no provenance when the reply is clean", async () => {
vi.mocked(runStreamingRawCompletionWithFallback).mockResolvedValue({
result: {
content: "Your LDL reading looks steady overall.",
tokensUsed: 20,
model: "claude",
providerType: "anthropic",
},
workingProvider: { providerType: "anthropic", instance: {} },
fallbackHops: [],
} as never);
const res = await POST(
req("doc-1", { message: "how is my ldl?" }) as never,
ctx("doc-1") as never,
);
await frames(res);
const assistantWrite = vi
.mocked(appendMessage)
.mock.calls.find((c) => c[0].role === "assistant");
// v1.32.14 — clean reply → metricSourceJson stays null (no notice on reload).
expect(assistantWrite?.[0].metricSource ?? null).toBeNull();
});
});
+1 -1
View File
@@ -58,6 +58,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const imported = await syncUserFitbit(user.id, { fullSync });
const { imported } = await syncUserFitbit(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});
@@ -0,0 +1,355 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.32.9 (Coach Guard II / G2) — the Grounding Ledger is CROSS-TURN, so its
* two load-bearing properties can only be seen at the route level, over a
* conversation with prior turns (the #591 lesson — the pure module cannot see
* the route's activation + cross-turn assembly):
*
* - a prior-turn TOOL figure (persisted as `groundedFigures`) recalled this
* turn reconciles and is NOT stripped;
* - a number that appears only in a prior ASSISTANT REPLY (never a tool figure)
* does NOT ground the next turn — assistant prose is never a ledger source
* (D3 / red-team H3). It is still stripped.
*
* Guards are REAL here (coach-prose-grounding + grounding-ledger unmocked); the
* provider loop + persistence are stubbed, exactly like route-tool-mode.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(),
}));
vi.mock("@/lib/logging/redact", () => ({
redactSecrets: (s: string) => s,
redactOptional: (s: unknown) => s,
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
},
}));
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
runStreamingRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runStreamingRawCompletionWithFallback,
}));
const { resolveProviderChain } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider: vi.fn(),
}));
const { assertConsentForChain } = vi.hoisted(() => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
}));
vi.mock("@/lib/ai/coach/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
"@/lib/ai/coach/types",
);
return actual;
});
const { fetchConversationWithMessages } = vi.hoisted(() => ({
fetchConversationWithMessages: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages,
listConversations: vi.fn(),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({
storeDeterministicFacts: vi.fn(async () => undefined),
}));
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
// unit-tested in the outbound-screen suite.
vi.mock("@/lib/medications/scheduled-doses", () => ({
getScheduledDoseValues: vi.fn(async () => []),
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-23"),
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
}));
const { detectRefusal } = vi.hoisted(() => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
screenCoachReply: vi.fn(() => ({ block: false })),
coachOutboundFallback: vi.fn(() => "fallback"),
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: vi.fn(async () => ({
snapshotJson: SNAPSHOT_JSON,
sections: { bloodPressure: { aggregate: { mean: 128 } } },
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: "REFERENCE RANGES",
})),
}));
vi.mock("@/lib/workouts/hr-series", () => ({
buildWorkoutHrSeries: vi.fn(async () => null),
}));
vi.mock("@/lib/workouts/zones", () => ({
computeZones: vi.fn(() => null),
hrMaxFromAge: vi.fn(() => 185),
parseWhoopZoneDurations: vi.fn(() => null),
}));
vi.mock("@/lib/workouts/sport-context", () => ({
buildSportContext: vi.fn(async () => null),
}));
const {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
runCoachToolLoop,
} = vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
probeScope: { sources: ["bp"], window: "last30days" },
})),
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
renderFocusHint: vi.fn(() => ""),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 3,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
const { parseSuggestReminder } = vi.hoisted(() => ({
parseSuggestReminder: vi.fn(),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
vi.mock("@/lib/validations/coach-prefs", () => ({
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
sse.done = Promise.resolve(
producer({ signal: { aborted: false }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** Force the model's assembled prose through the sentinel/suggest parsers. */
function stubReply(prose: string, toolResults: unknown[]): void {
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults,
}));
}
function assistantContent(): string {
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (assistant?.[0] as { content: string }).content;
}
function assistantProvenance(): Record<string, unknown> | null | undefined {
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (
assistant?.[0] as {
metricSource?: Record<string, unknown> | null;
}
).metricSource;
}
describe("coach chat — cross-turn Grounding Ledger (G2)", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
});
it("recalls a PRIOR-turn tool figure (persisted groundedFigures) without stripping it", async () => {
// Turn 1 fetched systolic 128 and persisted it as groundedFigures.
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [
{ role: "user", content: "How is my BP?" },
{
role: "assistant",
content: "Your systolic averaged 128.",
metricSource: { groundedFigures: [128] },
},
],
});
// This turn: a FRESH sleep tool activates the verifier; the model recalls
// the prior 128 AND cites the fresh 440.
stubReply(
"Your sleep averaged 440 minutes, and your systolic 128 average still holds.",
[{ present: true, data: { metric: "sleep", aggregate: { mean: 440 } } }],
);
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
await sse.done;
const content = assistantContent();
expect(content).toContain("128");
expect(content).toContain("440");
expect(content).not.toContain("[unverified]");
expect(content).not.toContain("[…]");
// v1.32.14 — a fully grounded reply carries NO unverifiedFigures key.
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
});
it("does NOT let a number from a prior ASSISTANT REPLY ground the next turn (D3 / H3)", async () => {
// The prior assistant reply contains 158, but the persisted tool figure was
// 128 — 158 was never a tool figure (a rung leak / fabrication). The ledger
// must not read the assistant prose, so 158 stays ungrounded and is stripped.
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [
{ role: "user", content: "How is my BP?" },
{
role: "assistant",
content: "Your systolic spike to 158 mmHg is worth watching.",
metricSource: { groundedFigures: [128] },
},
],
});
// This turn: a fresh BP tool returns the real 128; the model repeats the
// fabricated 158.
stubReply("Your systolic spike to 158 mmHg persists this week.", [
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
]);
await post(chatReq({ conversationId: "c1", message: "still high?" }));
await sse.done;
const content = assistantContent();
expect(content).not.toContain("158");
// v1.32.14 — the withheld figure reads as the editorial elision mark, never
// the old internal QA literal, and the count rides the provenance envelope.
expect(content).toContain("[…]");
expect(content).not.toContain("[unverified]");
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
});
});
@@ -0,0 +1,333 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.32.7 (Coach Guard I / G1) — route-level SSE assertions on the ASSEMBLED
* reply, the class the pure-module tests cannot see (the #591 lesson: the unit
* suite was green while the route regressed, because the guard runs inside
* `route.ts`). Unlike `route-tool-mode.test.ts`, this file uses the REAL
* outbound guard and the REAL numeric verifier — only the provider + IO are
* stubbed — so it exercises the guard exactly as production does, on both a
* tool turn and a no-tool turn.
*
* It asserts on the persisted assistant `content`, which is the fully-guarded
* text streamed to the client (every guard runs before the first token frame).
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
workout: { findFirst: vi.fn(async () => null) },
},
}));
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
runStreamingRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runStreamingRawCompletionWithFallback,
}));
const { resolveProviderChain } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider: vi.fn(),
}));
const { assertConsentForChain } = vi.hoisted(() => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
}));
vi.mock("@/lib/ai/coach/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
"@/lib/ai/coach/types",
);
return actual;
});
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages: vi.fn(),
listConversations: vi.fn(),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({
storeDeterministicFacts: vi.fn(async () => undefined),
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-21"),
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
}));
const { detectRefusal } = vi.hoisted(() => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
// NB: the outbound guard + numeric verifier are REAL — that is the point.
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: vi.fn(async () => ({
snapshotJson: SNAPSHOT_JSON,
sections: { bloodPressure: { aggregate: { mean: 128 } } },
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: "REFERENCE RANGES",
})),
}));
vi.mock("@/lib/workouts/hr-series", () => ({
buildWorkoutHrSeries: vi.fn(async () => null),
}));
vi.mock("@/lib/workouts/zones", () => ({
computeZones: vi.fn(() => null),
hrMaxFromAge: vi.fn(() => 185),
parseWhoopZoneDurations: vi.fn(() => null),
}));
vi.mock("@/lib/workouts/sport-context", () => ({
buildSportContext: vi.fn(async () => null),
}));
const { runCoachToolLoop } = vi.hoisted(() => ({
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
probeScope: { sources: ["bp"], window: "last30days" },
})),
renderDataInventory: vi.fn(() => "DATA INVENTORY"),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
const { parseSuggestReminder } = vi.hoisted(() => ({
parseSuggestReminder: vi.fn(),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
vi.mock("@/lib/validations/coach-prefs", () => ({
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
sse.done = Promise.resolve(
producer({ signal: { aborted: false }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
import { COACH_OUTBOUND_RISK_BLOCK_EN } from "@/lib/ai/coach/outbound-guard";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** The reply the model "returned" flows through the sentinel parsers verbatim. */
function echo(content: string): void {
parseKeyValuesSentinel.mockReturnValue({
prose: content,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose: content });
}
/** Drive a TOOL turn returning `content` with the given tool payloads. */
async function toolTurn(
content: string,
toolResults: Array<{ present: boolean; data: unknown }> = [],
): Promise<string> {
echo(content);
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
runCoachToolLoop.mockResolvedValue({
result: { content, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults,
});
const res = await post(chatReq({ message: "How is my BP?" }));
await sse.done;
expect(res.status).toBe(200);
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (assistant?.[0] as { content: string }).content;
}
/** Drive a NO-TOOL turn (a provider lacking tools) returning `content`. */
async function noToolTurn(content: string): Promise<string> {
echo(content);
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
{ providerType: "local", instance: { supportsTools: false } },
]);
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content, tokensUsed: 42, model: "m" },
workingProvider: { providerType: "anthropic" },
});
const res = await post(chatReq({ message: "How is my BP?" }));
await sse.done;
expect(res.status).toBe(200);
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (assistant?.[0] as { content: string }).content;
}
describe("coach chat — Guard I at the route level (G1)", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
});
it("replaces a fabricated risk assertion end-to-end (tool turn)", async () => {
const content =
"Based on SCORE2, your risk sits at about 14% given your numbers.";
const out = await toolTurn(content, [
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
]);
expect(out).toBe(COACH_OUTBOUND_RISK_BLOCK_EN);
expect(out).not.toContain("14%");
});
it("replaces a fabricated risk assertion end-to-end (no-tool turn)", async () => {
const out = await noToolTurn(
"Your ten-year cardiovascular risk is roughly twelve percent.",
);
expect(out).toBe(COACH_OUTBOUND_RISK_BLOCK_EN);
expect(out).not.toContain("twelve percent");
});
it("streams a model-perfect refusal intact — it is not blocked or mangled (tool turn)", async () => {
const content =
"I can't calculate a 10-year cardiovascular risk for you — an ASCVD score is something your clinician computes with lab values I don't have.";
const out = await toolTurn(content, [
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
]);
expect(out).toBe(content);
expect(out).not.toContain("[…]");
});
it("streams dates, ranges and thousands unmangled when they are grounded (tool turn)", async () => {
const content =
"On 2026-07-23 your systolic sat between 120 and 135 mmHg, with about 10,000 steps logged.";
const out = await toolTurn(content, [
{
present: true,
data: { metric: "bp", section: { aggregate: { min: 121, max: 134 } } },
},
{
present: true,
data: { metric: "steps", section: { aggregate: { latest: 10000 } } },
},
]);
expect(out).toBe(content);
expect(out).not.toContain("[…]");
});
it("soft-strips a genuinely fabricated figure on a tool turn (the floor still holds)", async () => {
const content = "Your systolic averaged about 138 recently.";
const out = await toolTurn(content, [
{
present: true,
data: { metric: "bp", section: { aggregate: { avgSys30: 128 } } },
},
]);
expect(out).toContain("[…]");
expect(out).not.toContain("138");
});
});
@@ -405,7 +405,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
(c) => (c[0] as { role: string }).role === "assistant",
);
const content = (assistantCall?.[0] as { content: string }).content;
expect(content).toContain("[unverified]");
expect(content).toContain("[…]");
expect(content).not.toContain("138");
});
@@ -474,7 +474,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
(c) => (c[0] as { role: string }).role === "assistant",
);
const content = (assistantCall?.[0] as { content: string }).content;
expect(content).not.toContain("[unverified]");
expect(content).not.toContain("[…]");
expect(content).toContain("42");
expect(content).toContain("320");
});
@@ -534,7 +534,7 @@ describe("coach chat — tool-mode routing (F1)", () => {
(c) => (c[0] as { role: string }).role === "assistant",
);
const content = (assistantCall?.[0] as { content: string }).content;
expect(content).not.toContain("[unverified]");
expect(content).not.toContain("[…]");
expect(content).toContain("130");
});
+102 -23
View File
@@ -103,9 +103,14 @@ import {
} from "@/lib/ai/coach/tools";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import {
findUnverifiedCoachNumbers,
findUnverifiedCoachNumbersInLedger,
stripUnverifiedNumbers,
} from "@/lib/ai/coach/coach-prose-grounding";
import {
buildGroundingLedger,
figuresForPersistence,
} from "@/lib/ai/coach/grounding-ledger";
import { getScheduledDoseValues } from "@/lib/medications/scheduled-doses";
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
@@ -270,6 +275,14 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// ── Conversation resolution ──────────────────────────────────
let workingConversationId: string;
let priorTurns: CoachTurn[] = [];
// v1.32.9 (Coach Guard II / G2) — cross-turn Grounding Ledger sources. Prior
// USER messages (numbers the user themselves stated) and the persisted
// per-turn tool figures of prior turns (`groundedFigures` — server-computed
// magnitudes, NEVER the assistant's prose, per D3). Both feed reconciliation
// so a figure recalled a turn later is not stripped, without a laundering
// path through assistant narration.
let priorUserMessages: string[] = [];
let priorToolFigures: number[] = [];
// v1.11.1 — rolling summary of the elided older turns, read stale-while-
// revalidate; null for a fresh conversation or when none is on file.
let priorSummary: string | null = null;
@@ -316,6 +329,16 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
role: m.role,
content: m.content,
}));
// Ledger cross-turn sources (D3-safe): user-authored numbers + the
// persisted tool figures of prior turns. Assistant PROSE is never read.
priorUserMessages = existing.messages
.filter((m) => m.role === "user")
.map((m) => m.content);
priorToolFigures = existing.messages.flatMap((m) =>
m.metricSource?.groundedFigures
? [...m.metricSource.groundedFigures]
: [],
);
// v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs
// only on the inbound `message` per turn, so an injection that
@@ -409,6 +432,11 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// v1.16.0 — composed self-context: structured questionnaire fields
// plus age/gender merged in from the User profile.
const aboutMe = await getSelfContextTextForUser(userId, locale);
// v1.32.9 (Coach Guard II / G3) — the user's active medication doses, for the
// Grounding Ledger's `schedule` source AND the schedule-gated dose
// continuation exemption. Fail-open: a read failure yields an empty set, which
// keeps the Guard I phrase-anchored dose behaviour.
const scheduleDoses = await getScheduledDoseValues(userId).catch(() => []);
// v1.22 (B2/F6) — the canonical system-prompt module is owned elsewhere, so
// the two memory/action clauses are appended at assembly time here: one
// teaches the model to emit `---REMEMBER---` (durable "remind me" capture),
@@ -890,7 +918,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// the system-prompt GLP-1/grounding contracts. On a trip the turn is
// replaced with a calm, grounded fallback and any reminder suggestion /
// key-value provenance is dropped — the user never sees the unsafe text.
const outbound = screenCoachReply(replyText, locale);
const outbound = screenCoachReply(replyText, locale, scheduleDoses);
if (outbound.block && outbound.reason) {
replyText = coachOutboundFallback(outbound.reason, locale);
annotate({
@@ -909,7 +937,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. Cross-
// check every number the model cited against this turn's authoritative figure
// set; an unmatched number (transcription / paraphrase drift) is soft-stripped
// to "[unverified]" and annotated. Cheap, non-blocking, and a no-op when there
// to the editorial elision mark `[…]` and annotated (v1.32.14 — the withheld
// count also rides the provenance envelope to drive the per-message notice).
// Cheap, non-blocking, and a no-op when there
// is no authoritative set — the prompt-level grounding rule remains the
// backstop, exactly like the briefing's "no signals → skip". A blocked turn
// already carries canned fallback prose, so skip it.
@@ -919,31 +949,67 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// (`snapshot.sections`, which already carries the correlations-snapshot block).
// Exactly one is populated per turn; the grading, tolerance, and exemptions are
// identical, so a number the model invents is flagged the same way on both.
const authoritativePayloads =
// v1.32.9 (Coach Guard II / G2+G3) — grade the prose against the typed
// Grounding Ledger rather than a per-turn magnitude bag. Activation is
// unchanged from Guard I: the ledger is graded ONLY when THIS turn delivered
// fresh figures the model was told to ground against (a present tool result
// / pinned workout, or the full snapshot on the no-tools path). The
// cross-turn, memory, schedule, reference, and guided sources only WIDEN an
// active grading — they never ACTIVATE it, so a snapshot figure the model
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
// holds). Assistant prose is never a ledger source (D3).
const activatingPayloads =
toolResultPayloads.length > 0
? toolResultPayloads
: noToolsSnapshotPayloads;
if (!outbound.block && authoritativePayloads.length > 0) {
const unverified = findUnverifiedCoachNumbers(
replyText,
authoritativePayloads,
);
if (unverified.length > 0) {
const { prose: corrected, stripped } = stripUnverifiedNumbers(
let groundedFigures: number[] = [];
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
// elision mark). Hoisted out of the guard block so it can ride the provenance
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
// (the guard is skipped, its fallback prose carries no figures).
let unverifiedStripped = 0;
if (activatingPayloads.length > 0) {
const ledger = buildGroundingLedger({
toolPayloads: activatingPayloads,
priorToolFigures,
priorUserMessages,
// The self-context (goals / about-me) rides the system prompt on both
// paths — the ledger's `memory` source.
memoryTexts: aboutMe ? [aboutMe] : [],
scheduleDoses,
// Reference grounding rides the prompt only on the no-tools
// full-snapshot path; the guided block rides both paths.
referenceGrounding:
!toolMode && includeFullSnapshot ? snapshot.referenceGrounding : null,
guidedBlock: turnContext.guidedBlock,
});
// Persist THIS turn's tool figures (bare magnitudes) so a LATER turn can
// recall them via the ledger — D3: the tool trace, never the prose.
groundedFigures = figuresForPersistence(ledger);
if (!outbound.block) {
const unverified = findUnverifiedCoachNumbersInLedger(
replyText,
unverified,
ledger,
locale,
);
replyText = corrected;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
flagged: unverified.length,
stripped,
// No raw values — just the count + truncated tokens for ops triage.
tokens: unverified.slice(0, 6).map((u) => u.source),
promptVersion: PROMPT_VERSION,
},
});
if (unverified.length > 0) {
const { prose: corrected, stripped } = stripUnverifiedNumbers(
replyText,
unverified,
);
replyText = corrected;
unverifiedStripped = stripped;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
flagged: unverified.length,
stripped,
// No raw values — just the count + truncated tokens for ops triage.
tokens: unverified.slice(0, 6).map((u) => u.source),
promptVersion: PROMPT_VERSION,
},
});
}
}
}
@@ -1048,6 +1114,19 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
})),
}
: {}),
// v1.32.9 (Coach Guard II / G2) — persist THIS turn's tool figures so a
// later turn's Grounding Ledger can recall them (D3-safe: tool trace, not
// prose). Dropped on a blocked turn (its figures rode the discarded reply).
...(!outbound.block && groundedFigures.length > 0
? { groundedFigures }
: {}),
// v1.32.14 — the count of figures the grounding guard withheld this turn,
// so the quiet "some figures couldn't be checked" notice renders under the
// bubble and survives a conversation reload. Count only, no values. Omitted
// when nothing was withheld.
...(unverifiedStripped > 0
? { unverifiedFigures: unverifiedStripped }
: {}),
};
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
@@ -93,6 +93,7 @@ import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { getMedicationCategories } from "@/lib/medication-category";
import { __resetAllCachesForTests, caches } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -157,6 +158,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
@@ -229,6 +231,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
},
},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 1,
@@ -285,6 +288,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
sys: [{ measuredAt: at, value: 125 }],
dia: [{ measuredAt: at, value: 75 }],
},
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: at,
totalMeasurements: 2,
@@ -324,6 +328,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
sys: [{ measuredAt: new Date("2026-05-14T21:50:00Z"), value: 125 }],
dia: [{ measuredAt: new Date("2026-05-14T22:10:00Z"), value: 75 }],
},
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date("2026-05-14T21:50:00Z"),
totalMeasurements: 2,
@@ -350,6 +355,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
@@ -409,6 +415,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
@@ -456,6 +463,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
@@ -491,6 +499,7 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
const EMPTY_AGGREGATE = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: null,
totalMeasurements: 0,
@@ -543,3 +552,278 @@ describe("GET /api/insights/comprehensive — stale-while-revalidate marker", ()
expect(freshBody.data.revalidating).toBe(false);
});
});
// v1.32.17 (R1) — mood × metric correlations must pair on the LOCAL
// logging day. The mood series has been local-day-keyed since v1.32.12;
// the three metric partners (sys BP, weight, resting pulse) were still
// UTC-keyed, so for non-UTC users an evening reading landed a day off and
// paired with the wrong mood entry. These tests run under CI's TZ=UTC and
// carry tz-explicit fixtures so the assertions are host-TZ-independent.
describe("GET /api/insights/comprehensive — mood × metric local-day pairing (v1.32.17)", () => {
type MoodBody = {
data: {
moodBpScatterData: Array<{ mood: number; sysBP: number }>;
moodWeightScatterData: Array<{ mood: number; weight: number }>;
moodPulseScatterData: Array<{ mood: number; pulse: number }>;
scatterData: Array<{ weight: number; sysBP: number }>;
weightBpCorrelation: unknown;
bpMedicationCorrelation: unknown;
};
};
function moodRollupDay(midnightZ: string, mean: number, count = 1) {
return {
userId: "user-comp-1",
granularity: "DAY",
bucketStart: new Date(midnightZ),
count,
mean,
minScore: mean,
maxScore: mean,
sd: null,
computedAt: new Date(),
};
}
function sessionWithTz(tz: string) {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
}
it("Berlin: a mood + BP logged the same local evening now pair (previously dropped)", async () => {
sessionWithTz("Europe/Berlin");
// Mood logged 00:30 local on 2026-07-10 → rollup bucketStart is the
// canonical local day at UTC midnight.
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
// 22:35Z = 00:35 Berlin on Jul 10 → local day 2026-07-10, but UTC
// day 2026-07-09. The old UTC `dailyByType` key (below) would never
// match the mood's local 2026-07-10 → the pair was dropped.
bpRawRows: {
sys: [{ measuredAt: new Date("2026-07-09T22:35:00Z"), value: 128 }],
dia: [],
},
weightRawRows: [],
dailyByType: {
BLOOD_PRESSURE_SYS: [{ day: "2026-07-09", value: 128 }],
},
firstMeasurementAt: new Date("2026-07-09T22:35:00Z"),
totalMeasurements: 1,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
// Derived from the raw row in the user's tz → pairs on local 2026-07-10.
expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 128 }]);
});
it("New York: the lag-1 offset is gone — each pair couples mood(D) with the metric logged local day D", async () => {
sessionWithTz("America/New_York");
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
moodRollupDay("2026-07-10T00:00:00.000Z", 2),
moodRollupDay("2026-07-11T00:00:00.000Z", 3),
moodRollupDay("2026-07-12T00:00:00.000Z", 4),
]);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
// Each sys row is at (D+1)T01:00Z = 21:00 local on day D (EDT UTC-4).
// Old UTC code keyed each on D+1 and paired it with mood(D+1) — a
// lag-1 correlation sold as same-day. Local derivation keys on D.
bpRawRows: {
sys: [
{ measuredAt: new Date("2026-07-11T01:00:00Z"), value: 120 },
{ measuredAt: new Date("2026-07-12T01:00:00Z"), value: 130 },
{ measuredAt: new Date("2026-07-13T01:00:00Z"), value: 140 },
],
dia: [],
},
weightRawRows: [],
dailyByType: {},
firstMeasurementAt: new Date("2026-07-11T01:00:00Z"),
totalMeasurements: 3,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
// mood(07-10)=2 ↔ 120 (logged local 07-10), not 130 (the old +1 offset).
expect(body.data.moodBpScatterData).toEqual([
{ mood: 2, sysBP: 120 },
{ mood: 3, sysBP: 130 },
{ mood: 4, sysBP: 140 },
]);
});
it("Berlin: mood × weight and mood × resting pulse also pair on the local day (all three partners moved)", async () => {
sessionWithTz("Europe/Berlin");
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
// Resting-HR row at 00:30 local Jul 10 (= 22:30Z Jul 9).
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 58 }],
);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
bpRawRows: { sys: [], dia: [] },
// Weight row at 00:30 local Jul 10 (= 22:30Z Jul 9).
weightRawRows: [
{ measuredAt: new Date("2026-07-09T22:30:00Z"), value: 80.5 },
],
dailyByType: {},
firstMeasurementAt: new Date("2026-07-09T22:30:00Z"),
totalMeasurements: 2,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
expect(body.data.moodWeightScatterData).toEqual([
{ mood: 4, weight: 80.5 },
]);
expect(body.data.moodPulseScatterData).toEqual([{ mood: 4, pulse: 58 }]);
});
it("UTC user: pairing is byte-identical to the pre-fix UTC-slice join (invariance)", async () => {
sessionWithTz("UTC");
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([moodRollupDay("2026-07-10T00:00:00.000Z", 4)]);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
{
summaries: {},
// Under UTC, `userDayKey` degenerates to the ISO slice, so the raw
// derivation reproduces exactly what the old `dailyByType` join gave.
bpRawRows: {
sys: [{ measuredAt: new Date("2026-07-10T08:00:00Z"), value: 122 }],
dia: [],
},
weightRawRows: [],
dailyByType: {
BLOOD_PRESSURE_SYS: [{ day: "2026-07-10", value: 122 }],
},
firstMeasurementAt: new Date("2026-07-10T08:00:00Z"),
totalMeasurements: 1,
},
);
const res = await callGet(makeReq());
const body = (await res.json()) as MoodBody;
expect(body.data.moodBpScatterData).toEqual([{ mood: 4, sysBP: 122 }]);
});
it("metric × metric guard: weight × BP and BP-med continuity are byte-identical across timezones (UTC joins untouched)", async () => {
// R1 must NOT re-key the two internally-consistent UTC joins
// (weight × BP, BP-med continuity) — those migrate with the rollup
// tier in R12. Both read the UTC `dailyByType` / UTC `scheduledFor`
// slice, so their output must be invariant to the caller's timezone.
// Run the SAME fixture under UTC then America/New_York and deep-equal.
const days = [
"2026-05-01",
"2026-05-02",
"2026-05-03",
"2026-05-04",
"2026-05-05",
];
const aggregate = {
summaries: {},
bpRawRows: { sys: [], dia: [] },
weightRawRows: [],
dailyByType: {
WEIGHT: days.map((d, i) => ({ day: d, value: 80 + i })),
BLOOD_PRESSURE_SYS: days.map((d, i) => ({
day: d,
value: 120 + i * 2,
})),
},
firstMeasurementAt: new Date(),
totalMeasurements: 10,
};
const medication = {
id: "med-bp",
name: "Amlodipine",
dose: "5mg",
active: true,
asNeeded: false,
createdAt: new Date("2026-04-01T00:00:00Z"),
schedules: [
{
id: "sch-1",
windowStart: "07:00",
windowEnd: "08:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: [],
},
],
scheduleRevisions: [],
pauseEras: [],
};
// Intake taken on the first three days, missed on the last two → a
// non-degenerate continuity series.
const intakeEvents = days.slice(0, 3).map((d) => ({
medicationId: "med-bp",
userId: "user-comp-1",
deletedAt: null,
scheduledFor: new Date(`${d}T07:00:00Z`),
takenAt: new Date(`${d}T07:05:00Z`),
skipped: false,
}));
function primeMocks() {
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue(aggregate);
(
prisma.medication.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([medication]);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue(intakeEvents);
vi.mocked(getMedicationCategories).mockResolvedValue({
"med-bp": "BLOOD_PRESSURE",
});
}
sessionWithTz("UTC");
primeMocks();
const utcBody = (await (await callGet(makeReq())).json()) as MoodBody;
// Fresh cache so the second run recomputes rather than serving the
// cached UTC body under the same user id.
__resetAllCachesForTests();
sessionWithTz("America/New_York");
primeMocks();
const nyBody = (await (await callGet(makeReq())).json()) as MoodBody;
expect(nyBody.data.scatterData).toEqual(utcBody.data.scatterData);
expect(nyBody.data.weightBpCorrelation).toEqual(
utcBody.data.weightBpCorrelation,
);
expect(nyBody.data.bpMedicationCorrelation).toEqual(
utcBody.data.bpMedicationCorrelation,
);
// And the guard is meaningful only if these joins actually produced
// output to compare.
expect(utcBody.data.scatterData).toHaveLength(5);
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
});
});
+92 -39
View File
@@ -36,6 +36,7 @@ import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { userDayKey } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -136,13 +137,17 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// warm-up so the next request lands on the rollup tier. Same posture
// as `/api/mood/analytics`.
//
// The bucket key shape change is documented in the route-parity test
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
// (write-time). For Berlin tenants whose mood log timestamps don't
// straddle the UTC boundary the two labels agree on every realistic
// entry — the v1.5 per-user-tz bucketing closes the residual DST
// edge.
// Since v1.32.12 the mood rollup writer mints `bucketStart` from the
// canonical local `MoodEntry.date` label at UTC midnight, so
// `.toISOString().slice(0, 10)` recovers the user's LOCAL calendar day —
// byte-identical to the live fallback's `entry.date`. Both mood paths
// therefore emit local-day keys. The mood × metric pairing partners
// below (sys BP, weight, resting pulse) are consequently derived in the
// user's tz (v1.32.17) so both join sides share ONE local-day key space.
// The measurement-rollup tier itself still buckets UTC (`dailyByType`)
// and migrates to local in a documented follow-up release; the two
// internally-consistent UTC joins (weight × BP, BP-med continuity) are
// left on it deliberately (see their call sites below).
void ensureUserMoodRollupsFresh(userId);
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
let dailyMoodEntries: Array<{ day: string; value: number }>;
@@ -247,6 +252,13 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// 24-hour default tolerance — daily-mean buckets are the
// SQL-side equivalent and the directive explicitly accepts the
// small semantic shift to bound the raw-row footprint).
//
// v1.32.17 — this pair stays on the UTC `dailyByType` series: BOTH
// sides come from `dailyByType` (weight × sys), so they share one UTC
// day-key space and are internally consistent. Re-keying it to local
// is R12's read-swap (it migrates with the rollup tier), NOT this
// hotfix — doing it here would mean per-request raw re-aggregation
// without the local-bucketed rollup behind it.
const weightDaily = aggregate.dailyByType.WEIGHT ?? [];
const sysDaily = aggregate.dailyByType.BLOOD_PRESSURE_SYS ?? [];
const weightBpPairs: PairedPoint[] = joinDailyByDay(weightDaily, sysDaily);
@@ -257,14 +269,26 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
}));
// ── Correlations: mood × {sys BP, weight, pulse} ─────────
// Daily-key matches the legacy `buildMoodMetricPairs` exactly —
// mood is already per-day in the legacy code and we just align the
// metric side to the same daily bucket.
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDaily);
// v1.32.17 — the mood series is LOCAL-day-keyed (since v1.32.12, see the
// rollup note above). `pairMoodWithDaily` joins on an EXACT YYYY-MM-DD
// key, so the metric partners MUST also be local-day-keyed or every
// evening logger west of UTC gets a lag-1 pairing sold as same-day.
// Derive the sys / weight partners as exact local-day means from the raw
// rows already on the request path (canonical-source deduped in the
// aggregator), NOT from the UTC `dailyByType` — a UTC-day MEAN cannot be
// relabeled into a local-day mean. `round2` mirrors the aggregator's
// `ROUND(AVG, 2)` so correlation thresholds don't drift.
const sysDailyLocal = deriveDailyLocalMeans(aggregate.bpRawRows.sys, userTz);
const weightDailyLocal = deriveDailyLocalMeans(
aggregate.weightRawRows,
userTz,
);
const moodBpPairs = pairMoodWithDaily(dailyMoodEntries, sysDailyLocal);
const moodBpCorrelation = pearsonCorrelation(moodBpPairs);
const moodBpScatterData = moodBpPairs.map((p) => ({ mood: p.a, sysBP: p.b }));
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDaily);
const moodWeightPairs = pairMoodWithDaily(dailyMoodEntries, weightDailyLocal);
const moodWeightCorrelation = pearsonCorrelation(moodWeightPairs);
const moodWeightScatterData = moodWeightPairs.map((p) => ({
mood: p.a,
@@ -278,23 +302,16 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the
// low-percentile daily proxy) when the user has no resting rows — the
// proxy needs the per-day distribution, which the day-mean discards.
// Day keys follow this route's UTC-bucket convention (the per-user-tz
// bucketing is the documented v1.5 follow-up) so the resting series pairs
// against the same UTC day keys as the mood series.
//
// v1.30.3 (QA F8) — investigated switching this to `userDayKey(d, userTz)`
// to match the intraday page's resting-proxy bucketing (`71d7ca78c`), but
// `dailyMoodEntries` below (this series' ONLY pairing partner, joined by
// an exact YYYY-MM-DD key in `pairMoodWithDaily`) is itself UTC-anchored —
// it reads `moodRollupDayRows[].bucketStart.toISOString().slice(0, 10)`,
// a real UTC aggregation boundary, not just a display label. Moving ONLY
// this side to the user's tz would not fix anything: it would swap
// "evening readings shift a day relative to the true local day" for
// "evening readings mismatch their mood pairing partner, which used to
// line up precisely because both sides shared the same UTC convention."
// A genuine fix needs the mood-rollup tier itself to bucket per-user-tz
// (the same v1.5 follow-up the comment above already flags) — out of
// scope for this pass. Left UTC on both sides intentionally.
// v1.32.17 — key this resting series on `userDayKey(d, userTz)`. Its ONLY
// pairing partner is `dailyMoodEntries`, which is LOCAL-day-keyed since
// v1.32.12 (the mood rollup writer now mints `bucketStart` from the
// canonical local `MoodEntry.date` label, not a UTC truncation). Keeping
// UTC here would offset every evening logger west of UTC by one day and
// sell a lag-1 correlation as same-day. This is exactly the flip the
// v1.30.3 F8 note said becomes correct once the mood side goes local —
// it now has. (Unlike the mood side, this series builds from raw rows in
// this route, so it re-keys directly with no rollup-tier change.)
const restingPulseRows = await prisma.measurement.findMany({
where: {
userId,
@@ -316,15 +333,15 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
select: { measuredAt: true, value: true },
})
: [];
const utcDayKey = (d: Date) => d.toISOString().slice(0, 10);
const localDayKey = (d: Date) => userDayKey(d, userTz);
const { series: restingPulseSeries } = resolveRestingPulseSeries({
restingSamples: restingPulseRows,
pulseSamples: rawPulseRowsForResting,
dayKeyOf: utcDayKey,
dayKeyOf: localDayKey,
});
const restingByDay = new Map<string, { sum: number; count: number }>();
for (const s of restingPulseSeries) {
const key = utcDayKey(s.measuredAt);
const key = localDayKey(s.measuredAt);
const cur = restingByDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += s.value;
cur.count += 1;
@@ -458,14 +475,16 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
if (expectedBpIntakesPerDay > 0) {
// Daily systolic means (already keyed YYYY-MM-DD) from the SQL pass.
// v1.30.3 (QA F8) — `sysDaily` is UTC `date_trunc`-bucketed by the
// aggregator (`comprehensive-aggregator.ts`), so `takenByDay` below
// MUST key on the same UTC day, not the user's own tz — pairing the
// two on different day spaces would silently drop pairs rather than
// fix anything. Making this whole correlation user-tz-consistent
// needs the aggregator's `dailyByType` to bucket in the user's tz
// first (a bigger, already-flagged follow-up); until then this stays
// UTC to match its only pairing partner.
// v1.32.17 — `sysDaily` (UTC `date_trunc` from the aggregator's
// `dailyByType`) and `takenByDay` below (intake `scheduledFor`
// truncated to UTC) both live in the SAME UTC day-key space and are
// internally consistent with each other — this join is NOT broken and
// stays as is. Only the mood pairings above moved to a separately
// derived local-day series (their partner, the mood series, is
// local-day-keyed). This correlation migrates to local when the
// measurement-rollup tier buckets per-user tz (R12), not in this hotfix
// — re-keying it here without the local-bucketed rollup would mean
// per-request raw re-aggregation.
const sysByDay = new Map<string, number>();
for (const row of sysDaily) {
sysByDay.set(row.day, row.value);
@@ -565,6 +584,40 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// Helpers
// ─────────────────────────────────────────────────────────────
/**
* v1.32.17 — derive exact local-day mean series from raw measurement rows,
* keyed by the user's timezone via `userDayKey`. The mood series is
* local-day-keyed (since v1.32.12), and `pairMoodWithDaily` joins on an
* exact YYYY-MM-DD key, so its metric partners must be local-day-keyed too.
* A UTC-day mean (from `dailyByType`) cannot be relabeled into a local-day
* mean, so the mean is recomputed here from raw rows already on the request
* path. `userDayKey` formats via a memoised `Intl.DateTimeFormat` — no
* offset arithmetic — so DST-transition days (23 h / 25 h) bucket correctly
* and the result is host-TZ-independent (CI runs UTC). `round2` mirrors the
* aggregator's `ROUND(AVG, 2)` so correlation thresholds don't drift.
* Returns `{ day, value }[]` sorted ASC by day, the shape
* `pairMoodWithDaily` expects.
*/
function deriveDailyLocalMeans(
rows: Array<{ measuredAt: Date; value: number }>,
tz: string,
): Array<{ day: string; value: number }> {
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
const key = userDayKey(r.measuredAt, tz);
const cur = byDay.get(key) ?? { sum: 0, count: 0 };
cur.sum += r.value;
cur.count += 1;
byDay.set(key, cur);
}
return Array.from(byDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, stats]) => ({
day,
value: Math.round((stats.sum / stats.count) * 100) / 100,
}));
}
/**
* Daily-key inner join between two daily-aggregated series. Returns
* `PairedPoint` shapes compatible with `pearsonCorrelation`.
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
@@ -682,3 +683,91 @@ describe("DELETE /api/insights/layout", () => {
expect(call.data.insightsLayoutJson).toBeDefined();
});
});
/**
* v1.32.21 (R5a) — optimistic concurrency (issue #581 family). A PUT carrying
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
* still carrying that token, so an interleaved edit-mode Save and Settings
* pill-order Save can't clobber one another. A tokenless PUT keeps the prior
* unconditional write — the iOS-compat arm.
*/
describe("PUT /api/insights/layout — optimistic concurrency", () => {
function body(baseUpdatedAt?: string) {
return {
version: 2,
tiles: [{ id: "overview", visible: true, order: 0 }],
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
};
}
it("guards the write on the base token and returns the advanced token", async () => {
const base = new Date("2026-07-24T10:00:00.000Z");
const advanced = new Date("2026-07-24T10:05:00.000Z");
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
// The merge-read `previous` (no row) then the post-write token read.
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({ insightsLayoutJson: null } as never)
.mockResolvedValueOnce({ updatedAt: advanced } as never);
const res = await callPut(makeReq(body(base.toISOString())));
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(base.toISOString());
expect(prisma.user.update).not.toHaveBeenCalled();
const bodyJson = (await res.json()) as { data: { updatedAt: string } };
expect(bodyJson.data.updatedAt).toBe(advanced.toISOString());
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: null,
} as never);
// Writer B committed first; writer A's guarded write matches zero rows.
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await callPut(makeReq(body("2026-07-24T09:00:00.000Z")));
expect(res.status).toBe(409);
const bodyJson = (await res.json()) as {
data: null;
error: string;
meta?: { errorCode?: string };
};
expect(bodyJson.data).toBeNull();
expect(bodyJson.meta?.errorCode).toBe("insights_layout_conflict");
// Guarded write matched no row; no unconditional fallback → nothing written.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await callPut(makeReq(body("not-a-date")));
expect(res.status).toBe(422);
const bodyJson = (await res.json()) as { meta?: { errorCode?: string } };
expect(bodyJson.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the client omits the token (iOS compat)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
insightsLayoutJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await callPut(makeReq(body()));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
const bodyJson = (await res.json()) as { data: { updatedAt: string } };
expect(bodyJson.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
});
+44 -11
View File
@@ -23,13 +23,18 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
resolveInsightsLayout,
serializeInsightsLayout,
DEFAULT_INSIGHTS_LAYOUT,
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
type InsightsLayout,
type InsightsLayoutWithToken,
} from "@/lib/insights-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -95,12 +100,21 @@ const layoutSchema = z.object({
.optional(),
});
async function buildInsightsLayout(userId: string): Promise<InsightsLayout> {
async function buildInsightsLayout(
userId: string,
): Promise<InsightsLayoutWithToken> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { insightsLayoutJson: true },
// v1.32.21 (R5a) — `updatedAt` rides the cached layout so GET hands the
// client the optimistic-concurrency token alongside the layout it applies
// to. Cached together (and busted on every write) so the (layout, token)
// pair a client reads is always consistent — the widgets precedent.
select: { insightsLayoutJson: true, updatedAt: true },
});
return resolveInsightsLayout(row?.insightsLayoutJson);
return {
...resolveInsightsLayout(row?.insightsLayoutJson),
updatedAt: row?.updatedAt?.toISOString(),
};
}
export const GET = apiHandler(async () => {
@@ -110,7 +124,7 @@ export const GET = apiHandler(async () => {
// changes only when the user hits the Settings save button, which
// invalidates this cache via `invalidateUserInsightsLayout()`.
const layout = await cached(
caches.insightsLayout as ServerCache<InsightsLayout>,
caches.insightsLayout as ServerCache<InsightsLayoutWithToken>,
user.id,
() => buildInsightsLayout(user.id),
annotate,
@@ -121,11 +135,18 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const body = taken.rest;
const parsed = layoutSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue 422 envelope matches the dashboard-widgets route so
@@ -179,12 +200,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const previous = await buildInsightsLayout(user.id);
const normalized = serializeInsightsLayout(parsed.data, previous);
await prisma.user.update({
where: { id: user.id },
data: {
insightsLayoutJson: toJson(normalized),
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
// 409s and writes NOTHING (so an interleaved edit-mode Save can't be
// reverted by a Settings pill-order Save that started from an older read,
// and vice versa); a tokenless request keeps the prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { insightsLayoutJson: toJson(normalized) },
conflict: {
action: "insights.layout.conflict",
errorCode: "insights_layout_conflict",
message: "Insights layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "insights.layout.update" },
@@ -193,7 +223,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateUserInsightsLayout(user.id);
return apiSuccess(normalized);
return apiSuccess({
...normalized,
updatedAt: guarded.updatedAt,
} satisfies InsightsLayoutWithToken);
});
export const DELETE = apiHandler(async () => {
@@ -133,7 +133,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// webhook 200 response (the rollup is a cache tier, the source-of-
// truth is the mood_entries write that already committed).
try {
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
await recomputeMoodBucketsForEntry(user.id, entry.date);
} catch (rollupErr) {
getEvent()?.addWarning(
"Mood rollup recompute failed: " +
@@ -0,0 +1,303 @@
/**
* v1.32.8 (iOS #66) — the optional per-request `syncTrigger` on the HealthKit
* batch ingest.
*
* It is DIAGNOSTIC-ONLY: recorded on the `measurement.batch.ingest` wide event
* so an operator can see, per batch, what woke the client (foreground app open,
* background refresh, or a push), and NOTHING else. These pin that contract:
*
* - a batch carrying `syncTrigger:"background"` is accepted and the value
* rides the ingest annotation;
* - a batch WITHOUT it is accepted and the annotation carries `null`;
* - an invalid value is a 422 (closed enum);
* - the field never changes which rows are inserted / updated — the per-row
* outcomes are byte-identical with and without it, so it cannot leak into
* dedup, attribution, or storage.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { measurement: unknown });
}
}),
},
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: mocks.annotate };
});
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: async (
tx: {
measurement: {
findMany: (args: unknown) => Promise<
Array<{
id?: string;
type?: string;
source?: string;
externalId?: string | null;
}>
>;
createManyAndReturn: (args: {
data: Record<string, unknown>;
}) => Promise<Array<Record<string, unknown>>>;
updateMany: (args: {
where: Record<string, unknown>;
data: Record<string, unknown>;
}) => Promise<unknown>;
};
},
input: Record<string, unknown> & {
userId: string;
type: string;
source: string;
externalId: string;
},
options: { exactExternalMatch?: "update" | "duplicate" } = {},
) => {
const existing = await tx.measurement.findMany({});
const exact = existing.find(
(row: { type?: string; source?: string; externalId?: string | null }) =>
row.type === input.type &&
row.source === input.source &&
row.externalId === input.externalId,
);
if (exact && options.exactExternalMatch !== "update") {
return { status: "duplicate", row: exact };
}
if (exact) {
await tx.measurement.updateMany({
where: { id: exact.id },
data: { ...input, deletedAt: null },
});
return { status: "updated", row: { ...exact, ...input } };
}
const [inserted] = await tx.measurement.createManyAndReturn({
data: input,
});
if (inserted) return { status: "inserted", row: inserted };
return { status: "duplicate" };
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const SAMPLE_ID = "C0FFEE00-0000-4000-8000-000000000000";
const STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21";
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function sampleEntry(externalId: string, value: number) {
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value,
unit: "count/min",
startDate: "2026-06-21T14:00:00.000Z",
endDate: "2026-06-21T14:00:01.000Z",
externalId,
};
}
function stepsEntry(externalId: string, value: number) {
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value,
unit: "count",
startDate: "2026-06-21T00:00:00.000Z",
endDate: "2026-06-21T23:59:59.000Z",
externalId,
};
}
function ingestMeta(): Record<string, unknown> {
const call = mocks.annotate.mock.calls.find(
([arg]) =>
(arg as { action?: { name?: string } })?.action?.name ===
"measurement.batch.ingest",
);
return (call?.[0] as { meta: Record<string, unknown> }).meta;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation((async (
args: unknown,
) => {
const { data } = args as {
data:
| { type: unknown; source: string; externalId: string | null }
| Array<{ type: unknown; source: string; externalId: string | null }>;
};
const rows = Array.isArray(data) ? data : [data];
return rows.map(({ type, source, externalId }) => ({
type,
source,
externalId,
}));
}) as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
});
describe("POST /api/measurements/batch — syncTrigger diagnostic (iOS #66)", () => {
it("records `syncTrigger` on the ingest annotation when the client sends it", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "background",
}),
);
expect(res.status).toBe(200);
expect(ingestMeta().syncTrigger).toBe("background");
});
it("accepts a batch WITHOUT `syncTrigger` and annotates null", async () => {
const res = await POST(
makeRequest({ entries: [sampleEntry(SAMPLE_ID, 64)] }),
);
expect(res.status).toBe(200);
expect(ingestMeta().syncTrigger).toBe(null);
});
it("rejects an invalid `syncTrigger` value with 422 (closed enum)", async () => {
const res = await POST(
makeRequest({
entries: [sampleEntry(SAMPLE_ID, 64)],
syncTrigger: "wifi",
}),
);
expect(res.status).toBe(422);
});
it("does not change which rows insert / update with vs without the field", async () => {
// A pre-existing per-day steps row makes the re-post an overwrite; a fresh
// sample row inserts. The per-row outcome must be identical regardless of
// whether `syncTrigger` rides the request.
const seedExisting = () =>
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "m1",
type: "ACTIVITY_STEPS",
source: "APPLE_HEALTH",
externalId: STATS_ID,
},
] as never);
const entries = [sampleEntry(SAMPLE_ID, 64), stepsEntry(STATS_ID, 5000)];
seedExisting();
const withoutRes = await POST(makeRequest({ entries }));
expect(withoutRes.status).toBe(200);
const withoutBody = (await withoutRes.json()).data;
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.createManyAndReturn).mockImplementation(
(async (args: unknown) => {
const { data } = args as { data: unknown };
const rows = Array.isArray(data) ? data : [data];
return rows.map((r) => {
const row = r as {
type: unknown;
source: string;
externalId: string | null;
};
return {
type: row.type,
source: row.source,
externalId: row.externalId,
};
});
}) as never,
);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
seedExisting();
const withRes = await POST(makeRequest({ entries, syncTrigger: "push" }));
expect(withRes.status).toBe(200);
const withBody = (await withRes.json()).data;
// The row-level outcome (what inserted, what updated, per-entry statuses)
// is byte-identical; only the diagnostic annotation differs.
expect(withBody.inserted).toBe(withoutBody.inserted);
expect(withBody.updated).toBe(withoutBody.updated);
expect(withBody.duplicates).toBe(withoutBody.duplicates);
expect(withBody.entries).toEqual(withoutBody.entries);
expect(withoutBody.updated).toBe(1);
expect(withoutBody.inserted).toBe(1);
});
});
+15 -1
View File
@@ -159,8 +159,18 @@ const batchEntrySchema = z.object({
deviceType: deviceTypeEnum.nullable().optional(),
});
// v1.32.8 (iOS #66) — optional per-REQUEST diagnostic tag naming what woke the
// client for this sync. Purely observational: it flows into 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. Only the client knows its own wake reason, so it is client-asserted
// by necessity; being diagnostic-only, that carries no trust surface. Optional
// so every pre-#66 caller stays byte-for-byte unchanged.
const syncTriggerEnum = z.enum(["foreground", "background", "push"]);
const batchPayloadSchema = z.object({
entries: z.array(batchEntrySchema).min(1),
syncTrigger: syncTriggerEnum.optional(),
});
type BatchEntry = z.infer<typeof batchEntrySchema>;
@@ -248,7 +258,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
}
const { entries } = parsed.data;
const { entries, syncTrigger } = parsed.data;
// First pass — map each inbound entry; record skipped entries.
type Prepared = {
@@ -784,6 +794,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
// `skippedByReason` build above.
skipped_by_reason: skippedByReason,
failed: failedCount,
// v1.32.8 (iOS #66) — diagnostic sync-trigger tag (foreground /
// background / push), or null when the client did not send one.
// Observability only; it never influenced any count above.
syncTrigger: syncTrigger ?? null,
},
});
@@ -201,6 +201,56 @@ describe("GET /api/medications/[id]/dose-history", () => {
expect(adHoc?.intake?.id).toBe("evt-adhoc");
});
it("returns each intake's write provenance in `source` (iOS #64)", async () => {
const from = at(DAY, 0, 0).toISOString();
const to = at(DAY, 23, 59).toISOString();
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
// a native-app take carries API provenance
{
id: "evt-morning",
scheduledFor: at(DAY, 7, 0),
takenAt: at(DAY, 7, 5),
skipped: false,
autoMissed: false,
source: "API",
},
// a legacy row written before the column carried a value → null
{
id: "evt-adhoc",
scheduledFor: at(DAY, 11, 29),
takenAt: at(DAY, 11, 29),
skipped: false,
autoMissed: false,
source: null,
},
] as never);
// The route must SELECT `source` from Prisma, or it could never surface it.
const res = await GET(
getReq(`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const select = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
.calls[0][0]?.select as Record<string, unknown> | undefined;
expect(select?.source).toBe(true);
const json = await res.json();
const rows = json.data.rows as Array<{
kind: string;
timeOfDay: string | null;
intake: { id: string | null; source: string | null } | null;
}>;
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.intake?.id).toBe("evt-morning");
expect(morning?.intake?.source).toBe("API");
const adHoc = rows.find((r) => r.kind === "ad_hoc");
expect(adHoc?.intake?.id).toBe("evt-adhoc");
expect(adHoc?.intake?.source).toBe(null);
});
it("422 on a reversed from/to window", async () => {
const from = at(DAY, 12, 0).toISOString();
const to = at(DAY, 6, 0).toISOString();
@@ -87,6 +87,8 @@ interface SerializedDoseHistoryRow {
autoMissed: boolean;
/** v1.16.4 — per-intake dose override; null = configured dose. */
doseTaken: string | null;
/** v1.32.8 (iOS #64) — how the dose was recorded; null on legacy rows. */
source: "WEB" | "API" | "REMINDER" | "IMPORT" | "APPLE_HEALTH" | null;
} | null;
}
@@ -161,6 +163,9 @@ export const GET = apiHandler(
attributionSource: true,
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
doseTaken: true,
// v1.32.8 (iOS #64) — write provenance so the client can label how
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
source: true,
},
});
@@ -172,6 +177,7 @@ export const GET = apiHandler(
autoMissed: e.autoMissed,
pinned: e.attributionSource === "USER_PIN",
doseTaken: e.doseTaken,
source: e.source,
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
@@ -244,6 +250,7 @@ export const GET = apiHandler(
autoMissed: e.autoMissed,
pinned: e.pinned,
doseTaken: e.doseTaken,
source: e.source,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
@@ -269,6 +276,7 @@ export const GET = apiHandler(
skipped: row.intake.skipped,
autoMissed: row.intake.autoMissed ?? false,
doseTaken: row.intake.doseTaken ?? null,
source: row.intake.source ?? null,
}
: null,
}));
@@ -0,0 +1,203 @@
/**
* v1.32.8 (iOS #64) — write-provenance of a medication intake is derived from
* the TRANSPORT, never from the request body.
*
* - a cookie (browser) POST records `source: "WEB"`;
* - a Bearer (native app) POST records `source: "API"`;
* - a body that tries to set `source` is ignored (no mass assignment);
* - the producer-owned values (`REMINDER` / `IMPORT` / `APPLE_HEALTH`) are
* minted by OTHER routes and are unreachable here, so this route can only
* ever stamp WEB / API — the cookie-vs-Bearer split is the whole surface.
*
* These run the REAL `apiHandler` + `requireAuth` (only `getSession`,
* `next/headers`, and the Bearer resolver are mocked) so the auth-method
* derivation that a unit mock would paper over is exercised end to end. The
* medication has no schedules, so a taken write lands on the standalone
* (ad-hoc) create path where `source` is stamped directly.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
medicationIntakeEvent: {
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
// The medication carries no schedules, so a taken write resolves to no
// canonical slot and lands on the standalone create path. Stub the resolvers
// so the route deterministically takes that branch regardless of the fixtures.
vi.mock("@/lib/medications/scheduling/slot-upsert", () => ({
resolveSlotForWriteByBand: vi.fn(async () => ({ slotInstant: null })),
resolveSlotInstantForWrite: vi.fn(async () => null),
resolveForcedSlotForWrite: vi.fn(async () => null),
findPinConflict: vi.fn(async () => false),
mayConvergeOntoSuppliedSlot: vi.fn(() => true),
applyCanonicalSlotWrite: vi.fn(),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/lifecycle", () => ({
reconcileOneShotState: vi.fn().mockResolvedValue("noop"),
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Default to no Authorization header; the Bearer tests override this.
const headerStore = { authorization: null as string | null };
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({
get: (name: string) =>
name.toLowerCase() === "authorization" ? headerStore.authorization : null,
})),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { resolveBearerToken } from "@/lib/auth/bearer";
const USER = {
id: "user-1",
username: "testuser",
role: "USER" as const,
timezone: "Europe/Berlin",
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: USER,
};
const MED_OK = { id: "med-1", userId: "user-1" };
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function postReq(body: Record<string, unknown>): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** The `source` value stamped on the freshly-created standalone intake row. */
function createdSource(): unknown {
const call = vi.mocked(prisma.medicationIntakeEvent.create).mock.calls[0];
return (call?.[0] as { data?: { source?: unknown } } | undefined)?.data
?.source;
}
beforeEach(() => {
vi.resetAllMocks();
headerStore.authorization = null;
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// The standalone branch wraps the create in a `$transaction([...])`. The
// create mock records the `data` it was called with; the transaction
// resolves to the created row so the rest of the handler proceeds.
vi.mocked(prisma.medicationIntakeEvent.create).mockReturnValue({
id: "evt-1",
takenAt: new Date(),
scheduledFor: new Date(),
} as never);
vi.mocked(prisma.$transaction).mockResolvedValue([
{ id: "evt-1", takenAt: new Date(), scheduledFor: new Date() },
] as never);
});
describe("POST intake — transport-derived source (iOS #64)", () => {
it("stamps WEB for a cookie (browser) session", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
expect(createdSource()).toBe("WEB");
});
it("stamps API for a Bearer (native app) token", async () => {
vi.mocked(getSession).mockResolvedValue(null as never);
headerStore.authorization = "Bearer hlk_test-token";
vi.mocked(resolveBearerToken).mockResolvedValue({
user: USER as never,
tokenId: "tok-1",
permissions: ["*"],
expiresAt: new Date(Date.now() + 3_600_000),
});
const res = await POST(postReq({}), ROUTE_PARAMS);
expect(res.status).toBe(201);
expect(createdSource()).toBe("API");
});
it("ignores a client-supplied `source` in the body (no mass assignment)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// A cookie caller forging `source: "APPLE_HEALTH"` must still record WEB —
// the value is derived from the transport, never read from the body.
const res = await POST(postReq({ source: "APPLE_HEALTH" }), ROUTE_PARAMS);
expect(res.status).toBe(201);
expect(createdSource()).toBe("WEB");
});
});
+13 -4
View File
@@ -43,7 +43,16 @@ export const POST = apiHandler(
);
async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user } = await requireAuth();
const { user, authMethod } = await requireAuth();
// v1.32.8 (iOS #64) — write provenance derived from the transport, never
// client-asserted: a Bearer/native call stores `API`, a cookie/browser call
// stores `WEB`. The other `IntakeSource` values are producer-owned by OTHER
// routes (the Telegram worker mints `REMINDER`, the bulk/import paths mint
// `IMPORT` / `APPLE_HEALTH`), and this route only ever CREATES its own rows —
// a converge onto a pending `REMINDER` row updates through
// `applyCanonicalSlotWrite`, which never rewrites `source`, so that
// provenance is preserved.
const intakeSource = authMethod === "bearer" ? "API" : "WEB";
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
@@ -316,7 +325,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
isExplicitTaken,
isExplicitSkip,
idempotencyKey: idempotencyKey ?? null,
createSource: "WEB",
createSource: intakeSource,
// v1.8.5 — resolved + validated site (null unless a tracking-on
// injection taken write supplied an allowed site).
injectionSite: resolvedInjectionSite,
@@ -382,7 +391,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
isExplicitTaken,
isExplicitSkip,
idempotencyKey: idempotencyKey ?? null,
createSource: "WEB",
createSource: intakeSource,
injectionSite: resolvedInjectionSite,
attributionSource,
doseTaken: resolvedDoseTaken,
@@ -409,7 +418,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
scheduledFor: resolvedTakenAt ?? incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
source: "WEB",
source: intakeSource,
idempotencyKey: idempotencyKey ?? null,
// v1.8.5 — site only on a resolved taken-injection write.
...(resolvedInjectionSite !== null && {
@@ -9,16 +9,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
// v1.32.22 (M5) — the PATCH / DELETE now run inside `prisma.$transaction`
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as the tx, so the existing model mocks apply
// inside the transaction and `$queryRaw` (the advisory lock) is observable.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -52,11 +59,16 @@ vi.mock("next/headers", () => ({
})),
}));
import { PATCH } from "../route";
import { PATCH, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const prismaTx = prisma as unknown as {
$queryRaw: ReturnType<typeof vi.fn>;
$transaction: ReturnType<typeof vi.fn>;
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
@@ -76,6 +88,11 @@ const ROUTE_CTX = {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it so
// the callback still runs with the base client as the tx.
prismaTx.$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
@@ -244,3 +261,95 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling",
expect("doseStrength" in arg.data).toBe(false);
});
});
/**
* v1.32.22 (M5) — the stock-correction PATCH and the DELETE route their
* read-compose-write through the SAME per-medication advisory lock that
* `consumeForIntake` takes, so an absolute stock write can no longer clobber a
* concurrent dose decrement. These pin the routing: the write runs inside a
* transaction that takes the advisory lock BEFORE it reads + writes.
*/
describe("PATCH / DELETE inventory — advisory-lock serialisation (M5)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "i1",
} as never);
});
it("takes the medication advisory lock inside a transaction before the write", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
// One transaction, one advisory-lock query.
expect(prismaTx.$transaction).toHaveBeenCalledTimes(1);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
// The lock SQL is the canonical `pg_advisory_xact_lock` — proves the route
// shares the intake hook's key rather than re-deriving one.
const sql = prismaTx.$queryRaw.mock.calls[0]?.[0] as {
strings?: string[];
};
expect((sql.strings ?? []).join("")).toContain("pg_advisory_xact_lock");
// Lock is acquired BEFORE the row update (serialises against a concurrent
// consume decrement).
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const updateOrder = vi.mocked(prisma.medicationInventoryItem.update).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(updateOrder);
});
it("re-reads the row INSIDE the locked transaction", async () => {
await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
// The ownership re-read runs after the lock is taken.
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const readOrder = vi.mocked(prisma.medicationInventoryItem.findUnique).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(readOrder);
});
it("DELETE takes the same lock before deleting", async () => {
const res = await DELETE(patchReq({}), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const deleteOrder = vi.mocked(prisma.medicationInventoryItem.delete).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(deleteOrder);
});
it("still 404s an item owned by another user (checked inside the lock)", async () => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "someone-else",
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
firstUseAt: null,
notes: null,
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(404);
expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled();
});
});
@@ -36,33 +36,17 @@ import {
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
import { serializeInventoryItem } from "@/lib/medications/inventory/service";
import { lockMedicationInventory } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
async function loadOwnedItem(
medicationId: string,
itemId: string,
userId: string,
) {
const item = await prisma.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
return null;
}
return item;
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -84,67 +68,6 @@ export const PATCH = apiHandler(
notes,
} = parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
// v1.16.12 — Decimal column; work in JS numbers locally (the
// arithmetic below stays well within double precision) and let
// Prisma re-widen on write.
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (unitsRemaining !== undefined) {
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
// The wire field counts UNITS. Clamp to the item's capacity; the
// state-machine re-run below owns every state consequence (0 ⇒
// USED_UP, a raise out of 0 re-evaluates against the expiry
// clocks).
nextUnitsRemaining = Math.min(
unitsRemaining,
Number(existing.unitsTotal),
);
}
if (markAsUsedUp === true) {
nextUnitsRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
unitsTotal: Number(existing.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
// Only touch the note columns when the caller supplied a value; an absent
// `notes` leaves the existing ciphertext untouched. When present, encrypt
// at rest and null the plaintext column.
@@ -163,26 +86,114 @@ export const PATCH = apiHandler(
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
...labellingData,
...notesData,
},
// v1.32.22 (M5) — the stock correction writes an ABSOLUTE `unitsRemaining`
// composed from a prior read. `consumeForIntake` decrements the same rows
// under a per-medication advisory lock; without taking that SAME lock the
// correction's read-compose-write could clobber a concurrent decrement (or
// vice versa). Route the whole read-compose-write through one transaction
// that takes the lock first and re-reads INSIDE it, so the correction
// serializes against intake consumption instead of racing it.
const outcome = await prisma.$transaction(async (tx) => {
await lockMedicationInventory(tx, id);
const existing = await tx.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (
!existing ||
existing.userId !== user.id ||
existing.medicationId !== id
) {
return { notFound: true as const };
}
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
// v1.16.12 — Decimal column; work in JS numbers locally (the
// arithmetic below stays well within double precision) and let
// Prisma re-widen on write.
let nextUnitsRemaining: number = Number(existing.unitsRemaining);
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (unitsRemaining !== undefined) {
// v1.16.1 — stock correction (Bestand tab adjust / withdraw).
// The wire field counts UNITS. Clamp to the item's capacity; the
// state-machine re-run below owns every state consequence (0 ⇒
// USED_UP, a raise out of 0 re-evaluates against the expiry
// clocks).
nextUnitsRemaining = Math.min(
unitsRemaining,
Number(existing.unitsTotal),
);
}
if (markAsUsedUp === true) {
nextUnitsRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (unitsRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
unitsTotal: Number(existing.unitsTotal),
unitsRemaining: nextUnitsRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
const updated = await tx.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
...labellingData,
...notesData,
},
});
return { prevState: existing.state, updated };
});
if ("notFound" in outcome) return apiError("Inventory item not found", 404);
const { prevState, updated } = outcome;
await auditLog("medication.inventory.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
prevState: existing.state,
prevState,
nextState: updated.state,
},
});
@@ -193,7 +204,7 @@ export const PATCH = apiHandler(
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id, prev: existing.state, next: updated.state },
meta: { medication_id: id, prev: prevState, next: updated.state },
});
// A stock correction / first-use / used-up flip changes the
@@ -211,11 +222,24 @@ export const DELETE = apiHandler(
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
// v1.32.22 (M5) — take the same per-medication advisory lock the intake
// consumption hook takes, so a delete that interleaves a `consumeForIntake`
// serializes against it: the consume either fully applies before the row
// vanishes or blocks until the delete commits and then finds nothing to
// decrement, instead of half-applying against a row deleted mid-flight.
const existing = await prisma.$transaction(async (tx) => {
await lockMedicationInventory(tx, id);
const item = await tx.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== user.id || item.medicationId !== id) {
return null;
}
await tx.medicationInventoryItem.delete({ where: { id: itemId } });
return item;
});
if (!existing) return apiError("Inventory item not found", 404);
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
await auditLog("medication.inventory.delete", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -1,8 +1,12 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
// v1.32.22 (M5) — the [itemId] PATCH / DELETE now run inside a transaction
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as tx, and `$queryRaw` stands in for the
// advisory-lock query.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
@@ -11,8 +15,11 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
},
}));
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -72,6 +79,12 @@ function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it.
(
prisma as unknown as { $transaction: ReturnType<typeof vi.fn> }
).$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
+2 -3
View File
@@ -32,6 +32,7 @@ import {
recomputeMedicationComplianceForDay,
} from "@/lib/rollups/medication-compliance-rollups";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { NextRequest } from "next/server";
@@ -39,9 +40,7 @@ type RouteParams = { params: Promise<{ id: string }> };
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
function hhmmToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
return h * 60 + m;
return hhmmToMinutesOrNull(value) ?? 0;
}
/**
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
@@ -152,6 +153,8 @@ describe("PUT /api/medications/layout — preserve-when-absent", () => {
version: 1,
view: "table",
order: ["med-a", "med-b"],
// v1.32.21 — the write now echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledTimes(1);
@@ -275,3 +278,90 @@ describe("DELETE /api/medications/layout", () => {
expect(call.data.medicationListLayoutJson).toBeDefined();
});
});
/**
* v1.32.21 (R5a) — optimistic concurrency (issue #581 family). A PUT carrying
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
* still carrying that token, so a view toggle can never resurrect an order the
* reorder Save just committed (and vice versa). A tokenless PUT keeps the prior
* unconditional write — the iOS-compat arm.
*/
describe("PUT /api/medications/layout — optimistic concurrency", () => {
// A full blob (view + order) so no preserve-read fires; the only findUnique
// is the post-write token read.
function fullBody(baseUpdatedAt?: string) {
return {
version: 1,
view: "table",
order: ["med-a", "med-b"],
...(baseUpdatedAt !== undefined ? { baseUpdatedAt } : {}),
};
}
it("guards the write on the base token and returns the advanced token", async () => {
const base = new Date("2026-07-24T10:00:00.000Z");
const advanced = new Date("2026-07-24T10:05:00.000Z");
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
updatedAt: advanced,
} as never);
const res = await callPut(makeReq(fullBody(base.toISOString())));
expect(res.status).toBe(200);
// Conditional write on the exact base token — never unconditional.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(base.toISOString());
expect(prisma.user.update).not.toHaveBeenCalled();
const body = (await res.json()) as { data: { updatedAt: string } };
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
// Writer B committed first (updatedAt advanced); writer A replays with the
// pre-B token → the conditional write matches zero rows.
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await callPut(makeReq(fullBody("2026-07-24T09:00:00.000Z")));
expect(res.status).toBe(409);
const body = (await res.json()) as {
data: null;
error: string;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("medication_layout_conflict");
// The conditional write matched no row; there is NO unconditional
// fallback, so nothing was written — B's value survives.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await callPut(makeReq(fullBody("not-a-date")));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the client omits the token (iOS compat)", async () => {
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await callPut(makeReq(fullBody()));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
const body = (await res.json()) as { data: { updatedAt: string } };
expect(body.data.updatedAt).toBe("2026-07-24T11:00:00.000Z");
});
});
+43 -9
View File
@@ -21,6 +21,11 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import {
resolveMedicationListLayout,
serializeMedicationListLayout,
@@ -28,7 +33,7 @@ import {
MEDICATION_LIST_VIEWS,
MEDICATION_ORDER_ID_MAX_LENGTH,
MEDICATION_ORDER_MAX_ENTRIES,
type MedicationListLayout,
type MedicationListLayoutWithToken,
} from "@/lib/medication-list-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -55,12 +60,18 @@ const layoutSchema = z.object({
async function buildMedicationListLayout(
userId: string,
): Promise<MedicationListLayout> {
): Promise<MedicationListLayoutWithToken> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { medicationListLayoutJson: true },
// v1.32.21 (R5a) — `updatedAt` rides the cached blob so GET hands the
// client the optimistic-concurrency token alongside the presentation it
// applies to (busted on every write, so the pair stays consistent).
select: { medicationListLayoutJson: true, updatedAt: true },
});
return resolveMedicationListLayout(row?.medicationListLayoutJson);
return {
...resolveMedicationListLayout(row?.medicationListLayoutJson),
updatedAt: row?.updatedAt?.toISOString(),
};
}
export const GET = apiHandler(async () => {
@@ -70,7 +81,7 @@ export const GET = apiHandler(async () => {
// caches; the blob changes only on a view toggle or an order save,
// which invalidates via `invalidateUserMedicationListLayout()`.
const layout = await cached(
caches.medicationListLayout as ServerCache<MedicationListLayout>,
caches.medicationListLayout as ServerCache<MedicationListLayoutWithToken>,
user.id,
() => buildMedicationListLayout(user.id),
annotate,
@@ -81,11 +92,20 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
// v1.32.21 (R5a) — pull the optimistic-concurrency base token off the body
// BEFORE the Zod parse so the domain schema stays pure (issue #581 family).
// BOTH writers (view toggle, order save) route through here, so the token
// guards either against a stale interleave of the other.
const taken = takeBaseToken(rawBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const body = taken.rest;
const parsed = layoutSchema.safeParse(body);
if (!parsed.success) {
// Multi-issue 422 envelope matches the dashboard-widgets / insights
@@ -144,10 +164,21 @@ export const PUT = apiHandler(async (request: NextRequest) => {
order: mergedOrder,
});
await prisma.user.update({
where: { id: user.id },
// v1.32.21 (R5a) — guard the write on the client's base token. A stale base
// 409s and writes NOTHING, so a view toggle can't resurrect an order the
// reorder Save just committed (or vice versa); a tokenless request keeps the
// prior unconditional write.
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { medicationListLayoutJson: toJson(normalized) },
conflict: {
action: "medication.layout.conflict",
errorCode: "medication_layout_conflict",
message: "Medications layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "medication.layout.update" },
@@ -156,7 +187,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateUserMedicationListLayout(user.id);
return apiSuccess(normalized);
return apiSuccess({
...normalized,
updatedAt: guarded.updatedAt,
} satisfies MedicationListLayoutWithToken);
});
export const DELETE = apiHandler(async () => {
+9 -6
View File
@@ -247,13 +247,16 @@ export const PUT = apiHandler(
// (user, day) tuples) so we fan them out in parallel. Best-
// effort: rollup failures must not surface as 5xx.
try {
const targets = new Set<number>([entry.moodLoggedAt.getTime()]);
if (existing.moodLoggedAt.getTime() !== entry.moodLoggedAt.getTime()) {
targets.add(existing.moodLoggedAt.getTime());
// v1.32.12 — key on the `date` label. When the update moved the
// entry across a local-day boundary its `date` changed, so both
// the old and new labels need a recompute (independent buckets).
const targets = new Set<string>([entry.date]);
if (existing.date !== entry.date) {
targets.add(existing.date);
}
await Promise.all(
Array.from(targets).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
Array.from(targets).map((label) =>
recomputeMoodBucketsForEntry(user.id, label),
),
);
} catch (rollupErr) {
@@ -324,7 +327,7 @@ export const DELETE = apiHandler(
// deleted entry's bucket; the recompute helper handles the
// "now-empty day → drop the rollup row" branch internally.
try {
await recomputeMoodBucketsForEntry(user.id, existing.moodLoggedAt);
await recomputeMoodBucketsForEntry(user.id, existing.date);
} catch (rollupErr) {
annotate({
meta: {
@@ -110,11 +110,11 @@ describe("POST /api/mood-entries/bulk-delete", () => {
});
it("collapses the rollup recompute to the unique day set, not per-row", async () => {
// 3 deleted rows across 2 distinct UTC days → 2 recomputes.
// 3 deleted rows across 2 distinct date labels → 2 recomputes.
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
{ moodLoggedAt: new Date("2026-01-01T22:00:00Z") },
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
{ date: "2026-01-01" },
{ date: "2026-01-01" },
{ date: "2026-01-02" },
] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 3,
+10 -12
View File
@@ -75,7 +75,7 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// it is from the mutation below — no existence leak.
const affected = await prisma.moodEntry.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: null },
select: { moodLoggedAt: true },
select: { date: true },
});
// Soft-delete (tombstone) in one statement. The `where` pins `userId`
@@ -101,21 +101,19 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// v1.4.39 W-MOOD — collapse the deleted rows to the unique
// `(user, dayStart)` set BEFORE recomputing so a bulk delete spanning
// one day fires one recompute, not one per row. Best-effort — a
// populator hiccup never fails the user's delete.
const touchedDayStarts = new Set<number>();
// v1.32.12 — collapse the deleted rows to the unique set of `date`
// labels BEFORE recomputing so a bulk delete spanning one local day
// fires one recompute, not one per row, and keys byte-identically to
// the stored `MoodEntry.date`. Best-effort — a populator hiccup
// never fails the user's delete.
const touchedLabels = new Set<string>();
for (const row of affected) {
const d = row.moodLoggedAt;
touchedDayStarts.add(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
touchedLabels.add(row.date);
}
try {
await Promise.all(
Array.from(touchedDayStarts).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
Array.from(touchedLabels).map((label) =>
recomputeMoodBucketsForEntry(user.id, label),
),
);
} catch (rollupErr) {
+7 -10
View File
@@ -432,23 +432,20 @@ async function postBulk(request: NextRequest): Promise<Response> {
// unique `(user, dayStart)` set first to bound the recompute count.
// Best-effort: rollup failures must not surface as 5xx.
if (inserted > 0 || duplicates > 0) {
const touchedDayStarts = new Set<number>();
// v1.32.12 — collapse to the unique set of `date` labels touched by
// this batch (the same per-row `moodDateKey` the insert used), so
// the rollup keys byte-identically to the stored `MoodEntry.date`.
const touchedLabels = new Set<string>();
for (let i = 0; i < entries.length; i++) {
const status = results[i]?.status;
if (status === "inserted" || status === "duplicate") {
const d = entries[i].moodLoggedAt;
const dayStart = Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
);
touchedDayStarts.add(dayStart);
touchedLabels.add(moodDateKey(entries[i].moodLoggedAt, tz));
}
}
try {
await Promise.all(
Array.from(touchedDayStarts).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
Array.from(touchedLabels).map((label) =>
recomputeMoodBucketsForEntry(user.id, label),
),
);
} catch (rollupErr) {
@@ -111,11 +111,11 @@ describe("POST /api/mood-entries/restore", () => {
});
it("collapses the mood rollup recompute to the unique day set, not per-row", async () => {
// 3 restored rows across 2 distinct UTC days → 2 recomputes.
// 3 restored rows across 2 distinct date labels → 2 recomputes.
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([
{ moodLoggedAt: new Date("2026-01-01T08:00:00Z") },
{ moodLoggedAt: new Date("2026-01-01T20:00:00Z") },
{ moodLoggedAt: new Date("2026-01-02T09:00:00Z") },
{ date: "2026-01-01" },
{ date: "2026-01-01" },
{ date: "2026-01-02" },
] as never);
vi.mocked(prisma.moodEntry.updateMany).mockResolvedValue({
count: 3,
+8 -10
View File
@@ -76,7 +76,7 @@ async function postRestore(request: NextRequest): Promise<Response> {
// is from the mutation below — no existence leak.
const affected = await prisma.moodEntry.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: { moodLoggedAt: true },
select: { date: true },
});
// Un-tombstone in one statement. The `where` pins `userId` so a forged
@@ -102,20 +102,18 @@ async function postRestore(request: NextRequest): Promise<Response> {
// Bust per-user mood + achievements + analytics caches.
invalidateUserMood(user.id);
// Collapse the restored rows to the unique `(user, dayStart)` set
// BEFORE recomputing — mirrors the bulk-delete path. Best-effort: a
// v1.32.12 — collapse the restored rows to the unique set of `date`
// labels BEFORE recomputing — mirrors the bulk-delete path and keys
// byte-identically to the stored `MoodEntry.date`. Best-effort: a
// populator hiccup never fails the user's restore.
const touchedDayStarts = new Set<number>();
const touchedLabels = new Set<string>();
for (const row of affected) {
const d = row.moodLoggedAt;
touchedDayStarts.add(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()),
);
touchedLabels.add(row.date);
}
try {
await Promise.all(
Array.from(touchedDayStarts).map((t) =>
recomputeMoodBucketsForEntry(user.id, new Date(t)),
Array.from(touchedLabels).map((label) =>
recomputeMoodBucketsForEntry(user.id, label),
),
);
} catch (rollupErr) {
+1 -1
View File
@@ -334,7 +334,7 @@ async function postMoodEntry(request: NextRequest) {
// Best-effort: a failure here must not surface as a 5xx to the
// user, the rollup is a cache tier, not a write-path invariant.
try {
await recomputeMoodBucketsForEntry(user.id, moodLoggedAt);
await recomputeMoodBucketsForEntry(user.id, date);
} catch (rollupErr) {
annotate({
meta: {
@@ -7,7 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => ({
user: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
moodTagCategory: { findMany: vi.fn() },
}));
@@ -103,6 +103,8 @@ describe("PUT /api/mood/tags/layout", () => {
expect(res.status).toBe(200);
expect(db.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
// v1.32.22 (M2) — tokenless write echoes the fresh `updatedAt` token.
select: { updatedAt: true },
data: {
moodTagLayoutJson: {
groupOrder: ["custom", "feelings"],
@@ -0,0 +1,173 @@
/**
* v1.32.22 (M2) — `PUT /api/mood/tags/layout` optimistic concurrency.
*
* The two manage cards write DIFFERENT fields of the same blob (group order
* vs placements) from the same page; a stale merge would resurrect the other
* card's overwritten field. The base token 409s that instead. A tokenless PUT
* keeps the prior unconditional write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
moodTagCategory: {
findMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood/tags/layout", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
});
describe("GET /api/mood/tags/layout", () => {
it("returns the layout plus the optimistic-concurrency token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: { groupOrder: ["a", "b"], placements: {} },
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { groupOrder: string[]; updatedAt?: string };
};
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("omits updatedAt for a fresh user (null row)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as {
data: { updatedAt?: string };
};
expect(env.data.updatedAt).toBeUndefined();
});
});
describe("PUT /api/mood/tags/layout — optimistic concurrency", () => {
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ moodTagLayoutJson: null } as never)
// post-write fresh token
.mockResolvedValueOnce({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({
groupOrder: ["a", "b"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
const env = (await res.json()) as { data: { updatedAt: string } };
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: { groupOrder: ["a"], placements: {} },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({
placements: { a: ["t1"] },
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
}),
);
expect(res.status).toBe(409);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("mood_tag_layout_conflict");
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("keeps the unconditional write when the token is omitted (compat)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({ groupOrder: ["a", "b"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({ groupOrder: ["a"], baseUpdatedAt: "not-a-date" }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as { meta?: { errorCode?: string } };
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
+36 -6
View File
@@ -3,6 +3,11 @@ import { NextRequest } from "next/server";
import { prisma, toJson } from "@/lib/db";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
takeBaseToken,
guardedUserUpdate,
invalidBaseTokenError,
} from "@/lib/optimistic-lock";
import { annotate } from "@/lib/logging/context";
import {
moodTagLayoutSchema,
@@ -33,9 +38,11 @@ export const dynamic = "force-dynamic";
async function resolveLayoutResponse(
userId: string,
layout: MoodTagLayout,
updatedAt?: string,
): Promise<{
groupOrder: string[];
placements: Record<string, string[]>;
updatedAt?: string;
}> {
const categories = await prisma.moodTagCategory.findMany({
where: { isActive: true, OR: [{ userId: null }, { userId }] },
@@ -48,6 +55,9 @@ async function resolveLayoutResponse(
layout.groupOrder,
),
placements: layout.placements ?? {},
// v1.32.22 (M2) — optimistic-concurrency token; omitted (dropped from
// JSON) when absent so a fresh user's response shape is unchanged.
...(updatedAt !== undefined ? { updatedAt } : {}),
};
}
@@ -55,10 +65,12 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { moodTagLayoutJson: true },
select: { moodTagLayoutJson: true, updatedAt: true },
});
const stored = parseStoredMoodTagLayout(row?.moodTagLayoutJson);
return apiSuccess(await resolveLayoutResponse(user.id, stored));
return apiSuccess(
await resolveLayoutResponse(user.id, stored, row?.updatedAt?.toISOString()),
);
});
export const PUT = apiHandler(async (request: NextRequest) => {
@@ -68,7 +80,16 @@ export const PUT = apiHandler(async (request: NextRequest) => {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = moodTagLayoutSchema.safeParse(rawJsonBody);
// v1.32.22 (M2) — split the optimistic-concurrency base token off before the
// Zod parse. The two cards on this page write DIFFERENT fields of the same
// blob (group order vs placements); a stale merge would resurrect the other
// card's overwritten field, which the token now 409s instead.
const taken = takeBaseToken(rawJsonBody);
if ("invalid" in taken) return invalidBaseTokenError();
const base = taken.base;
const parsed = moodTagLayoutSchema.safeParse(taken.rest);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Preserve-when-absent: a PUT carrying only `groupOrder` must not wipe
@@ -88,10 +109,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
: {}),
});
await prisma.user.update({
where: { id: user.id },
const guarded = await guardedUserUpdate({
userId: user.id,
base,
data: { moodTagLayoutJson: toJson(merged) },
conflict: {
action: "mood.tag.layout.conflict",
errorCode: "mood_tag_layout_conflict",
message: "Mood-tag layout changed since it was loaded",
},
});
if ("conflict" in guarded) return guarded.conflict;
annotate({
action: { name: "mood.tag.layout.update" },
@@ -101,5 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
},
});
return apiSuccess(await resolveLayoutResponse(user.id, merged));
return apiSuccess(
await resolveLayoutResponse(user.id, merged, guarded.updatedAt),
);
});
@@ -0,0 +1,109 @@
/**
* S-1 — the Strava OAuth callback enqueues the self-converging history backfill
* at connect time (mirroring the WHOOP / Fitbit callbacks) so deep history
* lands within the hour instead of waiting for the next worker reboot. A missing
* boss instance is a warning, never a failure — boot discovery is the net.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const {
bossSend,
getGlobalBossMock,
exchangeCodeMock,
getCredsMock,
userFindUnique,
userUpdate,
markReconnectedMock,
} = vi.hoisted(() => ({
bossSend: vi.fn(async () => "job-id"),
getGlobalBossMock: vi.fn(),
exchangeCodeMock: vi.fn(),
getCredsMock: vi.fn(),
userFindUnique: vi.fn(),
userUpdate: vi.fn(async () => ({})),
markReconnectedMock: vi.fn(async () => {}),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: userFindUnique, update: userUpdate } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({
setAuth: vi.fn(),
setError: vi.fn(),
addWarning: vi.fn(),
}),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => {}) }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc(${s})` }));
vi.mock("@/lib/strava/client", () => ({ exchangeCode: exchangeCodeMock }));
vi.mock("@/lib/strava/credentials", () => ({
getStravaClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
oauthStateCookieName: () => "strava_oauth_state",
stateMatchesCookie: () => true,
verifySignedState: () => ({ userId: "u1" }),
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: markReconnectedMock,
}));
vi.mock("@/lib/jobs/strava-backfill", () => ({
STRAVA_BACKFILL_QUEUE: "strava-backfill",
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: getGlobalBossMock,
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function callback(): Promise<{ headers: { get(k: string): string | null } }> {
const req = new NextRequest(
"http://localhost/api/strava/callback?code=abc&state=signed-state",
{ headers: { cookie: "strava_oauth_state=signed-state" } },
);
return (GET as unknown as (r: NextRequest) => Promise<never>)(req);
}
beforeEach(() => {
vi.clearAllMocks();
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeCodeMock.mockResolvedValue({
access_token: "at",
refresh_token: "rt",
athlete: { id: 42 },
});
userFindUnique.mockResolvedValue({ stravaAthleteId: null });
getGlobalBossMock.mockReturnValue({ send: bossSend });
});
describe("GET /api/strava/callback — S-1 backfill enqueue", () => {
it("enqueues one STRAVA_BACKFILL_QUEUE job for the connecting user", async () => {
const res = await callback();
expect(res.headers.get("location")).toContain("strava=connected");
expect(markReconnectedMock).toHaveBeenCalledWith("u1", "strava");
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend).toHaveBeenCalledWith(
"strava-backfill",
expect.objectContaining({ userId: "u1" }),
);
});
it("still connects when the boss instance is unavailable (warning, not failure)", async () => {
getGlobalBossMock.mockReturnValue(null);
const res = await callback();
expect(res.headers.get("location")).toContain("strava=connected");
expect(bossSend).not.toHaveBeenCalled();
});
});
+20
View File
@@ -12,6 +12,8 @@ import {
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { STRAVA_BACKFILL_QUEUE } from "@/lib/jobs/strava-backfill";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -107,6 +109,24 @@ export const GET = apiHandler(async (request: NextRequest) => {
await auditLog("strava.connect", { userId });
await markReconnected(userId, "strava");
// Enqueue the self-converging history backfill at connect time so deep
// history lands within the hour instead of waiting for the next worker
// reboot. Best-effort: the boot-time discovery query
// (`stravaBackfillCompletedAt IS NULL`) is the safety net, so a missing
// boss instance here never strands the connection. Mirrors the WHOOP /
// Fitbit callback enqueue.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(STRAVA_BACKFILL_QUEUE, {
userId,
enqueuedAt: new Date().toISOString(),
})
.catch((err) =>
getEvent()?.addWarning(`strava-backfill enqueue failed: ${err}`),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?strava=connected",
@@ -30,7 +30,7 @@ describe("POST /api/whoop/sync", () => {
beforeEach(() => vi.clearAllMocks());
it("triggers an incremental sync by default", async () => {
sync.mockResolvedValue(7);
sync.mockResolvedValue({ imported: 7, failed: false });
const res = (await (
POST as unknown as (r: NextRequest) => Promise<{ data: unknown }>
)(req({}))) as { data: { imported: number; fullSync: boolean } };
@@ -40,7 +40,7 @@ describe("POST /api/whoop/sync", () => {
});
it("honours fullSync: true", async () => {
sync.mockResolvedValue(99);
sync.mockResolvedValue({ imported: 99, failed: false });
const res = (await (
POST as unknown as (r: NextRequest) => Promise<{ data: unknown }>
)(req({ fullSync: true }))) as { data: { fullSync: boolean } };
+1 -1
View File
@@ -27,6 +27,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
// no body provided -> default incremental sync
}
const imported = await syncUserWhoop(user.id, { fullSync });
const { imported } = await syncUserWhoop(user.id, { fullSync });
return apiSuccess({ imported, fullSync });
});
+26
View File
@@ -107,11 +107,28 @@ export default function LoginPage() {
);
}
// v1.32.11 — first-party web-handoff login (iOS #65). The app opens this page
// as `/auth/login?flow=native` inside an ASWebAuthenticationSession so the
// login runs in the self-hoster's real web origin (fixing password autofill +
// passkeys). It stays true for the whole component so every success path
// (password, passkey button, passkey autofill, MFA step) routes to completion.
const isNativeHandoff = searchParams.get("flow") === "native";
// v1.16.6 first-load waterfall fix — the session cookie exists the
// moment the login POST resolves, so the dashboard snapshot request
// can ride in parallel with the route transition + page-chunk
// download instead of queueing behind the dashboard mount.
function navigateAfterLogin() {
// Native web-handoff: hand control back to the app via the server's
// single-use code mint. Top-level navigation, not router.push — the
// completion answer is a 302 to `healthlog://login-callback` that only the
// browser/OS can follow. `next` is meaningless to the app and ignored; the
// page always requires a fresh interactive login here (no ambient one-click
// pass), which is exactly what the completion route's freshness rule needs.
if (isNativeHandoff) {
window.location.href = "/api/auth/native/complete";
return;
}
// Start the new session from an empty in-memory cache. The root
// QueryClient is created once and outlives every client-side navigation,
// so a previous user who closed the tab WITHOUT logging out (no logout
@@ -319,6 +336,15 @@ export default function LoginPage() {
</div>
</div>
{isNativeHandoff && (
<div
data-slot="native-handoff-banner"
className="bg-primary/10 text-foreground mt-6 rounded-lg p-3 text-center text-sm"
>
{t("auth.native.signingInToApp")}
</div>
)}
{mfaChallenge ? (
<div className="mt-8">
<MfaLoginStep
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// v1.32.14 — the per-message "some figures couldn't be checked" notice renders
// only on a settled assistant turn whose provenance carries unverifiedFigures ≥ 1.
// Mirror the coach-charts harness: SSR render with the hooks the bubble reaches
// for stubbed out.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "tester", role: "USER", avatarUrl: null },
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
}));
import { MessageThread } from "../message-thread";
import type {
CoachConversationDetailDTO,
CoachMessageDTO,
CoachProvenance,
} from "@/lib/ai/coach/types";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
// Distinctive substrings free of HTML-escaped characters, so the assertion does
// not couple to how the SSR renderer escapes the apostrophe in "couldn't".
const EN_COPY_HEAD = "be checked against your data and were left out.";
function conversationWith(
overrides: Partial<CoachMessageDTO>,
): CoachConversationDetailDTO {
const message: CoachMessageDTO = {
id: "m1",
role: "assistant",
content: "Your systolic was […] mmHg last week.",
createdAt: "2026-07-24T10:00:00.000Z",
metricSource: null,
providerType: "openai",
promptVersion: null,
tokensUsed: null,
model: null,
...overrides,
};
return {
id: "c1",
title: "Test",
createdAt: "2026-07-24T10:00:00.000Z",
updatedAt: "2026-07-24T10:00:00.000Z",
messageCount: 1,
fenced: false,
attachmentCount: 0,
messages: [message],
};
}
describe("Coach unverified-figures notice", () => {
it("renders under a settled assistant turn when unverifiedFigures ≥ 1", () => {
const provenance: CoachProvenance = {
windows: [],
metrics: [],
unverifiedFigures: 1,
};
const html = render(
<MessageThread
conversation={conversationWith({ metricSource: provenance })}
/>,
);
expect(html).toContain('data-slot="coach-unverified-notice"');
// Muted meta treatment (UI-STANDARDS §3) — never a warning colour.
expect(html).toContain("text-muted-foreground");
});
it("carries the localized, count-free copy", () => {
const html = render(
<MessageThread
conversation={conversationWith({
metricSource: { windows: [], metrics: [], unverifiedFigures: 3 },
})}
/>,
);
// Count-free sentence works for any n even though unverifiedFigures is 3.
expect(html).toContain(EN_COPY_HEAD);
expect(EN_COPY_HEAD).not.toMatch(/\d/);
});
it("does NOT render when no figure was withheld", () => {
const html = render(
<MessageThread
conversation={conversationWith({
metricSource: { windows: ["last7days"], metrics: ["bp"] },
})}
/>,
);
expect(html).not.toContain('data-slot="coach-unverified-notice"');
});
it("does NOT render when provenance is absent", () => {
const html = render(
<MessageThread conversation={conversationWith({ metricSource: null })} />,
);
expect(html).not.toContain('data-slot="coach-unverified-notice"');
});
it("does NOT render on a refusal turn even with the field set", () => {
const html = render(
<MessageThread
conversation={conversationWith({
providerType: "refusal",
metricSource: { windows: [], metrics: [], unverifiedFigures: 1 },
})}
/>,
);
expect(html).not.toContain('data-slot="coach-unverified-notice"');
});
});
@@ -11,6 +11,7 @@ import {
ChevronRight,
Clock,
Copy,
Info,
Loader2,
RotateCcw,
Sparkles,
@@ -604,6 +605,18 @@ function ChatBubbleImpl({
? selectCoachChartTokens(content, metricSource?.metrics)
: [];
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
// SETTLED assistant turn — skipped while in-flight, on errored and refusal
// turns — mirroring the token-footer gating. Rides `metricSource` so it survives
// reload with no memo change (the comparator already ref-checks metricSource).
const showUnverifiedNotice =
!inProgress &&
!errorCode &&
providerType !== "refusal" &&
!!content &&
(metricSource?.unverifiedFigures ?? 0) > 0;
return (
<div
data-slot="coach-bubble-assistant"
@@ -658,6 +671,19 @@ function ChatBubbleImpl({
{safeError && content && (
<p className="text-warning text-xs">{safeError}</p>
)}
{/* v1.32.14 — quiet grounding caveat when the guard withheld a figure.
Muted meta (UI-STANDARDS §3), never a warning colour — it is a
grounding caveat, not an error. Static single line, decorative icon,
no interactivity. The `[…]` marks in the prose above point here. */}
{showUnverifiedNotice && (
<p
data-slot="coach-unverified-notice"
className="text-muted-foreground flex items-start gap-1.5 text-xs leading-relaxed"
>
<Info aria-hidden="true" className="mt-0.5 size-3 shrink-0" />
{t("insights.coach.unverifiedFiguresNotice")}
</p>
)}
{/* v1.22 (W5) — accompanying chart(s). The token was already
stripped from the prose by <StreamedProse>; here it mounts the
real, self-fetching Recharts chart for the user's own series.
+33 -8
View File
@@ -30,10 +30,16 @@ import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import {
type InsightsLayout,
type InsightsLayoutWithToken,
type InsightsSectionConfig,
type InsightsSectionId,
} from "@/lib/insights-layout";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
/**
* v1.15.11 W3 — inline "Anpassen" edit mode for the customizable Insights
@@ -119,15 +125,21 @@ export function InsightsEditMode({
const saveMutation = useMutation({
mutationFn: async (next: InsightsLayout) => {
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
sections: next.sections,
tiles: next.tiles,
});
// v1.32.21 (R5a) — echo the optimistic-concurrency base token this edit
// was based on so an interleaved write (a Settings pill-order Save, or
// this surface open in another tab) 409s instead of clobbering.
return apiPut<InsightsLayoutWithToken>(
"/api/insights/layout",
withBaseToken(
{ version: 2, sections: next.sections, tiles: next.tiles },
readUpdatedAtToken(queryClient, queryKeys.insightsLayout()),
),
);
},
onSuccess: (saved) => {
// Optimistic-style settle: write the server-resolved layout into the
// shared cache so the overview repaints in the new order, then close.
// Optimistic-style settle: write the server-resolved layout (carrying
// the advanced token) into the shared cache so the overview repaints in
// the new order, then close.
queryClient.setQueryData(queryKeys.insightsLayout(), saved);
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
@@ -135,7 +147,20 @@ export function InsightsEditMode({
toast.success(t("insights.editMode.saveSuccess"));
onClose();
},
onError: () => toast.error(t("insights.editMode.saveError")),
onError: (err) => {
// v1.32.21 (R5a) — explicit-Save disposition: a 409 means someone else
// committed since this edit was based. Refetch so the token advances,
// KEEP the draft (stay in edit mode) so the user can re-save, and nudge
// gently — nothing was lost.
if (isConflict(err)) {
void queryClient.invalidateQueries({
queryKey: queryKeys.insightsLayout(),
});
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(t("insights.editMode.saveError"));
},
});
const resetMutation = useMutation({
+14 -10
View File
@@ -14,7 +14,7 @@ import type {
DailyBriefing as DailyBriefingPayload,
TrendAnnotations,
} from "@/lib/ai/schema";
import { queryKeys } from "@/lib/query-keys";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
@@ -381,15 +381,19 @@ export function useInsightsAdvisorQuery(
// their query subtree so the per-section text below the advisor card
// re-fetches.
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
// v1.18.10 — the dashboard hero surfaces the briefing headline from the
// SNAPSHOT (server-lifted `User.insightsCachedText`), not from this
// advisor cache. A regenerate rewrites that server cache, so invalidate
// the snapshot too — otherwise returning to the Startseite shows the
// previous briefing under the greeting until the snapshot's own stale
// window lapses. Closes "the briefing doesn't update" on the dashboard.
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
});
// v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing
// headline from the SNAPSHOT (server-lifted `User.insightsCachedText`),
// and the Today hero's lead line is the SAME briefing lifted into the
// digest — neither is this advisor cache. A regenerate rewrites that
// server cache, so both the snapshot and the digest must refetch.
// Regenerate runs on `/insights`, where both are UNMOUNTED, so the
// earlier active-only snapshot invalidation only marked it stale (never
// refetched, `refetchOnMount: false`) and never touched the digest at
// all — returning to `/` then showed the previous briefing under the
// greeting AND in the Today lead for up to 120 s. `refetchInactiveDailyReads`
// forces both to refetch right here so the return paints the fresh
// briefing at once.
void refetchInactiveDailyReads(queryClient);
},
});
+19 -3
View File
@@ -93,7 +93,7 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { t } = useTranslations();
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
// v1.24 — the per-marker description used to live on the labs overview rows.
@@ -225,16 +225,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { data: assessment, isLoading: assessmentLoading } =
useInsightBiomarkerAssessment(biomarkerId, readings.length > 0);
// v1.32.19 — a new reading for this marker changes the same-page AI
// assessment card (`insightsBiomarkerAssessment`, mounted here) and the
// Insights "what changed since your last panel" read (`insightsLabsChanges`).
// Both used to stay on their pre-write text until their own stale window
// lapsed; bust them alongside the result list so the assessment repaints as
// soon as the reading lands.
function invalidateAfterReadingWrite() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
queryClient.invalidateQueries({
queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale),
});
queryClient.invalidateQueries({
queryKey: queryKeys.insightsLabsChanges(),
});
}
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
invalidateAfterReadingWrite();
}
// v1.30.1 H3 — "Save & add another": same invalidation as `afterAdd`,
// minus the close. Useful here too — logging several past readings for
// this one biomarker back-to-back without re-opening the sheet each time.
function afterAddKeepOpen() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
invalidateAfterReadingWrite();
}
function afterEditMarker() {
+7
View File
@@ -108,6 +108,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
// v1.32.19 — a lab reading edit / delete / restore shifts the two most
// recent panels the Insights "what changed since your last panel" card
// reads, so bust it too instead of leaving it on its pre-write deltas
// until the 60 s stale window lapses.
queryClient.invalidateQueries({
queryKey: queryKeys.insightsLabsChanges(),
});
}
async function restore(id: string) {
@@ -467,13 +467,45 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
expect(vi.mocked(toast.success)).not.toHaveBeenCalledWith(
expect.stringContaining("successToast"),
);
// The landed take must reach every dependent consumer.
// The landed take must reach every dependent consumer AND force the two
// inactive daily reads (dashboard snapshot + Today digest) to refetch —
// the blessed `invalidateMedicationReads` fans out the bundle (N keys)
// then invalidates those two with `refetchType: "inactive"`.
expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(
medicationDependentKeys.length,
medicationDependentKeys.length + 2,
);
vi.unstubAllGlobals();
});
it("forces the inactive Today hero + dashboard reads to refetch after a landed take (v1.32.19 stale-hero fix)", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse("evt-a"));
vi.stubGlobal("fetch", fetchMock);
const queryClient = fakeQueryClient();
await runTakeAllDue({
medications: [dueTwo[0]],
t,
queryClient,
});
// The batch button lives on `/medications`, where the digest + snapshot
// are UNMOUNTED. A bare bundle invalidation (default active refetch) would
// leave them stale — so the helper MUST invalidate both with
// `refetchType: "inactive"`, which is what actually refetches them.
const calls = vi.mocked(queryClient.invalidateQueries).mock.calls;
const inactive = calls.filter(
([arg]) =>
(arg as { refetchType?: string } | undefined)?.refetchType ===
"inactive",
);
const keys = inactive.map(([arg]) =>
JSON.stringify((arg as { queryKey?: unknown }).queryKey),
);
expect(keys).toContain(JSON.stringify(["dashboard", "snapshot"]));
expect(keys).toContain(JSON.stringify(["daily", "digest"]));
vi.unstubAllGlobals();
});
it("reports total failure without invalidating (nothing changed server-side)", async () => {
vi.stubGlobal(
"fetch",
@@ -491,13 +523,15 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => {
vi.unstubAllGlobals();
});
it("invalidates exactly the medicationDependentKeys bundle (source pin)", () => {
// The bundle includes the dashboard snapshot key (v1.16.11), so a
// batch take refreshes the hero dose tally too. Pin the source so a
// refactor to a hand-rolled key list cannot slip through.
it("routes the batch invalidation through the blessed invalidateMedicationReads (source pin)", () => {
// v1.32.19 — the batch take now routes through the one blessed helper,
// which fans out `medicationDependentKeys` AND forces the inactive digest
// + snapshot to refetch (the maintainer's stale-hero repro). Pin the
// source so a revert to a bare bundle invalidation cannot slip through.
const src = readFileSync(resolve(__dirname, "../take-all-due.ts"), "utf8");
expect(src).toContain(
"await invalidateKeys(queryClient, medicationDependentKeys)",
expect(src).toContain("await invalidateMedicationReads(queryClient)");
expect(src).not.toContain(
"invalidateKeys(queryClient, medicationDependentKeys)",
);
});
});
@@ -28,7 +28,11 @@ import { Label } from "@/components/ui/label";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import {
invalidateKeys,
medicationDependentKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import type { QueryKey } from "@tanstack/react-query";
import type { LedgerSchedule } from "@/components/medications/dose-history-ledger";
@@ -174,6 +178,10 @@ export function LedgerAddDialog({
...medicationDependentKeys,
ledgerKey,
]);
// A ledger add can land a today-dated dose, so clear the unmounted
// Today hero + dashboard too (v1.32.19 — same class as the ledger's
// own log/edit/delete sites).
await refetchInactiveDailyReads(queryClient);
setNudgeOpen(false);
onOpenChange(false);
} catch {
@@ -87,6 +87,7 @@ import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
@@ -295,6 +296,10 @@ export function DoseHistoryLedger({
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch {
if (prev) queryClient.setQueryData(queryKey, prev);
toast.error(
@@ -334,6 +339,10 @@ export function DoseHistoryLedger({
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
toast.success(t("medications.detail.intake.edit.savedToast"));
} catch {
toast.error(t("medications.detail.intake.edit.failed"));
@@ -365,6 +374,10 @@ export function DoseHistoryLedger({
...medicationDependentKeys,
queryKey,
]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch {
toast.error(t("medications.detail.intake.edit.failed"));
}
@@ -380,6 +393,10 @@ export function DoseHistoryLedger({
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
toast.success(t("medications.detail.intake.deleteRow.toast"));
await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]);
// The ledger also invalidates its own window key above; the daily
// reads (Today hero + dashboard) are unmounted behind the detail
// page, so force their inactive refetch too (v1.32.19).
await refetchInactiveDailyReads(queryClient);
} catch {
toast.error(t("medications.detail.intake.deleteRow.failed"));
}
@@ -32,7 +32,7 @@ import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiPut } from "@/lib/api/api-fetch";
export interface IntakeEditDialogProps {
@@ -129,7 +129,10 @@ function IntakeEditDialogBody({
body.takenAt = null;
}
await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body);
await invalidateKeys(queryClient, medicationDependentKeys);
// Editing today's dose (un-skip, retime) changes its due/taken state —
// route through the blessed helper so the Today hero + dashboard clear
// at once even when they are unmounted behind the detail page.
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.edit.savedToast"));
onClose();
} catch {
@@ -40,7 +40,7 @@ import {
type IntakeHistorySortKey,
} from "@/components/medications/intake-history-list-v2";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
export interface IntakeHistoryEditableProps {
@@ -94,7 +94,7 @@ export function IntakeHistoryEditable({
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
eventIds: ids,
});
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.bulkDelete.toast"));
setBulkConfirmOpen(false);
clearSelection();
@@ -111,7 +111,7 @@ export function IntakeHistoryEditable({
const id = pendingDeleteId;
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.deleteRow.toast"));
setPendingDeleteId(null);
// Drop the deleted row from the pending selection so the
@@ -32,7 +32,7 @@ import {
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
MedicationIntakeImportError,
@@ -124,7 +124,10 @@ export function IntakeImportDialog({
: ""),
);
setResultType("success");
void invalidateKeys(queryClient, medicationDependentKeys);
// An import can land today-dated doses, so clear the Today hero +
// dashboard through the blessed helper (they are unmounted behind the
// import dialog on the detail page).
void invalidateMedicationReads(queryClient);
} catch (err) {
if (err instanceof MedicationIntakeImportError) {
setResult(t("medications.importFailed"));
@@ -21,6 +21,11 @@
*/
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm";
// Re-exported so the existing `dose-window` consumers (and its unit suite)
// keep their import site while the implementation lives in the shared util.
export { hhmmToMinutes };
/** One persisted explicit window. Matches `doseWindowEntrySchema`. */
export interface DoseWindowEntry {
@@ -40,12 +45,6 @@ export type DoseWindowScale = "intraday" | "dayScale";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
/** Minutes-since-midnight for a regex-valid HH:mm. */
export function hhmmToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":");
return Number(h) * 60 + Number(m);
}
/** Wrap minutes-since-midnight back to HH:mm (clamps into the day, 24h wrap). */
export function minutesToHhmm(total: number): string {
const wrapped = ((total % 1440) + 1440) % 1440;
@@ -46,11 +46,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
@@ -89,7 +85,7 @@ export function LifecycleManageBody({
// v1.5.5 C-E3-2 — body carries `{ active }` only; server derives
// pausedAt. No client-side pausedAt literal.
await apiPut(`/api/medications/${medicationId}`, { active: !pauseNext });
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(
pauseNext
? t("medications.detail.zone.pause.pausedToast")
@@ -110,7 +106,7 @@ export function LifecycleManageBody({
try {
const today = new Date().toISOString();
await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
onAfterAction?.();
@@ -246,7 +242,7 @@ export function DangerZoneBody({
setTier3aBusy(true);
try {
await apiDelete(`/api/medications/${medicationId}/intake/purge`);
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.purge.toast"));
setPurgeDialogOpen(false);
onAfterAction?.();
@@ -281,7 +277,7 @@ export function DangerZoneBody({
// Fire-and-forget the dependent-bundle invalidation (list /
// analytics / compliance / inline chart). Not awaited: navigation
// already happened and a transient refetch must not block it.
void invalidateKeys(queryClient, medicationDependentKeys);
void invalidateMedicationReads(queryClient);
} catch {
toast.error(t("medications.detail.zone.delete.failed"));
setTier3bBusy(false);
+11 -6
View File
@@ -39,7 +39,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiPost } from "@/lib/api/api-fetch";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import {
reduceCurrentWindowStatus,
type ScheduleWindowInput,
@@ -230,12 +230,17 @@ export async function runTakeAllDue(deps: {
}
}
// One invalidation for the whole batch (the bundle includes the
// dashboard snapshot key, so the hero dose tally refreshes too). A
// zero-success run changed nothing server-side — skip the refetch and
// keep the due set as-is for the retry.
// One invalidation for the whole batch through the blessed helper: it
// fans out the dependent-key bundle AND forces the inactive digest +
// snapshot to refetch. The batch button lives on `/medications`, where the
// Today hero and dashboard are UNMOUNTED, so a bare bundle invalidation
// (default `refetchType: "active"`) marked them stale but never refetched —
// the taken doses stayed "due" on the hero until the 120 s poll or a hard
// reload (the maintainer's exact repro, v1.32.19). A zero-success run
// changed nothing server-side — skip the refetch and keep the due set for
// the retry.
if (taken > 0) {
await invalidateKeys(queryClient, medicationDependentKeys);
await invalidateMedicationReads(queryClient);
}
if (failed === 0) {
@@ -5,44 +5,18 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
/**
* Invalidate every read that reflects a dose's taken/due state after an
* intake write. `invalidateKeys` invalidates with the default
* `refetchType: "active"`, which only refetches mounted queries — so the
* dashboard snapshot AND the Today digest, both inactive while the user is on
* the medication card or detail page, are marked stale but never refetched. On
* navigating back each remounts under `refetchOnMount: false`, so the pre-write
* cache is served and the "due" prompt (snapshot dose tally) plus the digest's
* dose-window rail item both linger until a hard reload. Force the inactive
* queries to refetch so the dashboard clears as soon as the dose is recorded.
*
* v1.29.1 — the digest joins the forced-inactive refetch. Its `medsToday`
* reads the SAME server snapshot cell the intake route already hard-evicts, so
* the refetch returns post-write dose state immediately (no server change
* needed — the eviction seam is reused).
* Every intake write in this hook clears the Today hero + dashboard through
* the one blessed `invalidateMedicationReads` (in `@/lib/query-keys`): it
* pairs the `medicationDependentKeys` bundle invalidation with a forced
* inactive refetch of the digest + snapshot, so a taken / skipped / undone
* dose repaints the moment it lands rather than on the 120 s poll. The helper
* used to live inline here (v1.29.1); v1.32.19 lifted it into the factory as
* the single canonical entry point every intake surface routes through.
*/
async function invalidateMedicationReads(
queryClient: QueryClient,
): Promise<void> {
await invalidateKeys(queryClient, medicationDependentKeys);
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardSnapshot(),
refetchType: "inactive",
}),
queryClient.invalidateQueries({
queryKey: queryKeys.dailyDigest(),
refetchType: "inactive",
}),
]);
}
type Translator = (
key: string,
+28 -1
View File
@@ -59,6 +59,12 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -72,6 +78,7 @@ import {
snapshotManageCache,
updateManageCache,
type ManageCatalog,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
/**
@@ -141,11 +148,31 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
reorderGroups(current, nextOrder),
);
try {
await apiPut("/api/mood/tags/layout", { groupOrder: nextOrder });
// v1.32.22 (R5b) — echo the base token this order was based on. The two
// cards on this page write DIFFERENT fields of the same blob (group order
// vs tag placements); a stale merge would resurrect the other card's
// overwritten field, which the token now 409s instead. Seed the token
// from the response so the next write on this surface is guarded too.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ groupOrder: nextOrder },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
if (isConflict(err)) {
// Drop the stale token so a deliberate retry writes against the tree
// the invalidation below reloads; nudge — the optimistic delta rolled
// back and nothing was clobbered.
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return false;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
@@ -47,6 +47,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ApiError, apiPatch, apiPut } from "@/lib/api/api-fetch";
import {
readUpdatedAtToken,
withBaseToken,
isConflict,
} from "@/lib/api/optimistic-token";
import { queryKeys } from "@/lib/query-keys";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -65,6 +71,7 @@ import {
type ManageCatalog,
type ManageCategory,
type ManageTag,
type MoodTagLayoutResolved,
} from "./use-mood-tag-manage";
/**
@@ -139,13 +146,26 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
const rollback = await snapshotManageCache(queryClient);
updateManageCache(queryClient, () => mergeBack(catalog, nextCatalog));
try {
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(nextCatalog),
});
// v1.32.22 (R5b) — echo + seed the layout token (see `putGroupOrder` in
// the groups card): the sibling group-order card writes the same blob, so
// a stale placements merge is exactly the overwrite the token 409s.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ placements: buildVisiblePlacements(nextCatalog) },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("mood.manage.orderSaved"));
return true;
} catch (err) {
rollback();
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return false;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
return false;
} finally {
@@ -237,12 +257,24 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
}
// Both kinds pin the resulting per-group order in the layout blob
// so the resolved tree matches what the user just saw happen.
await apiPut("/api/mood/tags/layout", {
placements: buildVisiblePlacements(moved),
});
// v1.32.22 (R5b) — echo + seed the layout token so a concurrent write to
// the same blob 409s instead of clobbering.
const resolved = await apiPut<MoodTagLayoutResolved>(
"/api/mood/tags/layout",
withBaseToken(
{ placements: buildVisiblePlacements(moved) },
readUpdatedAtToken(queryClient, queryKeys.moodTagLayout()),
),
);
queryClient.setQueryData(queryKeys.moodTagLayout(), resolved);
toast.success(t("common.saved"));
} catch (err) {
rollback();
if (isConflict(err)) {
queryClient.removeQueries({ queryKey: queryKeys.moodTagLayout() });
toast.message(t("common.conflictReloaded"));
return;
}
toast.error(err instanceof ApiError ? err.message : t("common.error"));
} finally {
void invalidateMoodTagCaches(queryClient);
@@ -62,6 +62,19 @@ export interface ManageCatalog {
categories: ManageCategory[];
}
/**
* v1.32.22 (R5b) — the resolved `GET/PUT /api/mood/tags/layout` response.
* `updatedAt` is the optimistic-concurrency token the two management cards
* echo on their next write; no active query reads this key (it is only ever
* invalidated), so the token is seeded straight from each write response into
* `queryKeys.moodTagLayout()` and read back at mutate time.
*/
export interface MoodTagLayoutResolved {
groupOrder: string[];
placements: Record<string, string[]>;
updatedAt?: string;
}
export function useMoodTagManage(enabled: boolean) {
return useQuery({
queryKey: queryKeys.moodTagManage(),
@@ -8,6 +8,8 @@ import {
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_IDS,
IOS_PIN_ONLY_WIDGET_IDS,
buildRingMutationPayload,
widgetWritesBusy,
type DashboardLayout,
} from "@/lib/dashboard-layout";
@@ -540,10 +542,72 @@ describe("<DashboardLayoutSection> — health-score anchor + reorder (v1.27.27)"
),
"utf8",
);
expect(src).toContain("buildRingMutationPayload(next)");
// v1.32.16 — the ring body is built via `buildRingMutationPayload`,
// now also threading the optimistic-concurrency base token.
expect(src).toContain("buildRingMutationPayload({ ...next, baseUpdatedAt");
// The pre-fix shape must never come back: spreading a query-cache
// snapshot into the ring PUT body is exactly the stale-`widgets`
// race issue #581 reported.
expect(src).not.toMatch(/\.\.\.remote,/);
});
});
/**
* v1.32.16 (issue #581) — a committed Save must stay authoritative against a
* concurrent instant hero-ring write. The full DOM interaction (fire a ring
* write, then a Save, assert the disabled/pending gate) can't run under this
* suite's SSR-only convention, so the load-bearing client contract is pinned
* as pure exported units plus source pins for the wiring the JSX depends on.
*/
describe("dashboard layout — Save cannot be clobbered by a ring write (#581)", () => {
it("buildRingMutationPayload never carries widgets and threads the base token", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
});
// None of the fields a stale snapshot could carry and use to overwrite
// a fresher Save.
expect(payload).not.toHaveProperty("widgets");
expect(payload).not.toHaveProperty("comparisonBaseline");
expect(payload).not.toHaveProperty("chartOverlayPrefs");
// The ring selection + the guard token DO ride.
expect(payload.selectedScoreRings).toEqual(["READINESS"]);
expect(payload.baseUpdatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("buildRingMutationPayload omits the base-token key when none is known", () => {
const payload = buildRingMutationPayload({
selectedScoreRings: [],
heroRingOrder: ["HEALTH_SCORE"],
});
expect(payload).not.toHaveProperty("baseUpdatedAt");
});
it("widgetWritesBusy gates off EITHER write being in flight", () => {
expect(widgetWritesBusy(false, false)).toBe(false);
expect(widgetWritesBusy(true, false)).toBe(true); // Save pending
expect(widgetWritesBusy(false, true)).toBe(true); // ring pending
expect(widgetWritesBusy(true, true)).toBe(true);
});
it("the Save + ring controls bind the shared busy gate and serialise via one scope (source pin)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/settings/dashboard-layout-section.tsx",
),
"utf8",
);
// Save button + ring controls both read `widgetWritesBusy(...)` so the
// gate is one source of truth — the button can never disable on
// `saveMutation.isPending` alone (the pre-fix hole).
expect(src).toContain("widgetWritesBusy(");
// Both writes share a mutation scope so they run serially at the client.
expect(src).toContain("scope: { id: DASHBOARD_WIDGETS_MUTATION_SCOPE }");
// Each write sends the optimistic-concurrency base token.
expect(src).toContain("baseUpdatedAt: readBaseToken()");
// A 409 is handled (refetch + gentle nudge), not swallowed as a hard error.
expect(src).toContain("err.status === 409");
});
});
@@ -38,7 +38,14 @@ const mutateSpy = vi.fn();
const invalidateSpy = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
useQueryClient: () => ({
invalidateQueries: invalidateSpy,
// v1.32.22 (M3) — the writer reads/seeds the optimistic-concurrency token
// via the query cache; no token seeded → tokenless (unconditional) write.
getQueryData: () => undefined,
setQueryData: vi.fn(),
removeQueries: vi.fn(),
}),
useMutation: (config: MutationConfig) => {
lastMutation = config;
return { mutate: mutateSpy, isPending: false };
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more