diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba3240aa..90c38799e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,137 @@ # Changelog +## [1.38.16] — 2026-09-10 + +The API says what it refused and why, on every route, and the admin page +says whether last night's off-host copy actually landed for each account. + +### Added + +- **Off-host backup freshness per account.** The nightly job reported + counts, which cannot name the failing account. It now keeps a ledger row + per account (`OffhostBackupState`: last attempt, last success, size), + written the moment the object is durably in the bucket and under its own + error boundary, so a ledger write that fails cannot turn a landed backup + into a failure. Admin → Backups shows one row per account with a state + judged against the nightly schedule with six hours of grace for the Berlin + clock: `fresh` (within one period), `due` (within two), `stale`, `never` + (walked by a run, no object yet) and `unknown` (labelled "No record yet" + in the console; the honest answer on the first day after this release). + Stale first. Never a bucket listing; an empty state says so when off-host + backup is not configured. The row is in the wipe plan and registered as + not part of the backup itself. Runbook updated. + +- **The server side of the needs-based onboarding.** A record now holds its + onboarding answers (who the record is for, the areas to watch, medication + on a schedule, where readings come from, an upcoming visit, units), the + ordered steps with stable ids and a status each, when the flow completed + and which first result was offered. `GET /api/auth/me` publishes it as + `onboarding` for the record the payload describes; + `PATCH /api/onboarding/answers` takes one step at a time and is + idempotent, `POST /api/onboarding/complete` derives the module map from + the answers exactly once and only when every question is answered or + passed, `POST /api/onboarding/restart` re-asks the questions and keeps the + modules. The derivation turns on what the answers name and writes an + explicit off for the rest, but never turns off a module the person + switched on by hand and never one whose domain already holds rows, so + nothing is lost by a choice; the mapping from needs to modules lives in + the module registry next to the keys. The getting-started checklist orders + its items from the answers and no longer reads every account that never + entered the flow as unfinished. The web wizard that asks these questions + follows in a later release; today's first-run steps are unchanged. + +- **The error-reporting card tells the truth.** A green "Configured" badge + appeared from a DSN alone, even with sending off, and never said where + reports go. The forwarder now records the last successful and the last + failed delivery (timestamps and a short reason, never a response body); + the badge is green only when a report left within fourteen days, amber + otherwise with the reason, and the card names the target host, never the + key. `docs/ops/deploy.md` gains a note on setting it for an instance you + operate yourself; there is deliberately no default target. + +### Fixed + +- **A cycle-only share no longer sees the rest of the record.** + `GET /api/cycle/insights` was declared for the `cycle` section but + returned luteal and follicular averages for weight, resting heart rate, + HRV, sleep, steps, glucose and mood score, which are `measurements` and + `mind` data, to a delegate whose grant opened only the cycle. The route + asks the grant per section now and skips the queries it does not cover; + the owner and an unscoped grant get the same answer as before. One helper + answers "which sections does this grant open" for every route; two + verbatim copies of it are gone. + +- **Controls a delegate could not use are not offered.** The vaccination + pass showed add, edit and delete to a read-level delegate; a visit row + opened an editable sheet with Save and Delete for read and write levels; + the capture surfaces and the dashboard's quick entry offered kinds whose + section the grant did not open; nine pages stayed open to any delegate + because of a coarse "in a shared record" test. Each control asks the + section its own route requires (`canWriteDomain` / `canManageDomain`), the + coarse "may this person add" answer is retired, the nine page gates read + the record's own module map, and a structural guard sweeps every component + for the shape rather than naming files, because the first sweep found six + and missed three. + +- **A wipe, an account deletion and an encrypted export refuse under an + active switch.** They always did (the fresh-second-factor gate opens with + the plain authentication that refuses any switch) but the contract did not + say so; the three operations now publish the refusal, and a test drives it + through the browser's own carrier. + +- **A booster reminder is not minted for a record its grant does not + cover.** The mint refuses rather than silently skipping, with a test that + no row is written. + +### Changed + +- **Every shape refusal carries the issue list.** 59 refusal sites in 57 + routes answered a schema refusal with a bare 422 and a sentence; they go + through the shared helper now, so `details.issues` names each field the + way the rest of the tree already did. `error` and `meta` are unchanged; + six of the eleven dotted tokens that are still the whole `error` string + also ride as `meta.errorCode`, the five Coach reminder tokens do not yet. + A structural sweep reads every `safeParse` refusal in `src/app/api` and + fails when one bypasses the helper; its allowlist is empty. A second sweep + inventories the eleven token-string 422s, each with a written reason: one + is a published contract, five keep the string because the status did not + move and the token rides in `meta.errorCode` as well, five are the Coach + reminder surfaces that move as one piece or not at all. + +- **A body that is not JSON at all is a 400 everywhere.** Seven preference + routes answered 422 with a dotted token as the whole message; they answer + 400 with a sentence and the same token in `meta.errorCode`, the status + roughly two hundred other routes already use. A body that parses but fails + the schema is still the 422 beside it. The native client branches on + `meta.errorCode`, which did not change; the health-score configuration + route keeps its published 422 with a written reason. + `PUT /api/auth/me/report-selection` also names the leaf ids it does not + know in `meta.unknownLeaves` instead of refusing with a bare token. + +- **Two more writes honour `Idempotency-Key`.** The GLP-1 log and the custom + cycle symptom, the two row-creating writes the native client's offline + queue is most likely to replay after a lost response, join the routes that + return the first answer with `X-Idempotent-Replay: true` instead of a + second row. Documented per route. + +- **Single-record writes share one generous per-account bucket.** Eleven + routes that create or edit one row had no rate limit at all; they share + `record-write:` at 300 per minute, keyed on the acting account, + applied after authentication, with the 429 carrying `Retry-After`, the + `X-RateLimit-*` triple and `meta.errorCode = record_write.rate_limited` so + a client can tell the shared bucket from a route's own. The batch + endpoints keep their own buckets. A test pulls the key and the window out + of the limiter's query and holds the documentation to them. + +- **The error codes are a published list.** 257 `errorCode` values across 47 + surfaces, plus a group for the handful that carry no prefix, are + catalogued in `src/lib/openapi/error-codes.ts` and rendered into the error + envelope's description, grouped by surface. A guard walks the import graph + from every route (about fourteen hundred modules, the generated client + never opened) and fails in both directions: a code emitted anywhere a + route can reach but absent from the catalogue, and a catalogue entry + nothing emits. Existing codes keep their spelling. + ## [1.38.15] — 2026-09-10 The rest of #939 for managed profiles, one figure for medication adherence diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e468f3627..74e680555 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.15 + version: 1.38.16 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -117,7 +117,7 @@ paths: `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than guessing. A 429 raised by a ceiling that is not one of these buckets — a daily AI budget, an upstream provider's own refusal relayed onward — carries none of them, because there is no bucket to describe. - headers: + headers: &a8 Retry-After: description: Whole seconds to wait before retrying. Rounded up, never below 1. schema: @@ -152,6 +152,22 @@ paths: burns their own allowance rather than locking the owner out and cannot collect a fresh one by switching records. Audits as `cycle.symptom.custom.create` with the icon only — never the label. Delegable at MANAGE level over the `cycle` section: the record's own symptom vocabulary is what the day-log writes need in order to say anything." + parameters: + - &a6 + name: Idempotency-Key + in: header + required: false + schema: + type: string + pattern: ^[A-Za-z0-9_\-:.]{8,128}$ + minLength: 8 + maxLength: 128 + description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the + work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED + rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no + key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different + route is a different key. While a first request under this key is still in flight, a second is refused with + 409 rather than queued." requestBody: required: true content: @@ -167,6 +183,25 @@ paths: $ref: "#/components/schemas/CycleCustomSymptomCreatedEnvelope" "401": *a1 "403": *a2 + "409": &a7 + description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry + after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." + headers: + X-Idempotent-Replay: + required: true + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + schema: + type: string + enum: + - "true" + - "false" + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` (an empty or over-40-character label, an icon outside the allow-list, a @@ -332,21 +367,7 @@ paths: description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." parameters: - - &a6 - name: Idempotency-Key - in: header - required: false - schema: - type: string - pattern: ^[A-Za-z0-9_\-:.]{8,128}$ - minLength: 8 - maxLength: 128 - description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the - work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED - rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no - key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different - route is a different key. While a first request under this key is still in flight, a second is refused with - 409 rather than queued." + - *a6 requestBody: required: true content: @@ -368,27 +389,23 @@ paths: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" "401": *a1 "403": *a2 - "409": &a7 - description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry - after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." - headers: - X-Idempotent-Replay: - required: true - description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that - refuses a duplicate still in flight. Absent when the request carried no usable key." - schema: - type: string - enum: - - "true" - - "false" - description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that - refuses a duplicate still in flight. Absent when the request carried no usable key." + "409": *a7 + "422": *a4 + "429": &a10 + description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — + `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns + their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is + `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when + both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` + headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against + the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 + spends the same allowance as one that writes rows. A client with more than a handful of rows to send should + use the batch endpoint for its domain instead of looping this one." + headers: *a8 content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 - "429": *a5 /api/cycle/day-logs/{id}: patch: tags: @@ -564,7 +581,11 @@ paths: temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. - Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off." + Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` + section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives + the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged + discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are + unchanged." responses: "200": description: Phase-correlation rows + headline. @@ -672,6 +693,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" + "400": &a12 + description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names + the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not + a validation failure — a body that parses but fails the schema is the 422 beside this. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "422": *a4 "429": *a5 @@ -844,7 +873,7 @@ paths: type: string format: binary "401": *a1 - "403": &a8 + "403": &a9 description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the @@ -1764,6 +1793,20 @@ paths: type: string format: binary "401": *a1 + "403": + description: >- + The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access + token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/check-user: @@ -3446,7 +3489,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series: @@ -3498,7 +3541,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements: @@ -3695,7 +3738,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -3734,7 +3777,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. @@ -3750,7 +3793,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/measurements/{id}: get: tags: @@ -3771,7 +3814,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3819,7 +3862,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3855,7 +3898,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3957,7 +4000,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -3986,7 +4029,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -4017,7 +4060,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a9 + "403": &a11 description: >- Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a @@ -4058,7 +4101,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/personal-records: @@ -4105,7 +4148,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4160,7 +4203,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4196,7 +4239,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a12 + "403": &a15 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -4365,7 +4408,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -4490,7 +4533,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/medications/{id}/glp1: get: tags: @@ -4515,7 +4558,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4542,6 +4585,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -4598,13 +4642,14 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/api-endpoint: @@ -4732,7 +4777,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4848,7 +4893,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4887,7 +4932,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4932,7 +4977,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -4980,7 +5025,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5028,7 +5073,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No event with that id under this medication and record. content: @@ -5071,7 +5116,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5113,7 +5158,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5158,7 +5203,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5238,7 +5283,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -5269,7 +5314,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -5289,7 +5334,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -5323,7 +5368,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a10 + "422": &a13 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -5368,7 +5413,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5405,7 +5450,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5478,7 +5523,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5487,7 +5532,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 get: tags: - Medications @@ -5552,7 +5597,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5585,7 +5630,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5621,7 +5666,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5665,7 +5710,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5700,7 +5745,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5778,7 +5823,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5806,7 +5851,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -5981,7 +6026,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6019,7 +6064,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6063,7 +6108,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication or revision not found (or owned by another user). content: @@ -6104,7 +6149,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication or revision not found (or owned by another user). content: @@ -6167,7 +6212,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A dose-history import is already in progress. content: @@ -6229,7 +6274,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such job for this medication (or owned by another user). content: @@ -6263,7 +6308,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such job for this account (or owned by another user). content: @@ -6293,7 +6338,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/ai/test: @@ -6483,6 +6528,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" + "400": *a12 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -6492,7 +6538,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/auth/me/disable-coach: get: @@ -6530,6 +6576,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -6684,6 +6731,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" + "400": *a12 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -6693,7 +6741,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/auth/me/source-priority: get: @@ -7524,6 +7572,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -7710,6 +7759,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" + "400": *a12 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7719,7 +7769,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/auth/me/report-selection: get: @@ -7744,7 +7794,8 @@ paths: - Auth summary: Replace the owner's saved doctor-report selection description: Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is - a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved + a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` + `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer. requestBody: @@ -7760,6 +7811,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -8734,7 +8786,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 delete: tags: @@ -8823,7 +8875,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -8938,7 +8990,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/anamnesis/facts: get: @@ -8956,7 +9008,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -9066,7 +9118,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a11 + "404": &a14 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9102,7 +9154,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a11 + "404": *a14 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -9834,7 +9886,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -9892,7 +9944,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10203,7 +10255,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10226,7 +10278,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10272,7 +10324,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10309,7 +10361,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -10344,7 +10396,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -10418,7 +10470,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/analytics/range: @@ -10488,7 +10540,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -10517,7 +10569,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -10546,7 +10598,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -10575,7 +10627,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -10604,7 +10656,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -10633,7 +10685,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -10725,7 +10777,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -10767,7 +10819,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived: @@ -10876,7 +10928,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -10915,7 +10967,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -10935,7 +10987,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg: @@ -10958,7 +11010,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 post: @@ -10996,7 +11048,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11036,7 +11088,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11068,7 +11120,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11089,7 +11141,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11107,7 +11159,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11137,7 +11189,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "404": description: No current pattern with that id for this account. content: @@ -11162,7 +11214,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11181,7 +11233,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11200,7 +11252,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11223,7 +11275,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/mood/insights: @@ -11245,7 +11297,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11268,7 +11320,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -11378,7 +11430,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a13 + "404": &a16 description: Not found / not owned by the caller. content: application/json: @@ -11413,7 +11465,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11449,7 +11501,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11502,7 +11554,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 delete: @@ -11533,7 +11585,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11581,7 +11633,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/mood-entries: get: @@ -11664,7 +11716,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -11697,8 +11749,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "409": &a14 + "403": *a9 + "409": &a17 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -11707,7 +11759,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/mood-entries/{id}: get: tags: @@ -11729,8 +11781,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a13 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 put: @@ -11764,9 +11816,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a13 - "409": *a14 + "403": *a9 + "404": *a16 + "409": *a17 "422": *a4 "429": *a5 delete: @@ -11791,8 +11843,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a13 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11821,7 +11873,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11850,7 +11902,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11894,7 +11946,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -11942,7 +11994,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a15 + "400": &a18 description: Body is not parseable JSON. content: application/json: @@ -11955,13 +12007,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a16 + "413": &a19 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a17 + "415": &a20 description: Content-Type is not `application/json`. content: application/json: @@ -12020,7 +12072,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a15 + "400": *a18 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12035,8 +12087,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a16 - "415": *a17 + "413": *a19 + "415": *a20 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12090,10 +12142,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a15 + "400": *a18 "401": *a1 - "413": *a16 - "415": *a17 + "413": *a19 + "415": *a20 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -12119,7 +12171,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/settings/global-services: @@ -12206,8 +12258,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: @@ -12445,8 +12504,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: @@ -12575,7 +12641,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a18 + "400": &a21 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." content: @@ -12624,7 +12690,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a18 + "400": *a21 "401": *a1 "422": *a4 "429": *a5 @@ -12860,7 +12926,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -12885,7 +12951,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -12909,8 +12975,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a8 - "404": &a19 + "403": *a9 + "404": &a22 description: Measurement reminder not found / not owned. content: application/json: @@ -12944,8 +13010,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 delete: @@ -12967,8 +13033,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -12992,8 +13058,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13020,8 +13086,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13050,8 +13116,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13084,8 +13150,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13125,8 +13191,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/labs: @@ -13198,7 +13264,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13225,10 +13291,10 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/labs/{id}: get: tags: @@ -13249,8 +13315,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a8 - "404": &a20 + "403": *a9 + "404": &a23 description: Lab result not found (or owned by another user). content: application/json: @@ -13284,8 +13350,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 delete: @@ -13307,8 +13373,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 /api/labs/restore: @@ -13334,7 +13400,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13438,7 +13504,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13463,15 +13529,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a8 - "409": &a22 + "403": *a9 + "409": &a25 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/biomarkers/{id}: get: tags: @@ -13492,8 +13558,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a8 - "404": &a21 + "403": *a9 + "404": &a24 description: Biomarker not found (or owned by another user). content: application/json: @@ -13527,9 +13593,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a8 - "404": *a21 - "409": *a22 + "403": *a9 + "404": *a24 + "409": *a25 "422": *a4 "429": *a5 delete: @@ -13552,7 +13618,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a21 + "404": *a24 "422": *a4 "429": *a5 /api/encounters: @@ -13610,7 +13676,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13636,10 +13702,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/encounters/suggest: get: tags: @@ -13667,7 +13733,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -13690,8 +13756,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a8 - "404": &a23 + "403": *a9 + "404": &a26 description: Visit not found (or owned by another user). content: application/json: @@ -13724,8 +13790,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a23 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13749,8 +13815,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a23 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13775,8 +13841,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a23 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13806,7 +13872,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a23 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13835,7 +13901,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a23 + "404": *a26 "422": *a4 "429": *a5 /api/practitioners: @@ -13865,7 +13931,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13889,7 +13955,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13913,8 +13979,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a8 - "404": &a24 + "403": *a9 + "404": &a27 description: Practitioner not found (or owned by another user). content: application/json: @@ -13947,8 +14013,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13971,8 +14037,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -13997,8 +14063,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/vaccinations: @@ -14041,7 +14107,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14068,10 +14134,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/vaccinations/{id}: get: tags: @@ -14092,8 +14158,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": &a25 + "403": *a9 + "404": &a28 description: Vaccination not found (or owned by another user). content: application/json: @@ -14127,8 +14193,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14152,8 +14218,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14177,8 +14243,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14186,12 +14252,19 @@ paths: tags: - Records summary: Plan a booster reminder from a dose - description: "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: + description: >- + Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as - `vaccination.booster.planned`." + `vaccination.booster.planned`. + + + The operation is declared under the health background and the row it mints belongs to the measurements section, + so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 + `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does + not. parameters: - in: path name: id @@ -14218,8 +14291,26 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": + description: >- + Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there + (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14247,7 +14338,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -14277,7 +14368,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a25 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14306,7 +14397,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a25 + "404": *a28 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14339,7 +14430,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14365,7 +14456,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14389,8 +14480,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": &a26 + "403": *a9 + "404": &a29 description: Illness episode not found (or owned by another user). content: application/json: @@ -14424,8 +14515,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14448,8 +14539,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14476,8 +14567,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "409": *a7 "422": *a4 "429": *a5 @@ -14509,8 +14600,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14560,8 +14651,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 post: @@ -14597,8 +14688,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14625,7 +14716,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a26 + "404": *a29 "422": *a4 "429": *a5 /api/illness/insights: @@ -14698,17 +14789,24 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: tags: - Mental health summary: Record a completed screener - description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned - PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 - and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive - PHQ-9 item-9 the response carries the locale-aware crisis-resource set. + description: >- + Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / + GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals + run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the + response carries the locale-aware crisis-resource set. + + + The projection row is the one write this operation makes outside its declared section: the operation is fenced + on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a + screener whose own total is missing from the record would be an inconsistency inside the section the grant does + open. The consent copy for the mood-and-mind section names it. parameters: - *a6 requestBody: @@ -14724,7 +14822,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14751,7 +14849,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/allergies: @@ -14784,7 +14882,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14809,10 +14907,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/allergies/{id}: get: tags: @@ -14833,8 +14931,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a8 - "404": &a27 + "403": *a9 + "404": &a30 description: Allergy not found (or owned by another user). content: application/json: @@ -14867,8 +14965,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14891,8 +14989,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 /api/family-history: @@ -14916,7 +15014,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14941,7 +15039,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14965,8 +15063,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": &a28 + "403": *a9 + "404": &a31 description: Family history entry not found (or owned by another user). content: application/json: @@ -14999,8 +15097,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15023,8 +15121,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15137,7 +15235,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15272,7 +15370,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -15319,7 +15417,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15438,7 +15536,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -15467,7 +15565,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -15894,7 +15992,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15919,7 +16017,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a30 + "409": &a33 description: A custom metric with this name already exists for the caller. content: application/json: @@ -15947,8 +16045,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a8 - "404": &a29 + "403": *a9 + "404": &a32 description: Custom metric not found (or owned by another user). content: application/json: @@ -15982,8 +16080,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a29 - "409": *a30 + "404": *a32 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -16006,7 +16104,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a29 + "404": *a32 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16049,8 +16147,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a8 - "404": *a29 + "403": *a9 + "404": *a32 "422": *a4 "429": *a5 post: @@ -16079,10 +16177,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a29 + "404": *a32 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -16115,7 +16213,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a31 + "404": &a34 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16148,7 +16246,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a31 + "404": *a34 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16181,6 +16279,125 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/onboarding/answers: + patch: + tags: + - Onboarding + summary: Save one answer of the needs-based setup flow + description: >- + Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the + same step. The body names the step and carries either its answer or `status: "skipped"`; the two can never ride + together. + + + Idempotent. The stored state is computed by a pure function of the row and the body, so sending the same step + twice writes the same value and the second write changes nothing a reader can see — including the first-result + completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract: + steps may be answered, re-answered and passed in any order, which is what makes a back button work. + + + The `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit` + and `unitPreference`, which every display surface already reads, and records the answer beside them. + + + Cookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on + somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never + write another record's setup. Rate-limited to 120 writes per 10 minutes per account. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingAnswerRequest" + responses: + "200": + description: The full setup state after the write. + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingStateEnvelope" + "401": *a1 + "413": + description: Body exceeds 16 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`Content-Type` is not `application/json`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/onboarding/restart: + post: + tags: + - Onboarding + summary: Ask the setup questions again + description: >- + "Set up again", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp, + and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again. + + + It writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module + somebody turned on by hand, and the protection against the second derivation lives in the merge rather than + here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and + leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run + redirect. + + + Cookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to + 10 restarts per 10 minutes per account. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingRestartRequest" + responses: + "200": + description: The reset setup state. + content: + application/json: + schema: + $ref: "#/components/schemas/OnboardingStateEnvelope" + "401": *a1 + "413": + description: Body exceeds 4 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`Content-Type` is not `application/json`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/onboarding/step: post: tags: @@ -16264,16 +16481,35 @@ paths: post: tags: - Onboarding - summary: Stamp onboarding as complete (legacy single-shot path) + summary: Stamp onboarding as complete, and derive the module map description: >- Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`. + v1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record + that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's + module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm + re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears + the marker when the person asks for the questions again. The merge is one-directional — a module the answers + name is switched on, a module they do not name is switched off only where the record does not already carry an + explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route + sets to `true` when the cycle area was chosen and never to `false`. A half-answered flow derives nothing and is + not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen + would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY + wizard's completion out of the derivation, since that wizard never puts these questions. + + + The response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the + fixed acknowledgement it always did. + + The older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it - writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal - selection — that only happens on the step route's completing call. + enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one + (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It + also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing + call. Calling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 @@ -16289,8 +16525,9 @@ paths: $ref: "#/components/schemas/OnboardingCompleteRequest" responses: "200": - description: "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not - echo what was saved." + description: Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields + that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries + the state after the derivation. content: application/json: schema: @@ -16486,7 +16723,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a32 + "403": &a35 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -16541,7 +16778,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a32 + "403": *a35 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16570,7 +16807,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a32 + "403": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -17072,26 +17309,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a33 + "400": &a36 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a34 + "413": &a37 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a35 + "415": &a38 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a36 + "422": &a39 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -17277,11 +17514,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17424,11 +17661,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17581,11 +17818,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17661,21 +17898,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a37 + "422": &a40 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a38 + "429": &a41 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a39 + "502": &a42 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -17720,11 +17957,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17800,9 +18037,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/strava/credentials: get: tags: @@ -17838,11 +18075,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17920,9 +18157,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/status: get: tags: @@ -17958,7 +18195,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/fitbit/test: post: tags: @@ -17976,9 +18213,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/google-health/resume: post: tags: @@ -17995,7 +18232,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/google-health/test: post: tags: @@ -18028,9 +18265,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/whoop/resume: post: tags: @@ -18047,7 +18284,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/whoop/test: post: tags: @@ -18064,9 +18301,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/withings/resume: post: tags: @@ -18084,7 +18321,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/withings/test: post: tags: @@ -18102,8 +18339,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 + "422": *a40 + "429": *a41 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -18131,7 +18368,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -18167,7 +18404,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18224,7 +18461,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -18243,7 +18480,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -18277,7 +18514,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 delete: tags: @@ -18340,7 +18577,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a40 + "403": &a43 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -18365,7 +18602,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "422": *a4 "429": *a5 post: @@ -18460,7 +18697,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "404": description: No such invitation for this account. content: @@ -18504,7 +18741,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "404": description: No such grant on this account's record. content: @@ -18545,7 +18782,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "404": description: No such grant held by this account. content: @@ -18927,7 +19164,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -19257,7 +19494,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a41 + "403": &a44 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -19290,7 +19527,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a41 + "403": *a44 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -19317,7 +19554,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a41 + "403": *a44 "422": *a4 "429": *a5 post: @@ -19347,7 +19584,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a41 + "403": *a44 "413": description: Body exceeds 16 KiB. content: @@ -19390,7 +19627,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a41 + "403": *a44 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -19417,7 +19654,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -19437,7 +19674,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -19448,7 +19685,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a42 + - &a45 name: family in: path required: true @@ -19472,7 +19709,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -19492,7 +19729,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a42 + - *a45 requestBody: required: true content: @@ -19513,7 +19750,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -19626,7 +19863,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/environment: @@ -19644,7 +19881,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/environment/home: @@ -19681,7 +19918,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19735,7 +19972,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19794,7 +20031,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -19833,7 +20070,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19886,7 +20123,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -20027,13 +20264,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a43 + "403": &a46 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a44 + "404": &a47 description: No stored copy with this id. content: application/json: @@ -20092,8 +20329,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a43 - "404": *a44 + "403": *a46 + "404": *a47 "409": description: Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. @@ -27575,6 +27812,282 @@ components: minLength: 1 required: - ids + OnboardingAnswerRequest: + anyOf: + - type: object + properties: + step: + type: string + const: who + status: + type: string + const: done + recordTarget: + type: string + enum: + - me + - someone-else + - both + required: + - step + - recordTarget + additionalProperties: false + - type: object + properties: + step: + type: string + const: areas + status: + type: string + const: done + areas: + maxItems: 9 + type: array + items: + type: string + enum: + - blood-pressure + - weight-body + - glucose + - sleep + - mood + - cycle + - activity + - labs + - illness + required: + - step + - areas + additionalProperties: false + - type: object + properties: + step: + type: string + const: medication + status: + type: string + const: done + medication: + type: string + enum: + - yes + - no + - sometimes + required: + - step + - medication + additionalProperties: false + - type: object + properties: + step: + type: string + const: sources + status: + type: string + const: done + sources: + maxItems: 10 + type: array + items: + type: string + enum: + - manual + - apple-health + - withings + - oura + - whoop + - polar + - fitbit + - strava + - nightscout + - file + required: + - step + - sources + additionalProperties: false + - type: object + properties: + step: + type: string + const: visit + status: + type: string + const: done + visit: + type: string + enum: + - within-a-month + - later + - no + required: + - step + - visit + additionalProperties: false + - type: object + properties: + step: + type: string + const: units + status: + type: string + const: done + units: + type: object + properties: + glucoseUnit: + anyOf: + - type: string + enum: + - mg/dL + - mmol/L + - type: "null" + unitPreference: + anyOf: + - type: string + enum: + - metric + - imperial + - type: "null" + additionalProperties: false + required: + - step + - units + additionalProperties: false + - type: object + properties: + step: + type: string + const: confirm + status: + type: string + const: done + required: + - step + additionalProperties: false + - type: object + properties: + step: + type: string + const: first-result + status: + type: string + const: done + firstResult: + type: object + properties: + task: + type: string + enum: + - connect-source + - add-medication + - log-reading + target: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + completed: + type: boolean + required: + - task + additionalProperties: false + required: + - step + - firstResult + additionalProperties: false + - type: object + properties: + step: + type: string + const: done + status: + type: string + const: done + required: + - step + additionalProperties: false + - type: object + properties: + step: + type: string + const: areas + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: medication + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: sources + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: visit + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: units + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + - type: object + properties: + step: + type: string + const: first-result + status: + type: string + const: skipped + required: + - step + - status + additionalProperties: false + description: "One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six + steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements + rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a + skip may never carry an answer." + OnboardingRestartRequest: + type: object + properties: {} + additionalProperties: false + description: No input beyond the session. Send `{}`. OnboardingStepRequest: type: object properties: @@ -29492,8 +30005,102 @@ components: requestId: type: string errorCode: - description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be - reworded. + description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be + reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four + naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped + client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a + refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. + Three families are outside it on purpose: `assistant.disabled.` is built from a template so the + last segment is open (`assistant.disabled.coach` is the one the native client names); the + integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, + `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, + where the differences can be stated; and a 401 raised by a route checking a credential of its own may + carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy — + allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict, + anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, + auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — + cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid, + encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment + — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; + export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; + feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, + health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, + illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, + illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — + ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, + labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, + labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, + labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — + managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.not_found; measurement — + measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, + measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, + measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; + medication — medication.intake.import.invalid_format; medications — + medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, + medications.intake.bulk.too_large, medications.intake.force_slot.invalid, + medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, + medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth — + mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, + modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, + mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, + mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, + mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; + nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, + nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — + onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, + onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, + onboarding.step.completed, onboarding.step.concurrent, onboarding.step.invalid, + onboarding.step.outOfOrder, onboarding.step.rateLimited, onboarding.tour.invalid, + onboarding.user.notFound; personal_records — personal_records.invalid_query; practitioner — + practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, + profile.update.nothingSaved; record_write — record_write.rate_limited; report-selection — + report-selection.body.invalid_json, report-selection.body.invalid_shape, + report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, + share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, + share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, + share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, + sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, + sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, + sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, + sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, + sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — + source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — + tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, + dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, + missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, + notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows, + unreadable_json, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: @@ -34624,6 +35231,62 @@ components: - configChangedAt - computedAt additionalProperties: {} + onboardingRecord: + default: null + anyOf: + - type: object + properties: + id: + type: string + minLength: 1 + needs: + default: {} + type: object + propertyNames: + type: string + additionalProperties: {} + steps: + default: [] + type: array + items: + type: object + propertyNames: + type: string + additionalProperties: {} + firstResult: + default: null + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + modulesDerivedAt: + default: null + anyOf: + - type: string + minLength: 1 + - type: "null" + completedAt: + default: null + anyOf: + - type: string + minLength: 1 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + required: + - needs + - steps + - firstResult + - modulesDerivedAt + - completedAt + additionalProperties: {} + - type: "null" practitioners: default: [] type: array @@ -35962,6 +36625,7 @@ components: - correlationPatterns - intradayProfiles - healthScoreRecords + - onboardingRecord - practitioners - encounters - encounterDocumentLinks @@ -62688,6 +63352,217 @@ components: - data - error additionalProperties: false + OnboardingStateEnvelope: + type: object + properties: + data: + type: object + properties: + onboarding: + $ref: "#/components/schemas/OnboardingState" + required: + - onboarding + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + OnboardingState: + type: object + properties: + steps: + type: array + items: + $ref: "#/components/schemas/OnboardingStepState" + needs: + $ref: "#/components/schemas/OnboardingNeeds" + completedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which + stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one + alone." + firstResult: + anyOf: + - $ref: "#/components/schemas/OnboardingFirstResult" + - type: "null" + required: + - steps + - needs + - completedAt + - firstResult + additionalProperties: false + description: The needs-based setup flow for one record. Always present on the account payload — a record that never + entered the flow reads as empty answers with nine `pending` steps, so a client branches on the step statuses + rather than on the field's existence. On the account payload it describes the ACTIVE record, so a client under + an account switch reads the same record its counts and module map describe; under a scoped grant the answers + come back empty. + OnboardingStepState: + type: object + properties: + id: + type: string + enum: + - who + - areas + - medication + - sources + - visit + - units + - confirm + - first-result + - done + status: + type: string + enum: + - pending + - done + - skipped + required: + - id + - status + additionalProperties: false + description: >- + One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather than + refusing the list, so the flow may grow a screen without a client release. + + + `units` carries one resolution the others do not: a record whose account already holds BOTH unit preferences + reads as `done` whether or not the question was ever put, which is what stops the flow re-asking a value the + account holds. + OnboardingNeeds: + type: object + properties: + recordTarget: + anyOf: + - type: string + enum: + - me + - someone-else + - both + - type: "null" + description: Q1 — whose record this is. Null until answered. + areas: + type: array + items: + type: string + enum: + - blood-pressure + - weight-body + - glucose + - sleep + - mood + - cycle + - activity + - labs + - illness + description: Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`; + the map is the server's, and a client must not re-derive it. + medication: + anyOf: + - type: string + enum: + - yes + - no + - sometimes + - type: "null" + description: Q3 — medication on a schedule. + sources: + type: array + items: + type: string + enum: + - manual + - apple-health + - withings + - oura + - whoop + - polar + - fitbit + - strava + - nightscout + - file + description: Q4 — where the readings come from today. + visit: + anyOf: + - type: string + enum: + - within-a-month + - later + - no + - type: "null" + description: Q5 — a doctor's visit coming up. + units: + type: object + properties: + glucoseUnit: + anyOf: + - type: string + enum: + - mg/dL + - mmol/L + - type: "null" + unitPreference: + anyOf: + - type: string + enum: + - metric + - imperial + - type: "null" + required: + - glucoseUnit + - unitPreference + additionalProperties: false + description: "Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on + the account payload are, and the answers step writes them. This records what the flow was told." + required: + - recordTarget + - areas + - medication + - sources + - visit + - units + additionalProperties: false + description: The setup answers as given. Every field is null or empty until its question is answered. + OnboardingFirstResult: + type: object + properties: + task: + type: string + enum: + - connect-source + - add-medication + - log-reading + target: + anyOf: + - type: string + - type: "null" + description: What the task is about — a source key for a connection, an area key for a reading. Null for the medication + task. + completedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the offered task produced its result, or null. + required: + - task + - target + - completedAt + additionalProperties: false + description: The one task the flow offered at the end, and whether it produced its result. OnboardingStepEnvelope: type: object properties: @@ -62733,6 +63608,8 @@ components: completed: type: boolean const: true + onboarding: + $ref: "#/components/schemas/OnboardingState" required: - completed additionalProperties: false @@ -66383,6 +67260,8 @@ components: required: - medication additionalProperties: {} + onboarding: + $ref: "#/components/schemas/OnboardingState" required: - id - username @@ -66390,6 +67269,7 @@ components: - moduleAccess - recordSession - notificationPrefs + - onboarding additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 2b6fe2af9..4130a2277 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -215,6 +215,42 @@ A run where SOME account got a copy still succeeds, with the rest counted in `offhost_backup_failed`. Failing the whole queue over one account's object would re-upload everybody's on every retry. +### Which account has no copy + +The counts above say how many accounts were uploaded, never which. An account +whose object has failed every night for a month reads as `99 uploaded, 1 +failed` each time, which looks like weather. + +**Admin console → Backups → Off-host copies** answers it per account: when this +host last put that account's encrypted copy in the bucket, how big it was, and +how that compares with the nightly schedule. **Fresh** is inside one run, +**due** means one run produced nothing for that account, **stale** means two +did not, **never** means a run walked the account and no object has ever +landed for it, and **no record yet** means no run has recorded the account at +all. On a host without `BACKUP_S3_*` the card says off-host backup is not +configured and stops there. + +The ledger starts empty on the release that adds it, so on a host that has +been uploading nightly for months every account reads **no record yet** until +the first run after the upgrade. That is the ledger having no history, not the +bucket being empty — which is why it is a separate word from **never**, and +why the row says so rather than claiming nothing has ever reached the bucket. + +Each verdict carries six hours of slack on top of the schedule's own 24, and +that slack is load-bearing on two nights. The cron runs at 02:30 Europe/Berlin, +so on the DST fall-back Sunday two consecutive runs are 25 hours apart; and the +timestamp is when _that account's_ object landed, so on a cohort walked one +account at a time an account reached later tonight than last night drifts by +hours. Without the slack a healthy host would paint its whole cohort **due** +once a year. + +The verdicts come from a ledger the worker writes when the object lands +(`offhost_backup_state`), not from a listing of the bucket. Keep the worker's +grant as documented — PutObject, GetObject, AbortMultipartUpload — and the card +still tells the truth. A row that goes stale while the run reports success is +that account's object being refused, and the reason is in the run's +`offhost_backup_failures` meta. + ## The weekly in-database backup (`data-backup`) Separate from the off-host job above, and easy to confuse with it. A second diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 6700f779e..065ed1100 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -143,6 +143,27 @@ itself still works: it walks the journey against an in-process instance once per leg with that leg broken, and fails if any of them comes back green. +## Error reporting + +HealthLog ships no default error-reporting target and never will: an +operator's crash reports must not leave their host because nobody told them +not to. Reporting starts only when someone types a DSN into **Admin → +Integrations → GlitchTip**, and that card states plainly whether reports are +going anywhere and which host they go to. + +For a maintainer-operated instance, set the DSN there after the first deploy — +it lives in the database, not in the environment, so it survives an image +upgrade and does not need a compose change. + +The badge on that card tracks delivery, not intent. A DSN that is set and +parses is not the same fact as reports arriving: a wrong public key, a wrong +project id and a host that stopped answering all parse. So the card is green +only while a report has actually left this host inside the last fortnight, +amber while a target is set and nothing has come back from it, and it names the +reason underneath when the last attempt was refused. Press **Test** to prove +the target on demand — that send is recorded like any other, so the badge +answers immediately. + ## Verify the image signature Every release image (the multi-arch manifest **index**, covering both diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md index abd6a128a..05e2a9b14 100644 --- a/docs/self-hosting/account-sharing.md +++ b/docs/self-hosting/account-sharing.md @@ -49,16 +49,16 @@ Either the entire record, which is what sharing has always meant and what every grant written before v1.37.0 carries, or a set of named sections chosen when the invitation is written: -| Section | What it covers | -| ----------------- | -------------------------------------------------------------------------- | -| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics | -| Medications | The medication list, its schedules, doses taken, side effects and stock | -| Lab results | Lab results and the analytes behind them | -| Health background | Allergies, family history and the facts in the health profile | -| Illness | Illness episodes and the day logs kept under them | -| Mood and mind | Mood entries and the mental-health questionnaire history | -| Cycle | Cycle tracking | -| Documents | Everything in the document vault | +| Section | What it covers | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics | +| Medications | The medication list, its schedules, doses taken, side effects and stock | +| Lab results | Lab results and the analytes behind them | +| Health background | Allergies, family history, the facts in the health profile, visits and the practices behind them, and the vaccination record | +| Illness | Illness episodes and the day logs kept under them | +| Mood and mind | Mood entries and the mental-health questionnaire history | +| Cycle | Cycle tracking | +| Documents | Everything in the document vault | A section that was not shared is refused exactly the way a record nobody shared is, so the shape of what was held back is not readable from @@ -68,8 +68,12 @@ shared. The invitation screen says this in as many words, because a note or a document name inside one section can be about any other. Aggregate surfaces (the dashboard overview, the health score, the daily -digest) appear only on a whole-record grant. A figure derived from part of -a record reads as a figure about the person, and it would not be one. +digest, every insight) read across sections, so a grant naming sections +never opens one. A figure derived from part of a record reads as a figure +about the person, and it would not be one. Whole-record grants do not all +open them equally either: the daily digest and the dashboard's snapshot +answer at read, while the health score, the dashboard's summary and the +insights need manage. A stored scope never grows. A section added by a later release is not in a set somebody ticked before it existed. @@ -80,11 +84,24 @@ Three levels, fixed when the invitation is written. Raising one means revoking and inviting again, because widening a grant in place would carry a consent the other person never gave. -| Level | Adds | -| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Read | Read what the grant opens, and change nothing. | -| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. | -| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | +| Level | Adds | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Read | Read what the grant opens, and change nothing. | +| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. Editing, deleting and restoring stay with you. | +| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | + +Some entries are creates only **manage** reaches, and the split runs by +entry rather than by section: an allergy, a family-history entry, a mood +entry, a screener, a cycle day, a water entry and a preventive-care +reminder are all created at manage, in sections a write grant can +otherwise add to. Nothing in the document vault is writable at any level. + +One entry needs two sections rather than a higher level. Planning the +booster a vaccination suggests files it as a preventive-care reminder, +which lives under Readings, so a grant that opens Health background alone +logs the dose and is refused the booster. Logging the dose is unaffected +either way, and the app does not offer the booster to a grant that cannot +plan it. Offering **manage** asks the owner for a fresh second factor if they have one enrolled, which makes it a browser-only act either way: a Bearer @@ -106,12 +123,19 @@ whole feature is built on, and it is drawn on the server: | API tokens, the MCP connector | A durable credential that outlives the access | | Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record | | Notification channels and push devices | The owner's health alerts redirected to another phone or chat | -| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | +| Full exports, the encrypted archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | | Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent | | Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record | | Sharing itself | Inviting somebody else, widening the access, or passing it on | | Locale, theme, dashboard layout | Presentation belongs to the person, not to the record | +One export is the exception, and it is deliberate: the doctor report +(`POST /api/export/health-record`) answers at manage on a whole-record +grant. It renders a prepared artefact from a selection the caller declares +rather than handing the record over whole, which is the difference the row +above is drawing. The full exports and the passphrase-encrypted archive +stay with the owner. + This table is about a grant between two accounts. A managed profile is the one place where some of it is deliberately different, because a record with no login has nobody else to keep its own configuration: see "Records diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index febb55d52..63a0d5fb0 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -707,6 +707,51 @@ async function installA11yMocks(page: Page) { }, ], retentionDays: 30, + // The off-host card, with one row in each state — the scan has to reach + // the status washes, and a card that paints only its header proves + // nothing about the rows an operator actually reads. + offhost: { + configured: true, + periodHours: 24, + rows: [ + { + userId: "a11y-user", + username: "a11y-backup-user", + lastAttemptAt: "2026-07-20T03:00:00.000Z", + lastSuccessAt: "2026-07-20T03:00:00.000Z", + sizeBytes: 4096, + ageHours: 3, + freshness: "fresh", + }, + { + userId: "a11y-user-2", + username: "a11y-backup-user-2", + lastAttemptAt: "2026-07-14T03:00:00.000Z", + lastSuccessAt: "2026-07-14T03:00:00.000Z", + sizeBytes: 2048, + ageHours: 150, + freshness: "stale", + }, + { + userId: "a11y-user-3", + username: "a11y-backup-user-3", + lastAttemptAt: "2026-07-20T03:00:00.000Z", + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "never", + }, + { + userId: "a11y-user-4", + username: "a11y-backup-user-4", + lastAttemptAt: null, + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "unknown", + }, + ], + }, }), ); } diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts index d745c670a..83f01b3dc 100644 --- a/e2e/medication-compliance-journey.spec.ts +++ b/e2e/medication-compliance-journey.spec.ts @@ -231,8 +231,8 @@ async function openSeedingSurface(page: Page): Promise { } /** - * The two dose times the journey's medication runs on: now, and twelve hours - * from now. + * The two dose times the journey's medication runs on: now, and a twin later + * the same day. * * They are derived from the clock rather than picked from the wizard's * suggestion chips, and that is the whole reason the flow is stable. The card @@ -241,17 +241,27 @@ async function openSeedingSurface(page: Page): Promise { * belongs to yesterday or to tomorrow, and the day the write lands on decides * what the dashboard tile reads. A slot anchored at "now" is inside its own * on-time window by construction, so the take is always today's, whenever the - * suite runs. Its twin twelve hours away is the second dose of the day. + * suite runs. + * + * The twin must be on today's calendar as well, because the skip lands on it + * and the tile reads the latest day: a twin twelve hours on wraps past + * midnight for every run that starts after noon, the skip then lands on + * tomorrow's slot, and today reads one taken over two expected (50) where the + * skip-parity figure is 100. So the twin is twelve hours on before noon and + * the last minute of the day after it; either way the card offers it next. */ async function clockAnchoredDoseTimes( page: Page, ): Promise<{ open: string; other: string }> { return page.evaluate(() => { const pad = (n: number) => String(n).padStart(2, "0"); + const hhmm = (minutes: number) => + `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); return { - open: `${pad(now.getHours())}:${pad(now.getMinutes())}`, - other: `${pad((now.getHours() + 12) % 24)}:${pad(now.getMinutes())}`, + open: hhmm(nowMinutes), + other: hhmm(Math.min(nowMinutes + 12 * 60, 24 * 60 - 1)), }; }); } @@ -362,7 +372,7 @@ test.describe("medication adherence journey", () => { // The tile reads THE LATEST day, and a day boundary crossed mid-flow moves // that day out from under both tile assertions: a fresh day has two doses - // scheduled, none taken, and answers 0 where 50 is asserted. The flow is a + // scheduled, none taken, and answers 0 where 100 is asserted. The flow is a // couple of minutes long, so declining to start in the last ten of the day // is enough. The clock is the browser's, which the config pins to the zone // the compliance engine falls back to. diff --git a/messages/de.json b/messages/de.json index bfb2c3f93..fada64eae 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6598,6 +6598,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip Environment", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Fehlerberichte gehen an {host}.", + "glitchtipTargetOff": "Fehlerberichte bleiben auf diesem Host. Es wird nichts verschickt.", + "glitchtipTargetNoTarget": "Fehler-Tracking ist an, aber kein Ziel gesetzt, also wird nichts verschickt.", + "glitchtipUnproven": "Unbestätigt", + "glitchtipDeliveryUnproven": "Ein Ziel ist gesetzt, aber noch kein Bericht hat diesen Host verlassen. Drücke Test, um zu sehen, ob es einen annimmt.", + "glitchtipDeliveryStale": "In den letzten {hours} h hat kein Bericht diesen Host verlassen. Drücke Test, um zu prüfen, ob das Ziel noch annimmt.", + "glitchtipDeliveryFailed": "Der letzte Bericht kam nicht durch ({reason}).", "webPushVapidDescription": "Konfiguriere VAPID-Schlüssel und Subject direkt hier, damit Browser-Push ohne ENV-Variablen funktioniert.", "webPushVapidPublicKey": "VAPID Public Key", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7013,7 +7020,23 @@ "restoreMissingDocuments": "Dokumente", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Psychische Fragebögen", - "restoreMissingConsent": "Einwilligungsnachweise" + "restoreMissingConsent": "Einwilligungsnachweise", + "offhost": { + "title": "Kopien außer Haus", + "description": "Wann dieser Host zuletzt die verschlüsselte Kopie jedes Kontos in deinen Bucket gelegt hat.", + "loadError": "Der Status außer Haus konnte nicht geladen werden.", + "notConfiguredTitle": "Sicherung außer Haus ist nicht eingerichtet", + "notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt nichts diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.", + "stateFresh": "Frisch", + "stateDue": "Fällig", + "stateStale": "Veraltet", + "stateNever": "Nie", + "stateUnknown": "Noch kein Eintrag", + "neverDetail": "Für dieses Konto ist nichts angekommen. Zuletzt geprüft {when}.", + "unknownDetail": "Noch kein nächtlicher Lauf hat dieses Konto erfasst.", + "age": "vor {hours} h", + "scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei." + } }, "danger-zone": { "title": "Gefahrenzone", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "Stimmung und Psyche", - "detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen." + "detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen. Ein ausgefüllter Fragebogen legt seinen Summenwert zusätzlich als Messwert ab." }, "cycle": { "label": "Zyklus", diff --git a/messages/en.json b/messages/en.json index 5ad731168..6d9bc7c0e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6598,6 +6598,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip environment", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Error reports go to {host}.", + "glitchtipTargetOff": "Error reports stay on this host. Nothing is sent anywhere.", + "glitchtipTargetNoTarget": "Error tracking is on, but no target is set, so nothing is sent.", + "glitchtipUnproven": "Unproven", + "glitchtipDeliveryUnproven": "A target is set, but no report has left this host yet. Press Test to find out whether it accepts one.", + "glitchtipDeliveryStale": "No report has left this host in the last {hours} h. Press Test to check the target still accepts one.", + "glitchtipDeliveryFailed": "The last report did not get through ({reason}).", "webPushVapidDescription": "Configure VAPID keys and subject here so browser push works without environment variables.", "webPushVapidPublicKey": "VAPID public key", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7013,7 +7020,23 @@ "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Workouts", "restoreMissingMentalHealth": "Mental-health screeners", - "restoreMissingConsent": "Consent receipts" + "restoreMissingConsent": "Consent receipts", + "offhost": { + "title": "Off-host copies", + "description": "When this host last put each account's encrypted copy in your bucket.", + "loadError": "Could not load the off-host status.", + "notConfiguredTitle": "Off-host backup is not configured", + "notConfiguredDescription": "No bucket and no key are set, so nothing leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.", + "stateFresh": "Fresh", + "stateDue": "Due", + "stateStale": "Stale", + "stateNever": "Never", + "stateUnknown": "No record yet", + "neverDetail": "Nothing has landed for this account. Last checked {when}.", + "unknownDetail": "No nightly run has recorded this account yet.", + "age": "{hours} h ago", + "scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not." + } }, "danger-zone": { "title": "Danger Zone", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "Mood and mind", - "detail": "Mood entries and the history of the mental-health questionnaires." + "detail": "Mood entries and the history of the mental-health questionnaires. A completed questionnaire also files its total score as a reading." }, "cycle": { "label": "Cycle", diff --git a/messages/es.json b/messages/es.json index cf5293105..ca0e7969b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6598,6 +6598,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Entorno de GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Los informes de errores van a {host}.", + "glitchtipTargetOff": "Los informes de errores se quedan en este host. No se envía nada.", + "glitchtipTargetNoTarget": "El seguimiento de errores está activado, pero no hay destino, así que no se envía nada.", + "glitchtipUnproven": "Sin confirmar", + "glitchtipDeliveryUnproven": "Hay un destino definido, pero ningún informe ha salido aún de este host. Pulsa Probar para ver si lo acepta.", + "glitchtipDeliveryStale": "Ningún informe ha salido de este host en las últimas {hours} h. Pulsa Probar para comprobar que el destino sigue aceptándolos.", + "glitchtipDeliveryFailed": "El último informe no llegó ({reason}).", "webPushVapidDescription": "Configura aquí las claves VAPID y el subject para que el push del navegador funcione sin variables de entorno.", "webPushVapidPublicKey": "Clave pública VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7013,7 +7020,23 @@ "restoreMissingDocuments": "Documentos", "restoreMissingWorkouts": "Entrenamientos", "restoreMissingMentalHealth": "Cuestionarios de salud mental", - "restoreMissingConsent": "Comprobantes de consentimiento" + "restoreMissingConsent": "Comprobantes de consentimiento", + "offhost": { + "title": "Copias fuera del host", + "description": "Cuándo puso este host por última vez la copia cifrada de cada cuenta en tu bucket.", + "loadError": "No se pudo cargar el estado fuera del host.", + "notConfiguredTitle": "La copia fuera del host no está configurada", + "notConfiguredDescription": "No hay bucket ni clave definidos, así que nada sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.", + "stateFresh": "Reciente", + "stateDue": "Pendiente", + "stateStale": "Obsoleta", + "stateNever": "Nunca", + "stateUnknown": "Sin registro aún", + "neverDetail": "No ha llegado nada para esta cuenta. Última comprobación {when}.", + "unknownDetail": "Ninguna ejecución nocturna ha registrado esta cuenta todavía.", + "age": "hace {hours} h", + "scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos." + } }, "danger-zone": { "title": "Zona de peligro", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "Ánimo y bienestar mental", - "detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental." + "detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental. Un cuestionario completado también registra su puntuación total como una medición." }, "cycle": { "label": "Ciclo", diff --git a/messages/fr.json b/messages/fr.json index b58b49ab8..55052bcd3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6598,6 +6598,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Environnement GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Les rapports d'erreur vont vers {host}.", + "glitchtipTargetOff": "Les rapports d'erreur restent sur cet hôte. Rien n'est envoyé.", + "glitchtipTargetNoTarget": "Le suivi des erreurs est activé, mais aucune cible n'est définie, donc rien n'est envoyé.", + "glitchtipUnproven": "Non confirmé", + "glitchtipDeliveryUnproven": "Une cible est définie, mais aucun rapport n'a encore quitté cet hôte. Appuyez sur Tester pour voir si elle en accepte un.", + "glitchtipDeliveryStale": "Aucun rapport n'a quitté cet hôte depuis {hours} h. Appuyez sur Tester pour vérifier que la cible en accepte toujours.", + "glitchtipDeliveryFailed": "Le dernier rapport n'est pas passé ({reason}).", "webPushVapidDescription": "Configurez ici les clés VAPID et le subject pour que le push du navigateur fonctionne sans variables d'environnement.", "webPushVapidPublicKey": "Clé publique VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7013,7 +7020,23 @@ "restoreMissingDocuments": "Documents", "restoreMissingWorkouts": "Séances", "restoreMissingMentalHealth": "Questionnaires de santé mentale", - "restoreMissingConsent": "Preuves de consentement" + "restoreMissingConsent": "Preuves de consentement", + "offhost": { + "title": "Copies hors hôte", + "description": "Quand cet hôte a déposé pour la dernière fois la copie chiffrée de chaque compte dans votre bucket.", + "loadError": "Impossible de charger l'état hors hôte.", + "notConfiguredTitle": "La sauvegarde hors hôte n'est pas configurée", + "notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc rien ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.", + "stateFresh": "Récente", + "stateDue": "Attendue", + "stateStale": "Périmée", + "stateNever": "Jamais", + "stateUnknown": "Pas encore d'enregistrement", + "neverDetail": "Rien n'est arrivé pour ce compte. Dernière vérification {when}.", + "unknownDetail": "Aucun cycle nocturne n'a encore enregistré ce compte.", + "age": "il y a {hours} h", + "scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit." + } }, "danger-zone": { "title": "Zone de danger", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "Humeur et santé mentale", - "detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale." + "detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale. Un questionnaire rempli enregistre aussi son score total comme une mesure." }, "cycle": { "label": "Cycle", diff --git a/messages/it.json b/messages/it.json index c390dddc1..1ba139607 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6598,6 +6598,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Ambiente GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Le segnalazioni di errore vanno a {host}.", + "glitchtipTargetOff": "Le segnalazioni di errore restano su questo host. Non viene inviato nulla.", + "glitchtipTargetNoTarget": "Il tracciamento degli errori è attivo, ma non c'è una destinazione, quindi non viene inviato nulla.", + "glitchtipUnproven": "Non confermato", + "glitchtipDeliveryUnproven": "Una destinazione è impostata, ma nessuna segnalazione ha ancora lasciato questo host. Premi Prova per vedere se ne accetta una.", + "glitchtipDeliveryStale": "Nessuna segnalazione ha lasciato questo host nelle ultime {hours} h. Premi Prova per verificare che la destinazione le accetti ancora.", + "glitchtipDeliveryFailed": "L'ultima segnalazione non è arrivata ({reason}).", "webPushVapidDescription": "Configura qui le chiavi VAPID e il subject affinché il push del browser funzioni senza variabili d'ambiente.", "webPushVapidPublicKey": "Chiave pubblica VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7013,7 +7020,23 @@ "restoreMissingDocuments": "Documenti", "restoreMissingWorkouts": "Allenamenti", "restoreMissingMentalHealth": "Questionari sulla salute mentale", - "restoreMissingConsent": "Ricevute di consenso" + "restoreMissingConsent": "Ricevute di consenso", + "offhost": { + "title": "Copie fuori host", + "description": "Quando questo host ha messo per l'ultima volta la copia cifrata di ogni account nel tuo bucket.", + "loadError": "Impossibile caricare lo stato fuori host.", + "notConfiguredTitle": "Il backup fuori host non è configurato", + "notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nulla lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.", + "stateFresh": "Recente", + "stateDue": "Attesa", + "stateStale": "Obsoleta", + "stateNever": "Mai", + "stateUnknown": "Nessuna registrazione", + "neverDetail": "Per questo account non è arrivato nulla. Ultimo controllo {when}.", + "unknownDetail": "Nessun giro notturno ha ancora registrato questo account.", + "age": "{hours} h fa", + "scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due." + } }, "danger-zone": { "title": "Zona pericolosa", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "Umore e benessere mentale", - "detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale." + "detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale. Un questionario compilato registra anche il suo punteggio totale come una misurazione." }, "cycle": { "label": "Ciclo", diff --git a/messages/ko.json b/messages/ko.json index 9f00cb7e2..d8a2b6581 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6578,6 +6578,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip 환경", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "오류 보고서는 {host}(으)로 전송됩니다.", + "glitchtipTargetOff": "오류 보고서는 이 호스트에 남습니다. 아무것도 전송되지 않습니다.", + "glitchtipTargetNoTarget": "오류 추적은 켜져 있지만 대상이 없어 아무것도 전송되지 않습니다.", + "glitchtipUnproven": "미확인", + "glitchtipDeliveryUnproven": "대상이 설정되어 있지만 아직 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 수락하는지 확인하세요.", + "glitchtipDeliveryStale": "지난 {hours}시간 동안 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 대상이 여전히 수락하는지 확인하세요.", + "glitchtipDeliveryFailed": "마지막 보고서가 전달되지 않았습니다 ({reason}).", "webPushVapidDescription": "환경 변수 없이 브라우저 푸시가 동작하도록 VAPID 키와 subject를 여기서 설정하세요.", "webPushVapidPublicKey": "VAPID 공개 키", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -6993,7 +7000,23 @@ "restoreMissingDocuments": "문서", "restoreMissingWorkouts": "운동", "restoreMissingMentalHealth": "정신 건강 선별 검사", - "restoreMissingConsent": "동의 기록" + "restoreMissingConsent": "동의 기록", + "offhost": { + "title": "외부 보관 사본", + "description": "이 호스트가 각 계정의 암호화 사본을 버킷에 마지막으로 올린 시점입니다.", + "loadError": "외부 보관 상태를 불러오지 못했습니다.", + "notConfiguredTitle": "외부 보관 백업이 설정되지 않았습니다", + "notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 것이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.", + "stateFresh": "최신", + "stateDue": "지연", + "stateStale": "오래됨", + "stateNever": "없음", + "stateUnknown": "기록 없음", + "neverDetail": "이 계정으로 도착한 것이 없습니다. 마지막 확인 {when}.", + "unknownDetail": "아직 어떤 야간 실행도 이 계정을 기록하지 않았습니다.", + "age": "{hours}시간 전", + "scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다." + } }, "danger-zone": { "title": "위험 구역", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "기분과 마음", - "detail": "기분 기록과 정신건강 설문 이력이에요." + "detail": "기분 기록과 정신건강 설문 이력이에요. 작성한 설문은 총점도 측정값으로 함께 저장돼요." }, "cycle": { "label": "생리 주기", diff --git a/messages/pl.json b/messages/pl.json index 270131987..c4dfafe32 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6598,6 +6598,13 @@ "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Środowisko GlitchTip", "glitchtipEnvironmentPlaceholder": "production", + "glitchtipTargetOn": "Zgłoszenia błędów trafiają do {host}.", + "glitchtipTargetOff": "Zgłoszenia błędów zostają na tym hoście. Nic nie jest wysyłane.", + "glitchtipTargetNoTarget": "Śledzenie błędów jest włączone, ale nie ustawiono celu, więc nic nie jest wysyłane.", + "glitchtipUnproven": "Niepotwierdzone", + "glitchtipDeliveryUnproven": "Cel jest ustawiony, ale żaden raport nie opuścił jeszcze tego hosta. Naciśnij Testuj, aby sprawdzić, czy zostanie przyjęty.", + "glitchtipDeliveryStale": "Przez ostatnie {hours} h żaden raport nie opuścił tego hosta. Naciśnij Testuj, aby sprawdzić, czy cel nadal je przyjmuje.", + "glitchtipDeliveryFailed": "Ostatni raport nie dotarł ({reason}).", "webPushVapidDescription": "Skonfiguruj tutaj klucze VAPID i subject, aby push przeglądarki działał bez zmiennych środowiskowych.", "webPushVapidPublicKey": "Klucz publiczny VAPID", "webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)", @@ -7013,7 +7020,23 @@ "restoreMissingDocuments": "Dokumenty", "restoreMissingWorkouts": "Treningi", "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego", - "restoreMissingConsent": "Potwierdzenia zgody" + "restoreMissingConsent": "Potwierdzenia zgody", + "offhost": { + "title": "Kopie poza hostem", + "description": "Kiedy ten host ostatnio umieścił zaszyfrowaną kopię każdego konta w Twoim buckecie.", + "loadError": "Nie udało się wczytać stanu poza hostem.", + "notConfiguredTitle": "Kopia poza hostem nie jest skonfigurowana", + "notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc nic nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.", + "stateFresh": "Świeża", + "stateDue": "Zaległa", + "stateStale": "Przeterminowana", + "stateNever": "Nigdy", + "stateUnknown": "Brak wpisu", + "neverDetail": "Dla tego konta nic nie dotarło. Ostatnie sprawdzenie {when}.", + "unknownDetail": "Żaden nocny przebieg nie zarejestrował jeszcze tego konta.", + "age": "{hours} h temu", + "scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa." + } }, "danger-zone": { "title": "Strefa zagrożenia", @@ -10485,7 +10508,7 @@ }, "mind": { "label": "Nastrój i zdrowie psychiczne", - "detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego." + "detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego. Wypełniony kwestionariusz zapisuje też swój wynik łączny jako pomiar." }, "cycle": { "label": "Cykl", diff --git a/package.json b/package.json index e3401361b..2177036ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.15", + "version": "1.38.16", "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", diff --git a/prisma/migrations/0336_offhost_backup_ledger/migration.sql b/prisma/migrations/0336_offhost_backup_ledger/migration.sql new file mode 100644 index 000000000..e0a66eaa8 --- /dev/null +++ b/prisma/migrations/0336_offhost_backup_ledger/migration.sql @@ -0,0 +1,24 @@ +-- One row per account, written by the nightly off-host backup job when an +-- object lands in the bucket. The admin console reads it to say, per account, +-- whether the newest off-host copy is as fresh as the schedule promises, +-- without the page ever talking to the bucket. +CREATE TABLE "offhost_backup_state" ( + "user_id" TEXT NOT NULL, + -- Written on every account the run walks, success or not. A row's mere + -- existence is what lets the console tell "no run has reached this + -- account yet" from "a run reached it and nothing landed". + "last_attempt_at" TIMESTAMP(3) NOT NULL, + -- NULL until an object lands for this account. + "last_success_at" TIMESTAMP(3), + -- BIGINT, not INTEGER: a single object may occupy 8 MiB x 10 000 multipart + -- parts, so the uploader permits 80 GB and a 32-bit column would refuse + -- every account past 2.1 GB. + "size_bytes" BIGINT, + + CONSTRAINT "offhost_backup_state_pkey" PRIMARY KEY ("user_id") +); + +ALTER TABLE "offhost_backup_state" + ADD CONSTRAINT "offhost_backup_state_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql b/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql new file mode 100644 index 000000000..75e271b60 --- /dev/null +++ b/prisma/migrations/0337_glitchtip_delivery_outcome/migration.sql @@ -0,0 +1,8 @@ +-- Delivery outcome of the last error report this host tried to send. +-- "A DSN is set and it parses" and "reports are arriving" are different +-- facts, and only the second one is worth a green badge: a wrong public key, +-- a wrong project id and an unreachable host all parse. +ALTER TABLE "app_settings" + ADD COLUMN "glitchtip_last_ok_at" TIMESTAMP(3), + ADD COLUMN "glitchtip_last_failure_at" TIMESTAMP(3), + ADD COLUMN "glitchtip_last_failure_reason" TEXT; diff --git a/prisma/migrations/0338_onboarding_needs/migration.sql b/prisma/migrations/0338_onboarding_needs/migration.sql new file mode 100644 index 000000000..244fd1800 --- /dev/null +++ b/prisma/migrations/0338_onboarding_needs/migration.sql @@ -0,0 +1,22 @@ +-- v1.39 (C1) — the needs-based onboarding's per-record state. +-- +-- One row per record (an account or a managed profile), created lazily by the +-- first answered step. Nothing here is encrypted: the three JSON columns hold +-- closed enum vocabularies rather than free text. +CREATE TABLE "onboarding_records" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "needs_json" JSONB, + "steps_json" JSONB, + "first_result_json" JSONB, + "modules_derived_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "onboarding_records_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "onboarding_records_user_id_key" ON "onboarding_records"("user_id"); + +ALTER TABLE "onboarding_records" ADD CONSTRAINT "onboarding_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c6e0a00a..755ae4121 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -770,6 +770,7 @@ model User { notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] dataBackups DataBackup[] + offhostBackupState OffhostBackupState? achievements UserAchievement[] recommendationFeedback RecommendationFeedback[] idempotencyKeys IdempotencyKey[] @@ -995,6 +996,12 @@ model User { // seeded catalogue). cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + // v1.39 — the needs-based onboarding's answers and step ledger for THIS + // record. 1:1, and keyed by record rather than by the person at the + // keyboard so a guardian running a managed profile's setup writes the + // child's row, not their own. See `OnboardingRecord`. + onboardingRecord OnboardingRecord? + // v1.15.20 — user-authored "about me" context for the AI surfaces // (1:1, encrypted free text). See `UserHealthProfile`. healthProfile UserHealthProfile? @@ -4700,6 +4707,21 @@ model AppSettings { glitchtipDsn String? @map("glitchtip_dsn") glitchtipEnvironment String? @map("glitchtip_environment") + // Delivery outcome of the last error report this host tried to send. + // Without it the admin card can only say a DSN is set and parses, which is + // not the same fact as reports arriving: a wrong public key, a wrong project + // id or an unreachable host all parse perfectly and deliver nothing. + /// Instant the last report left successfully. Consumer: the GlitchTip card's + /// badge, which is only green while this is inside the recent window. + glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at") + /// Instant the last attempt failed. Consumer: the GlitchTip card's status + /// line, which names the failure rather than showing a reassuring badge. + glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at") + /// Short classification of that failure (`invalid_dsn`, `http_403`, + /// `network`) — never a response body, which is a remote host's text. + /// Consumer: the same status line. + glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason") + // Medication reminder thresholds (minutes after windowEnd) reminderLateMinutes Int @default(120) @map("reminder_late_minutes") reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes") @@ -4937,6 +4959,49 @@ model DataBackup { @@map("data_backups") } +// ─── Off-host backup ledger ────────────────────────────────── +// +// One row per account, written by the nightly off-host job the moment an +// object lands in the bucket. It exists because "the job ran" and "this +// account has a recent copy off-host" are different facts: the run reports +// counts, and a cohort of a hundred accounts where one has silently failed +// every night for a month reads exactly like a healthy one. +// +// The admin console's off-host card is the reader, and it is the only reader +// there will be: the page computes staleness from this timestamp rather than +// listing the bucket, so an operator who has revoked the worker's read grant +// still sees the truth. +model OffhostBackupState { + userId String @id @map("user_id") + + /// When the last nightly run walked this account, whether or not an object + /// came of it. It is what separates "no run has reached this account yet" + /// from "a run reached it and put nothing there" — two facts that look + /// identical in a ledger keyed only on success, and the first of which is + /// what every account reads on the day this table ships. Consumer: + /// `classifyOffhostBackup()` behind `GET /api/admin/backups`, and the + /// card's "last checked" line. + lastAttemptAt DateTime @map("last_attempt_at") + + /// When the newest object for this account finished uploading. NULL means + /// a run has looked at this account and nothing has ever landed for it. + /// Consumer: `classifyOffhostBackup()` behind `GET /api/admin/backups`. + lastSuccessAt DateTime? @map("last_success_at") + + /// Size of that object in bytes — the same number the run reports as its + /// largest, per account. `BigInt` because the uploader permits an object of + /// up to 8 MiB × 10 000 parts = 80 GB and a 32-bit column stops at 2.1 GB: + /// the biggest account on the host, the one an operator cares most about, + /// is exactly the one whose row would refuse to be written. + /// NULL alongside a NULL `lastSuccessAt`. + /// Consumer: the off-host card's size column. + sizeBytes BigInt? @map("size_bytes") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("offhost_backup_state") +} + // ─── Apple Health export.zip import (v1.4.34) ─────────────── // // Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the @@ -8298,3 +8363,63 @@ model VaccinationDocumentLink { @@index([userId]) @@map("vaccination_document_links") } + +// ─── Onboarding (needs-based flow) ───────────────────────────── + +/// v1.39 (C1) — what a record answered when it was set up, and how far the +/// flow got. +/// +/// One row per RECORD, not per person: a managed profile is an ordinary `User` +/// row with no credentials, so keying on `userId` is what lets a guardian run +/// a child's setup and have the answers land on the child's record. Created +/// lazily by the first answer; an account that never entered the flow has no +/// row and reads as "nothing answered, nine steps pending". +/// +/// The three JSON columns hold closed enum vocabularies, not free text, so +/// nothing here is encrypted: the encryption convention in this repository +/// covers free text a person wrote (`UserHealthProfile.aboutMeEncrypted`, the +/// Coach transcript, the emergency note) and identifiers (the KVNR), and an +/// answer drawn from nine fixed chips is neither. It is the same class of +/// value as `modulePreferencesJson` and `onboardingGoals`, both of which sit +/// in plaintext beside it. Readers are fail-soft rather than schema-validated +/// at the database edge — see `src/lib/onboarding/needs.ts`. +model OnboardingRecord { + id String @id @default(cuid()) + userId String @unique @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// The answers as given: `{ recordTarget, areas[], medication, sources[], + /// visit, units }`. Read by `parseOnboardingNeeds`, published on + /// `GET /api/auth/me` as `onboarding.needs`, and the input the module + /// derivation reads once at confirm time. + needsJson Json? @map("needs_json") + + /// The ordered steps as `[{ id, status }]` with `status` one of + /// `pending | done | skipped`. Stored as a status lookup — the ORDER on the + /// wire is always this release's `ONBOARDING_STEP_IDS`, so a step added or + /// retired needs no backfill. + stepsJson Json? @map("steps_json") + + /// The one task the flow offered at the end, and whether it produced its + /// result: `{ task, target, completedAt }`. Null until the first-result + /// step names one. + firstResultJson Json? @map("first_result_json") + + /// When the module map was derived from `needsJson`. The derivation is + /// once-only per run of the flow: `POST /api/onboarding/complete` skips it + /// while this is set, and `POST /api/onboarding/restart` clears it so a + /// re-run may derive again. Without it a repeated confirm would re-apply + /// the answers over decisions taken in Settings since. + modulesDerivedAt DateTime? @map("modules_derived_at") + + /// When the needs flow was completed. Deliberately NOT + /// `User.onboardingCompletedAt`: that column gates the first-run redirect in + /// `src/proxy.ts` and keeps that meaning, while this one may be cleared by + /// "Set up again" without pushing the person back through the redirect. + completedAt DateTime? @map("completed_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("onboarding_records") +} diff --git a/public/sw.js b/public/sw.js index 45a57b778..58f9dd341 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.38.15"; + /* @sw-version-fallback */ "v1.38.16"; 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/*` diff --git a/src/__tests__/domain-write-support-guard.test.ts b/src/__tests__/domain-write-support-guard.test.ts index 76ed8c0cb..277f2107d 100644 --- a/src/__tests__/domain-write-support-guard.test.ts +++ b/src/__tests__/domain-write-support-guard.test.ts @@ -24,6 +24,16 @@ * domain outside the vocabulary other than `record` fails the run outright, * because the table is keyed on the vocabulary and would silently lack it. * + * What it deliberately does not hold, stated so nobody reads more into a green + * run than it earns: the table is EXISTENTIAL per section. It answers "does + * any route in this section accept this need", and within a section the answer + * varies by verb — `POST /api/encounters` is WRITE while `POST /api/allergies` + * beside it is MANAGE, both under `profile`. So this guard proves the table + * and the routes agree about the section, never that one control's own verb is + * covered. That is the control's job: it asks the hook for its own verb class, + * and a control that read "my section is writable, therefore my create is" + * would be wrong in three of the four sections marked writable. + * * Mutation checks, run: * - set `mind.write` to `true` in the table → "agrees with the routes" red; * - change `requireRecordAuth("manage", "mind")` in `mood-entries/route.ts` diff --git a/src/__tests__/error-code-catalogue.test.ts b/src/__tests__/error-code-catalogue.test.ts new file mode 100644 index 000000000..58f5e2ec3 --- /dev/null +++ b/src/__tests__/error-code-catalogue.test.ts @@ -0,0 +1,271 @@ +/** + * The published `meta.errorCode` list is the list the server actually emits. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * and the envelope tells a client to branch on it — but of the two hundred and + * forty-two codes in the tree, sixteen appeared anywhere in the contract. A + * client had to discover each one by triggering the error, and had no way at + * all to learn a new one had appeared. The catalogue publishes them; this holds + * the catalogue to the code, in both directions: + * + * - A code emitted and not listed is today's defect repeating itself, and it + * is also how a fifth naming convention would get in unnoticed. + * - A code listed and no longer emitted is a promise the server stopped + * keeping, which is worse than an omission: a client can write a branch for + * it and wait forever. + * + * What it reads: every `.ts` under `src/app/api` outside a `__tests__` + * directory, and then every module those files import, transitively, so a code + * built one or five hops away from the route that answers with it is read + * where it is written. Import specifiers are resolved the way the app resolves + * them (`@/…` and relative paths, `.ts` / `.tsx` / `index.*`); anything that + * leaves `src` is not followed, and the generated Prisma client is never + * opened. Comment lines are stripped first, so a code quoted in prose is not + * mistaken for one that ships. + * + * This used to be the route tree plus a hand-kept list of library modules, and + * the hand-kept list is exactly what it sounds like: the dose-history import + * built five codes in a module nobody had thought to add, so the guard read + * neither the codes nor the file and agreed the catalogue was complete. The + * import graph is not hand-kept — a new module reached by a route is swept the + * first time a route imports it. + * + * Its limits, stated rather than implied. A code assembled at runtime from a + * template cannot be enumerated at all, and the one family that does so is + * named in the catalogue's own doc comment. A code that reaches the envelope + * through a variable is found where the values are declared, which means a + * literal in a constant the sweep's shapes do not cover would still be missed: + * a constant that holds wire codes is named `*_CODE` / `*_CODES` for that + * reason. And `IntegrationStatus.errorCode` is a different field with the same + * name — it records an upstream HTTP status in the sync ledger and never + * reaches a response — so the two ledger writers that take it are cut out of + * the text before it is matched. + */ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; + +const SRC_ROOT = join(process.cwd(), "src"); +const APP_ROOT = join(SRC_ROOT, "app"); +/** Never opened: the generated Prisma client is megabytes of machine output. */ +const GENERATED_ROOT = join(SRC_ROOT, "generated"); + +/** + * The sync ledger's writers. `IntegrationStatus.errorCode` records an upstream + * HTTP status and never reaches a response envelope, so their arguments are cut + * out before a code is looked for. A third writer would surface as an + * undocumented code rather than as silence, which is the direction to fail in. + */ +const LEDGER_WRITERS = ["recordSyncFailure", "parkIntegrationAtReauth"]; + +function tsFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...tsFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Resolve one import specifier the way the app's path alias resolves it. */ +function resolveSpecifier(specifier: string, fromFile: string): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2)); + else if (specifier.startsWith(".")) + base = resolve(dirname(fromFile), specifier); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +/** Every module a route reaches, however many hops away it is written. */ +function modulesUnderTheRoutes(): string[] { + const queue = tsFiles(APP_ROOT); + const seen = new Set(queue); + for (let i = 0; i < queue.length; i++) { + const file = queue[i]; + const source = readFileSync(file, "utf8"); + const specifiers = [ + ...source.matchAll( + /(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s*"([^"]+)"/g, + ), + ...source.matchAll(/import\(\s*"([^"]+)"\s*\)/g), + ]; + for (const match of specifiers) { + const resolved = resolveSpecifier(match[1], file); + if (!resolved) continue; + if (resolved.startsWith(GENERATED_ROOT)) continue; + if (resolved.includes(`${sep}__tests__${sep}`)) continue; + if (seen.has(resolved)) continue; + seen.add(resolved); + queue.push(resolved); + } + } + return [...seen]; +} + +/** Cut the sync-ledger writers' arguments out, parenthesis-balanced. */ +function withoutLedgerWrites(source: string): string { + let text = source; + for (const writer of LEDGER_WRITERS) { + for (;;) { + const call = text.indexOf(`${writer}(`); + if (call === -1) break; + const open = call + writer.length; + let depth = 0; + let close = text.length; + for (let j = open; j < text.length; j++) { + if (text[j] === "(") depth++; + else if (text[j] === ")") { + depth--; + if (depth === 0) { + close = j + 1; + break; + } + } + } + text = text.slice(0, call) + text.slice(close); + } + } + return text; +} + +/** Drop comment lines so a code quoted in prose is not read as one that ships. */ +function withoutComments(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("*") || + trimmed.startsWith("//") || + trimmed.startsWith("/*") + ); + }) + .join("\n"); +} + +/** The shapes a code is written in across the tree. */ +function codesIn(source: string): string[] { + const found: string[] = []; + // `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the + // annotated `errorCode: string = "x"` on the step-up error's constructor. + for (const m of source.matchAll( + /errorCode\s*(?::\s*[A-Za-z.[\]|" ]+)?\s*[:=]\s*"([^"]+)"/g, + )) { + found.push(m[1]); + } + // A single shared constant: `export const MODULE_DISABLED_ERROR_CODE = "…"`. + for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) { + found.push(m[1]); + } + // A family in one object: `export const AUTH_ERROR_CODES = { … }`, or in one + // tuple: `export const AUTO_EXPORT_FATAL_ERROR_CODES = [ … ] as const`. The + // tuple is how a union type of codes is declared, and that union is what an + // envelope relays when it writes `errorCode: parsed.fatal.reason`. Only the + // `_ERROR_CODES` spelling is read in tuple form: plenty of unrelated + // catalogues (nutrients, say) are lists of codes that are not error codes. + for (const m of source.matchAll(/\b([A-Z][A-Z0-9_]*_CODES)\s*=\s*([{[])/g)) { + const [, name, opener] = m; + if (opener === "[" && !name.endsWith("_ERROR_CODES")) continue; + const closer = opener === "{" ? "}" : "]"; + const start = source.indexOf(opener, m.index); + let depth = 0; + let end = start; + for (let j = start; j < source.length; j++) { + if (source[j] === opener) depth++; + else if (source[j] === closer) { + depth--; + if (depth === 0) { + end = j; + break; + } + } + } + const body = source.slice(start, end); + const values = + opener === "{" + ? body.matchAll(/:\s*"([^"]+)"/g) + : body.matchAll(/"([^"]+)"/g); + for (const value of values) found.push(value[1]); + } + return found; +} + +/** Every code the tree emits, mapped to the files that emit it. */ +function emittedCodes(): Map { + const emitted = new Map(); + for (const file of modulesUnderTheRoutes()) { + const source = withoutLedgerWrites( + withoutComments(readFileSync(file, "utf8")), + ); + for (const code of codesIn(source)) { + const where = relative(process.cwd(), file).split(sep).join("/"); + const seen = emitted.get(code); + if (seen) { + if (!seen.includes(where)) seen.push(where); + } else { + emitted.set(code, [where]); + } + } + } + return emitted; +} + +const emitted = emittedCodes(); +const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat()); + +describe("the published errorCode catalogue", () => { + it("finds the codes it is meant to judge", () => { + // A matcher that stopped matching would otherwise agree with an empty + // catalogue. Two hundred and twenty-nine when this was written, read out + // of the fourteen hundred modules the route tree reaches. + expect(emitted.size).toBeGreaterThan(200); + }); + + it("lists every code the server emits", () => { + const undocumented = [...emitted.entries()] + .filter(([code]) => !published.has(code)) + .map(([code, files]) => `${code} (${files[0]})`) + .sort(); + expect(undocumented).toEqual([]); + }); + + it("emits every code it lists", () => { + const stale = [...published].filter((code) => !emitted.has(code)).sort(); + expect(stale).toEqual([]); + }); + + it("files every code under the surface its own prefix names", () => { + const misfiled: string[] = []; + for (const [surface, codes] of Object.entries(ERROR_CODE_CATALOGUE)) { + for (const code of codes) { + const expected = code.includes(".") + ? code.slice(0, code.indexOf(".")) + : "(unprefixed)"; + if (expected !== surface) misfiled.push(`${code} under ${surface}`); + } + } + expect(misfiled).toEqual([]); + }); + + it("keeps no duplicate between two surfaces", () => { + const all = Object.values(ERROR_CODE_CATALOGUE).flat(); + expect(all.length).toBe(new Set(all).size); + }); +}); diff --git a/src/__tests__/helpers/source-files.ts b/src/__tests__/helpers/source-files.ts index 0fb711277..9a2a3cff1 100644 --- a/src/__tests__/helpers/source-files.ts +++ b/src/__tests__/helpers/source-files.ts @@ -73,3 +73,70 @@ export function walkSourceFiles( } return files; } + +/** + * A source file with its comments removed, line comments FIRST. + * + * The order is the whole reason this is shared rather than re-spelled per + * guard. Every private copy in this directory strips block comments first, + * with one non-greedy `/\*[\s\S]*?\*\//` pass, and that pass cannot tell a + * real block opener from a `/*` that happens to sit inside a line comment. + * This tree has one: `src/app/mood/page-client.tsx` explains its gate with + * "Every `/api/mood-entries/*` route also enforces the gate", and the `/*` in + * that path opened a fake block that swallowed the next 2.2 kB of real code — + * the module gate three lines below it included. A guard reading that source + * was searching a file with a hole in it and reporting a clean sweep. + * + * So each line is walked once, left to right: whichever opener comes first + * wins, a line comment ends the line, and a block comment carries its state to + * the next one. Line-comment detection needs `//` at the start of a line or + * after whitespace, so a URL inside a string survives. + * + * Its honest limit: it is a scanner, not a parser. A `/*` inside a string + * literal still opens a block, and a guard that needs more than this needs a + * real parse. + */ +export function stripComments(source: string): string { + const lines: string[] = []; + let inBlock = false; + + for (const line of source.split("\n")) { + let rest = line; + let kept = ""; + + while (rest.length > 0) { + if (inBlock) { + const closes = rest.indexOf("*/"); + if (closes === -1) break; + rest = rest.slice(closes + 2); + inBlock = false; + continue; + } + + const lineComment = rest.search(/(^|\s)\/\//); + const blockComment = rest.indexOf("/*"); + + if ( + blockComment !== -1 && + (lineComment === -1 || blockComment < lineComment) + ) { + kept += rest.slice(0, blockComment); + rest = rest.slice(blockComment + 2); + inBlock = true; + continue; + } + + if (lineComment !== -1) { + kept += rest.slice(0, rest.indexOf("//", lineComment)); + break; + } + + kept += rest; + break; + } + + lines.push(kept); + } + + return lines.join("\n"); +} diff --git a/src/__tests__/malformed-json-status-guard.test.ts b/src/__tests__/malformed-json-status-guard.test.ts new file mode 100644 index 000000000..b26fae67a --- /dev/null +++ b/src/__tests__/malformed-json-status-guard.test.ts @@ -0,0 +1,158 @@ +/** + * A body that will not parse is always a 400. + * + * `safeJson` has answered 400 since it was written and roughly two hundred and + * twenty routes go through it; `apiHandler`'s own `SyntaxError` branch answers + * 400 as well. Seven `/api/auth/me/*` writes hand-rolled `await req.json()` in + * a try/catch and threw 422 for the same condition, so a client's "my + * serializer produced garbage" branch had to accept two statuses on a subset of + * routes it could not predict — and the subset was invisible from the outside. + * + * The guard reads every hand-rolled parse out of the route tree rather than + * listing them, so a route that grows one later is judged too. The `catch` is + * read on its own: a `try` that parses a body and then refuses with anything + * other than 400 fails here. + * + * Its limit: a route that parses a body somewhere other than inside a `try` + * whose `catch` builds the refusal — a shared helper, a promise chain — is + * invisible to it. `safeJson` remains the documented entry, and the assertion + * on the helper below is what pins the status the rest of the tree inherits. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { safeJson } from "@/lib/api-response"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Hand-rolled parses that must keep a different status, with the reason. + * + * `/api/auth/me/health-score-config` is here because the published contract + * names its answer: the operation's own 422 description spells out that a body + * which is not JSON at all comes back with the bare message + * `health-score-config.body.invalid_json`. Moving it to 400 would change a + * promise a shipped client was generated against, so the status stays and the + * change is a contract decision of its own rather than a cleanup. + */ +const ALLOWLIST: Record = { + "src/app/api/auth/me/health-score-config/route.ts": { + status: 422, + reason: + "The 422 and its exact message are written into the operation's published description.", + }, +}; + +/** Every `route.ts` under `src/app/api`, tests excluded. */ +function routeFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...routeFiles(full)); + continue; + } + if (entry.name === "route.ts") found.push(full); + } + return found; +} + +/** The brace-balanced block starting at the `{` at or after `from`. */ +function block(src: string, from: number): { text: string; end: number } { + let i = from; + while (i < src.length && src[i] !== "{") i++; + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return { text: src.slice(i, j + 1), end: j + 1 }; + } + } + return { text: src.slice(i), end: src.length }; +} + +interface ParseRefusal { + route: string; + line: number; + status: number | null; +} + +/** Every `try { … .json() … } catch { … }` under the route tree. */ +function scanHandRolledParses(): ParseRefusal[] { + const found: ParseRefusal[] = []; + for (const file of routeFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + for (const m of src.matchAll(/\btry\s*\{/g)) { + const start = m.index ?? 0; + const tryBlock = block(src, start); + if (!/(?:req|request)\w*\.json\(\)/.test(tryBlock.text)) continue; + const after = src.slice(tryBlock.end); + const catchAt = after.match(/^\s*catch\s*(?:\([^)]*\)\s*)?\{/); + if (!catchAt) continue; + const catchBlock = block(after, catchAt.index ?? 0); + const status = catchBlock.text.match(/\b(4\d\d|5\d\d)\b/); + found.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: src.slice(0, start).split("\n").length, + status: status ? Number(status[1]) : null, + }); + } + } + return found; +} + +describe("malformed JSON answers one status", () => { + const parses = scanHandRolledParses(); + + it("finds the hand-rolled parses it is meant to judge", () => { + // Eight when the guard was written. A matcher that stops matching would + // otherwise report a clean pass it did not earn. + expect(parses.length).toBeGreaterThanOrEqual(8); + }); + + it("refuses an unparseable body with 400", () => { + const wrong = parses + .filter((parse) => { + const allowed = ALLOWLIST[parse.route]; + if (allowed) return parse.status !== allowed.status; + return parse.status !== 400; + }) + .map((parse) => `${parse.route}:${parse.line} -> ${parse.status}`); + expect(wrong).toEqual([]); + }); + + it("keeps no stale allowlist entries", () => { + const stale = Object.keys(ALLOWLIST).filter( + (route) => !parses.some((parse) => parse.route === route), + ); + expect(stale).toEqual([]); + }); + + it("is the status the shared helper already answers with", async () => { + const request = new Request("https://example.test/api/anything", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ not json", + }); + const result = await safeJson(request); + expect(result.error?.status).toBe(400); + expect(await result.error?.clone().json()).toMatchObject({ + data: null, + error: "Invalid JSON body", + }); + }); + + it("keeps the wrong-content-type refusal separate at 415", async () => { + const request = new Request("https://example.test/api/anything", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + const result = await safeJson(request); + expect(result.error?.status).toBe(415); + }); +}); diff --git a/src/__tests__/module-gate-record-scoped.test.ts b/src/__tests__/module-gate-record-scoped.test.ts new file mode 100644 index 000000000..a71f3de71 --- /dev/null +++ b/src/__tests__/module-gate-record-scoped.test.ts @@ -0,0 +1,120 @@ +/** + * A module gate decides from the RECORD's map, never from "am I a delegate". + * + * Every surface that paints a module reads `user?.modules?.X`, and a run of + * them used to widen that read with `inSharedRecord || …`. The prefix was + * right when it was written: `GET /api/auth/me` published the ACTOR's module + * map, so a delegate's own preferences would have closed a page in somebody + * else's record. Since the map became the record's, masked to what the grant + * opens, the same prefix fails open in the other direction — the navigation + * correctly drops the entry while a typed URL still renders the page for a + * record whose owner switched the module off, and the routes behind these + * pages carry no module gate of their own, so the page works. + * + * A DISCOVERED set rather than a named one. The first version of this guard + * froze the six files one sweep happened to find; three more were carrying the + * identical expression at the time, and a named list is structurally unable to + * say so. So the sweep walks every component under `src/app` and + * `src/components`, and the property is about the SHAPE: no boolean `||` may + * join `inSharedRecord` to a module read. The window the matcher searches + * cannot cross a `;`, a `{` or a `}`, so it sees one expression at a time and + * a delegate check elsewhere in the same file is none of its business. + * + * A structural check rather than a render, because the thing that regressed is + * one `||`: reaching these pages in a unit test would mean standing up the + * auth query, the router and the shell, and none of that is where the defect + * lives. + * + * Its limit, stated: it holds the two spellings of the OR. A gate that widened + * itself some other way — a ternary, a helper returning the delegate flag — + * would pass. The non-vacuity floor below is what keeps a broken walker from + * reading as a clean sweep. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { stripComments, walkSourceFiles } from "./helpers/source-files"; + +const ROOT = process.cwd(); +/** Floors pinned below the real counts, so a broken walk throws. */ +const ROOTS: ReadonlyArray<[string, number]> = [ + ["src/app", 100], + ["src/components", 700], +]; + +/** The module read every gated surface makes. */ +const MODULE_READ = "user?.modules?."; + +/** + * The two spellings of the widening, in one expression each. + * + * `[^;{}]*` is the whole point: it cannot cross a statement end or a block + * boundary, so a file that reads the module map in one place and asks about + * the delegate in another does not match. + */ +const WIDENED = [ + /inSharedRecord\s*\|\|[^;{}]*user\?\.modules\?\./, + /user\?\.modules\?\.[^;{}]*\|\|\s*inSharedRecord/, +]; + +/** Every `.tsx` under the roots, with its comment-stripped source. */ +function componentSources(): Array<{ file: string; source: string }> { + const out: Array<{ file: string; source: string }> = []; + for (const [root, floor] of ROOTS) { + const files = walkSourceFiles(join(ROOT, root), { + floor, + extensions: [".tsx"], + }); + for (const rel of files) { + if (rel.includes("__tests__/")) continue; + out.push({ + file: `${root}/${rel}`, + source: stripComments(readFileSync(join(ROOT, root, rel), "utf8")), + }); + } + } + return out; +} + +describe("a module gate reads the record's map alone", () => { + const sources = componentSources(); + const gated = sources.filter(({ source }) => source.includes(MODULE_READ)); + + it("discovers a real set of module-gated surfaces", () => { + // The empty-match trap: a walker that stopped walking, or a spelling of + // the module read that moved, would make every assertion below vacuous. + expect(sources.length, "no component was walked at all").toBeGreaterThan( + 500, + ); + expect( + gated.length, + "no surface was found reading the module map", + ).toBeGreaterThan(20); + // Spot checks, so a sweep that found twenty of the wrong files still + // fails: these are module-gated pages by construction. + const files = new Set(gated.map((entry) => entry.file)); + for (const file of [ + "src/app/vaccinations/page.tsx", + "src/app/labs/page.tsx", + "src/app/mood/page-client.tsx", + "src/components/documents/documents-view.tsx", + ]) { + expect(files, `${file} was not discovered`).toContain(file); + } + }); + + it("finds no gate widened by the delegate flag", () => { + const widened = gated + .filter(({ source }) => WIDENED.some((pattern) => pattern.test(source))) + .map(({ file }) => file); + + expect( + widened, + `these surfaces widen a module gate for a delegate; the record's masked map already answers for both cases:\n${widened + .map((file) => ` ❌ ${file}`) + .join("\n")}`, + ).toEqual([]); + }); +}); diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts index 9d0cbeff1..c7d285554 100644 --- a/src/__tests__/openapi-sharing-denial.test.ts +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -45,16 +45,16 @@ * - Comments are stripped before matching. `.../guardians/route.ts` names both * fence helpers in a docblock explaining why it uses neither, and counting * prose would classify an actor surface as delegable. - * - It covers the PUBLISHED surface, which is not the whole delegable surface. - * 116 route modules resolve the fence; 90 of them are in the OpenAPI document - * and 26 have never been (mood entries, the record-settings tree, the - * medication intake tree, several insight reads). That gap predates this - * release and is not closed here — "frozen" means the published contract is - * complete and consistent, not that every delegable route is described. What - * this guard does guarantee is that one of those 26 cannot be published - * without its refusal, because publishing it puts it in scope on the next run. + * - It covered the PUBLISHED surface and not the whole delegable one. That was + * the honest limit at v1.37.0, when 116 route modules resolved the fence and + * 26 of them were in no OpenAPI document at all. The gap is closed on this + * tree, so the limit is now an assertion rather than a caveat: the last leg + * below recomputes the fenced modules from the route sources and requires + * every one of them to be published. Two counts is one too many, so it names + * no number — a hard-coded figure is what turned the paragraph it replaces + * into a false claim about the contract. */ -import { readFileSync, existsSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -63,6 +63,8 @@ import { openApiPaths } from "@/lib/openapi/routes"; import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared"; import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; +import { stripComments } from "./helpers/source-files"; + /** Published paths that deliberately have no handler behind them. */ const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path)); @@ -80,8 +82,40 @@ interface PublishedOperation { handlerSource: string; } -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); +/** + * Every `route.ts` under `src/app/api` whose handlers resolve the record fence. + * + * Comments stripped first, for the reason the operation matcher strips them: + * one module names both fence helpers in a docblock explaining why it uses + * neither. + */ +function fencedRouteModules(): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (entry.name !== "route.ts") continue; + const source = stripComments(readFileSync(full, "utf8")); + if (/require(RecordAuth|GuardianAuth)\s*\(/.test(source)) { + out.push(full); + } + } + }; + walk(join(ROOT, "src/app/api")); + return out; +} + +/** `…/src/app/api/labs/[id]/route.ts` → `/api/labs/{id}`. */ +function apiPathFor(moduleFile: string): string { + return moduleFile + .slice(join(ROOT, "src/app").length) + .replaceAll("\\", "/") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); } function routeModulePath(apiPath: string): string { @@ -334,6 +368,33 @@ describe("the sharing refusal every delegable path publishes", () => { } }); + it("publishes every module that resolves the fence", () => { + // The reverse direction of the leg above, and the one the docblock used to + // apologise for: that one holds every PUBLISHED fenced operation to the + // refusal, and says nothing about a fenced module nobody published. A + // module missing from the document is a delegable surface a generated + // client cannot see and a reader of the contract cannot find, which is + // exactly how the mood-entry tree and the record-settings tree stayed + // invisible for a release. + const modules = fencedRouteModules(); + expect( + modules.length, + "no fenced route module was discovered — the matcher found nothing", + ).toBeGreaterThan(100); + + const published = new Set(Object.keys(openApiPaths)); + const missing = modules + .map((rel) => apiPathFor(rel)) + .filter((apiPath) => !published.has(apiPath)); + + expect( + missing, + `these route modules resolve the record fence and are in no published path:\n${missing + .map((apiPath) => ` ❌ ${apiPath}`) + .join("\n")}`, + ).toEqual([]); + }); + it("carries the refusal into the generated document", () => { const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60)); diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 4badffb3a..bf43fe9d2 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -51,6 +51,31 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(res.status).not.toBe(403); }); + it("permits the three setup-flow writes so the demo can finish onboarding", () => { + // v1.39 (C1) — the design spec requires the demo to complete the same flow + // with preset answers, and none of the three creates health data: closed + // enum answers, the step ledger, and the module map derived from them. + for (const [path, method] of [ + ["/api/onboarding/answers", "PATCH"], + ["/api/onboarding/complete", "POST"], + ["/api/onboarding/restart", "POST"], + ] as const) { + expect( + proxy(makeRequest(path, method)).status, + `${method} ${path} is blocked in the demo`, + ).not.toBe(403); + } + }); + + it("does not drag in a sibling verb on an allowlisted onboarding path", () => { + // The allowlist pins method per path, so admitting the answers PATCH must + // not open a DELETE somebody adds to that route later. + expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe( + 403, + ); + expect(proxy(makeRequest("/api/onboarding/step", "POST")).status).toBe(403); + }); + it("still blocks a health-data mutation (POST /api/measurements)", () => { const res = proxy(makeRequest("/api/measurements", "POST")); expect(res.status).toBe(403); diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts new file mode 100644 index 000000000..55c327147 --- /dev/null +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -0,0 +1,156 @@ +/** + * The single-record write ceiling is the one the contract describes. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written; the per-record creates a naive or older client loops over were not + * capped at all, which is backwards — the batch endpoint is the one a + * well-behaved client uses. Eleven of them share one generous per-account + * bucket now, and the operations publish its name and its cap so a client that + * meets the ceiling can read which one it met. + * + * A published number is only worth reading if it cannot drift from the code, so + * this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same + * technique `rate-limit-auth-surface.test.ts` uses — and holds the emitted + * OpenAPI description to what came back. Reading both from the same constant + * would prove nothing: the sentence is written by hand, deliberately, and this + * is what keeps the hand-written half honest. + * + * The route half is structural: every route in the list below calls the shared + * helper. Its limit is that it reads the file rather than the running handler, + * so a call placed after an early return would still count as present. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RECORD_WRITE_BUCKET_PREFIX, + RECORD_WRITE_LIMIT, + RECORD_WRITE_WINDOW_MS, + checkRecordWriteRateLimit, +} from "@/lib/rate-limit"; +import { buildOpenApiDocument } from "@/lib/openapi/registry"; +import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); + +import { prisma } from "@/lib/db"; + +/** + * The single-record writes on the shared bucket, paired with the operation + * that publishes it. Hand-listed on purpose: the point of the list is that + * adding a create without the limiter is a decision somebody has to make, and + * a list derived from the routes that already call it could not notice one. + */ +const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [ + ["src/app/api/measurements/route.ts", "/api/measurements"], + ["src/app/api/mood-entries/route.ts", "/api/mood-entries"], + ["src/app/api/medications/intake/route.ts", "/api/medications/intake"], + [ + "src/app/api/medications/[id]/intake/route.ts", + "/api/medications/{id}/intake", + ], + ["src/app/api/labs/route.ts", "/api/labs"], + [ + "src/app/api/custom-metrics/[id]/entries/route.ts", + "/api/custom-metrics/{id}/entries", + ], + ["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"], + ["src/app/api/vaccinations/route.ts", "/api/vaccinations"], + ["src/app/api/allergies/route.ts", "/api/allergies"], + ["src/app/api/biomarkers/route.ts", "/api/biomarkers"], + ["src/app/api/encounters/route.ts", "/api/encounters"], +]; + +/** The published sentence, read from the object the route modules spread. */ +const published = recordWriteRateLimitResponse["429"].description; + +beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockReset(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); +}); + +describe("the shared single-record write bucket", () => { + it("keys on the account under the name the contract publishes", async () => { + await checkRecordWriteRateLimit("account-7"); + + const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!; + // `$queryRaw\`INSERT … VALUES (${key}, …)\`` lands the key at index 1 and + // the window interval at index 2. + expect(call[1]).toBe("record-write:account-7"); + expect(call[2]).toBe(`${RECORD_WRITE_WINDOW_MS} milliseconds`); + expect(published).toContain("`record-write:`"); + expect(`${RECORD_WRITE_BUCKET_PREFIX}:account-7`).toBe(call[1]); + }); + + it("refuses at the cap the contract publishes", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: RECORD_WRITE_LIMIT, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject( + { allowed: true, limit: RECORD_WRITE_LIMIT, remaining: 0 }, + ); + + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { + count: RECORD_WRITE_LIMIT + 1, + reset_at: new Date(Date.now() + 60_000), + }, + ] as never); + await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject( + { allowed: false }, + ); + + const windowSeconds = RECORD_WRITE_WINDOW_MS / 1000; + expect(published).toContain( + `${RECORD_WRITE_LIMIT} requests per ${windowSeconds} seconds`, + ); + }); + + it("says the headers describe that bucket, and declares them", () => { + expect(Object.keys(recordWriteRateLimitResponse["429"].headers)).toEqual([ + "Retry-After", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ]); + expect(published).toContain("Retry-After"); + }); + + it("is called by every route that publishes it", () => { + const missing = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes("checkRecordWriteRateLimit("); + }).map(([route]) => route); + expect(missing).toEqual([]); + }); + + it("names the code it publishes at every site", () => { + // The prose is not the discriminator: two of these routes consult a second + // bucket of their own, and a client that can only read `error` cannot tell + // which one refused it. + const silent = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes('errorCode: "record_write.rate_limited"'); + }).map(([route]) => route); + expect(silent).toEqual([]); + expect(published).toContain("record_write.rate_limited"); + }); + + it("is published on every operation that answers it", () => { + const doc = buildOpenApiDocument() as { + paths: Record< + string, + { post?: { responses?: Record } } + >; + }; + const unpublished = ON_THE_SHARED_BUCKET.filter( + ([, path]) => + doc.paths[path]?.post?.responses?.["429"]?.description !== published, + ).map(([, path]) => path); + expect(unpublished).toEqual([]); + }); +}); diff --git a/src/__tests__/sharing-legacy-client-contract.test.ts b/src/__tests__/sharing-legacy-client-contract.test.ts index 67eaa3d0b..63dd4f71e 100644 --- a/src/__tests__/sharing-legacy-client-contract.test.ts +++ b/src/__tests__/sharing-legacy-client-contract.test.ts @@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => { expect(capabilities.sections).toEqual(fixture.sections); expect(capabilities.recordKind).toBe(fixture.recordKind); expect(capabilities.inSharedRecord).toBe(true); - // Resolved server-side and rendered, never re-derived: `canAdd` follows - // `canWrite`, and `canManage` follows the published `manageableDomains` - // list rather than the level — a delegate's management reach is the - // server's answer, section by section. + // Resolved server-side and rendered, never re-derived: `canWrite` is the + // published level, and both per-section answers follow the published + // lists rather than the level — a delegate's reach is the server's + // answer, section by section. expect(capabilities.canWrite).toBe(fixture.canWrite); - expect(capabilities.canAdd).toBe(fixture.canWrite); const manageable = active?.manageableDomains ?? []; expect(capabilities.canManage).toBe(manageable.length > 0); expect(manageable.length > 0).toBe(fixture.level === "manage"); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index b991b8cab..6df0bd37e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -109,6 +109,10 @@ const PINNED_AFFORDANCES: Record< "src/components/admin/module-availability-section.tsx": { "toast.success": 1, }, + // Reads a ledger, writes nothing: the wash marks how fresh one account's + // off-host copy is, and the state it paints green came out of the database + // rather than out of a request this card just made. + "src/components/admin/offhost-backups-section.tsx": { "text-success": 1 }, "src/components/admin/recent-audit-preview.tsx": { CheckCircle2: 2, "text-success": 1, diff --git a/src/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts new file mode 100644 index 000000000..57426b41b --- /dev/null +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -0,0 +1,265 @@ +/** + * Every 422 raised by a Zod `safeParse` names the fields it refused. + * + * `returnAllZodIssues` / `apiValidationError` emit `details.issues` — one entry + * per rejected field — so a client with three bad fields fixes all three in one + * round-trip instead of three. Most routes use them. Fifty-nine refusal sites + * did not: they returned the correct 422 with a single opaque sentence, and a + * client could not map the failure onto a field. Twelve of those put a dotted + * machine token in `error` instead of a sentence, so `error` was sometimes text + * to show a person and sometimes a token that must not be shown, with nothing + * in the payload to tell them apart. + * + * The matcher is per-site, not per-file, because per-file is what let this + * grow: a route that used the helper once and a bare `apiError(..., 422)` + * somewhere else in the same file looked compliant to a `grep -l` sweep. So + * every `if (!.success)` block whose identifier came from a `safeParse` + * in that file is read on its own, and only the blocks that answer 422 are + * judged — a 400, a 401 or a 403 raised off a parse result is a different + * refusal and is left alone. + * + * The second half of the same audit item is the dotted token in `error`, and + * it is checked by its own sweep at the bottom of this file rather than by the + * per-site matcher above: `report-selection.leaves.unknown` was decided by + * `if (!minted.ok)` rather than by a parse result, so the block matcher could + * not see it while the string it answered with was the defect itself. The + * token sweep reads the refused string, not the condition that chose it, so a + * site cannot hide behind the shape of its own `if`. + * + * Its limits, stated so nobody reads more into a green run than is there: + * a refusal built somewhere other than the `if (!x.success)` block that decided + * it — a helper called with the error, a status assembled from a variable — is + * invisible to the issue-list half, and so is a route that hands the `ZodError` + * to shared code outside `src/app/api`. It catches the shape this defect + * actually took, and the count assertions below are what keep it from passing + * on a matcher that silently stopped matching anything. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Routes that answer a shape refusal without the issue list, each with the + * reason it must differ. Empty today: every 422 raised off a `safeParse` under + * `src/app/api` carries `details.issues`. An entry here is a decision, not a + * backlog item — write why the list cannot ship, not when it will. + */ +const ALLOWLIST: Record = {}; + +/** Every `route.ts` under `src/app/api`, tests excluded. */ +function routeFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...routeFiles(full)); + continue; + } + if (entry.name === "route.ts") found.push(full); + } + return found; +} + +/** + * The `{ … }` (or single statement) that follows an `if (…)`, brace-balanced. + * + * A refusal is often one statement with no braces — `if (!parsed.success) + * return apiError("Invalid data", 422);` — so both forms are read. + */ +function refusalBlock(src: string, afterCondition: number): string { + let i = afterCondition; + while (i < src.length && /\s/.test(src[i])) i++; + if (src[i] === "{") { + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return src.slice(i, j + 1); + } + } + return src.slice(i); + } + const end = src.indexOf(";", i); + return src.slice(i, end === -1 ? src.length : end + 1); +} + +interface RefusalSite { + route: string; + line: number; + carriesIssues: boolean; +} + +/** Every 422 refusal decided by a `safeParse` result, across the route tree. */ +function scanRefusalSites(): RefusalSite[] { + const sites: RefusalSite[] = []; + for (const file of routeFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + // Identifiers bound to a parse result. Both call styles are in the tree: + // `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`. + const parsed = new Set(); + for (const m of src.matchAll( + /(?:const|let)\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?[^;]*?safeParse\s*\(/g, + )) { + parsed.add(m[1]); + } + if (parsed.size === 0) continue; + + for (const m of src.matchAll( + /if\s*\(\s*!\s*([A-Za-z0-9_$]+)\.success\s*\)/g, + )) { + if (!parsed.has(m[1])) continue; + const block = refusalBlock(src, (m.index ?? 0) + m[0].length); + // `returnAllZodIssues` and `apiValidationError` answer 422 unless told + // otherwise, so a block that calls one is a 422 site whether or not the + // number is spelled: a future `refuse422(parsed.error)` helper would + // otherwise be invisible for the same reason. + const carriesIssues = /returnAllZodIssues|apiValidationError/.test(block); + if (!/\b422\b/.test(block) && !carriesIssues) continue; + if ( + /\b(?:400|401|403|409|413|415|429|500|502)\b/.test(block) && + !/\b422\b/.test(block) + ) + continue; + sites.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: src.slice(0, m.index).split("\n").length, + carriesIssues, + }); + } + } + return sites; +} + +describe("Zod shape refusals carry the issue list", () => { + const sites = scanRefusalSites(); + + it("finds the refusal sites it is meant to judge", () => { + // A matcher that stops matching would otherwise report a clean zero it did + // not earn. Two hundred and forty-eight sites when this was written, so + // the old floor of 40 would have survived losing five matches in six. + expect(sites.length).toBeGreaterThan(200); + }); + + it("answers every 422 with details.issues", () => { + const missing = sites + .filter((site) => !site.carriesIssues && !(site.route in ALLOWLIST)) + .map((site) => `${site.route}:${site.line}`); + expect(missing).toEqual([]); + }); + + it("keeps no stale allowlist entries", () => { + const stale = Object.keys(ALLOWLIST).filter( + (route) => !sites.some((site) => site.route === route), + ); + expect(stale).toEqual([]); + }); +}); + +/** + * The dotted token in `error`, wherever it is refused from. + * + * `error` is the field a client shows a person. A 422 that puts + * `report-selection.leaves.unknown` there hands the UI a string it must not + * render and gives it nothing else to branch on. That site was decided by + * `if (!minted.ok)`, not by a parse result, so the per-site matcher above ran + * past it — which is the argument for reading the refused string directly: + * whatever chose the refusal, the string is the defect. + * + * The inventory below is frozen and may only shrink. Each entry says why the + * token is still the whole `error` string; a new one fails until somebody + * either writes a sentence or writes the reason. + */ +const TOKEN_IN_ERROR_422: Record = { + "src/app/api/auth/me/coach-prefs/route.ts coach-prefs.body.invalid_shape": + "The status does not move for a shape refusal, so moving the string too would be an unforced second wire change; `meta.errorCode` now carries the same token.", + "src/app/api/auth/me/report-selection/route.ts report-selection.body.invalid_shape": + "Same shape refusal, same reason.", + "src/app/api/auth/me/source-priority/route.ts source-priority.body.invalid_shape": + "Same shape refusal, same reason.", + "src/app/api/insights/chat/messages/[id]/feedback/route.ts feedback.body.invalid": + "Same shape refusal, same reason.", + "src/app/api/insights/chat/route.ts coach.request.invalid": + "Same shape refusal, same reason.", + "src/app/api/auth/me/health-score-config/route.ts health-score-config.body.invalid_json": + "The published 422 description spells this exact body out as what a non-JSON request earns. It is the one operation whose contract promises the token, so the token is what it answers with.", + "src/app/api/coach/reminder-suggestions/route.ts coach.suggestion.unknownCadence": + "Predates this sweep and carries no `meta.errorCode` yet; the Coach reminder surfaces move as one piece or not at all.", + "src/app/api/coach/reminders/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", + "src/app/api/coach/reminders/[id]/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", + "src/app/api/coach/suggested-actions/route.ts coach.action.unknownInterval": + "Predates this sweep, same surface.", + "src/app/api/coach/suggested-actions/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", +}; + +/** A dotted machine token: no spaces, at least one dot, no sentence in sight. */ +const DOTTED_TOKEN = /^[a-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/; + +/** Every `.ts` under `src/app/api`, tests excluded — not only `route.ts`. */ +function apiFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...apiFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Every 422 whose `error` string is a dotted token, keyed ` `. */ +function tokenRefusals(): string[] { + const found: string[] = []; + for (const file of apiFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + const route = relative(process.cwd(), file).split(sep).join("/"); + const shapes = [ + /apiError\(\s*"([^"]+)"\s*,\s*422/g, + /apiValidationError\(\s*"([^"]+)"/g, + /new HttpError\(\s*422\s*,\s*"([^"]+)"/g, + ]; + for (const shape of shapes) { + for (const m of src.matchAll(shape)) { + if (!DOTTED_TOKEN.test(m[1])) continue; + const key = `${route} ${m[1]}`; + if (!found.includes(key)) found.push(key); + } + } + } + return found; +} + +describe("a 422 does not answer with a machine token where the sentence goes", () => { + const refusals = tokenRefusals(); + + it("finds the sites it is meant to judge", () => { + // Eleven when this was written, twelve before the report-selection leaf + // refusal was rewritten. A matcher that stopped matching would agree with + // an empty inventory. + expect(refusals.length).toBeGreaterThan(8); + }); + + it("keeps the token in `error` only where a reason is written down", () => { + const unexplained = refusals + .filter((key) => !(key in TOKEN_IN_ERROR_422)) + .sort(); + expect(unexplained).toEqual([]); + }); + + it("keeps no stale inventory entry", () => { + const stale = Object.keys(TOKEN_IN_ERROR_422) + .filter((key) => !refusals.includes(key)) + .sort(); + expect(stale).toEqual([]); + }); +}); diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f9548687e..24ac6042a 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -25,6 +25,7 @@ import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section" import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section"; import { BackupsSection } from "@/components/admin/backups-section"; +import { OffhostBackupsSection } from "@/components/admin/offhost-backups-section"; import { DangerZoneSection } from "@/components/admin/danger-zone-section"; import { EncryptionSection } from "@/components/admin/encryption-section"; import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; @@ -144,9 +145,12 @@ export function AdminSectionRenderer({ ); case "backups": + // Two cards, two independent failure modes: the copies held in this + // database, then the copies that reached the operator's bucket. return ( + ); case "encryption": diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 224e1148d..c34397817 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -259,6 +259,10 @@ function sourceClient() { }, ]), }, + // The setup answers. Absent for this fixture's account, which is the + // ordinary case: the export has to answer for a record that never entered + // the flow without inventing one. + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The score as it was SHOWN on a local day. Not recomputable: today's // number always comes from today's rows, so once the readings behind this // day moved, nothing can reproduce what it said. diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 409a5b345..2a2049d47 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -57,6 +57,7 @@ import { import { restoreProfileData } from "@/lib/export/profile-backup"; import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup"; import { restoreHealthScoreData } from "@/lib/export/health-score-backup"; +import { restoreOnboardingData } from "@/lib/export/onboarding-backup"; import { restoreVisitsData } from "@/lib/export/visits-backup"; import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup"; import { restoreSensitiveData } from "@/lib/export/sensitive-backup"; @@ -111,6 +112,7 @@ interface RestoreResponse { correlationPatterns: number; intradayProfiles: number; healthScoreRecords: number; + onboardingRecords: number; practitioners: number; encounters: number; encounterLinks: number; @@ -1377,6 +1379,14 @@ const handler = apiHandler( payload, ); + // The needs-based setup answers. Both ends live in + // `src/lib/export/onboarding-backup.ts`, same as the three above. + const onboardingCleared = await restoreOnboardingData( + tx, + ownerId, + payload, + ); + const biomarkerByName = new Map(); const restoredBiomarkerIds = new Set(); for (const biomarker of payload.biomarkers) { @@ -1979,6 +1989,7 @@ const handler = apiHandler( correlationPatterns: profileCleared.correlationPatterns, intradayProfiles: intradayCleared.intradayProfiles, healthScoreRecords: healthScoreCleared.healthScoreRecords, + onboardingRecords: onboardingCleared.onboardingRecords, practitioners: visitsCleared.practitioners, encounters: visitsCleared.encounters, encounterLinks: visitsCleared.encounterLinks, diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts new file mode 100644 index 000000000..70b583451 --- /dev/null +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -0,0 +1,199 @@ +/** + * `GET /api/admin/backups` — the off-host leg. + * + * The rows the weekly pass writes into this database were already covered by + * the page that lists them. What was not covered is the question this response + * now answers: per account, when did an encrypted copy last reach the + * operator's bucket, and is that inside what the nightly schedule promises. + * + * The bucket is never touched here, and that is the assertion that matters — + * the verdicts come from the ledger the worker writes, so an operator whose + * worker holds no listing grant on the bucket still gets a true answer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/jobs/job-failures", () => ({ + readLastQueueRun: vi.fn(async () => null), +})); + +const queryRawMock = vi.fn(); +const findManyMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: (...a: unknown[]) => queryRawMock(...a), + user: { findMany: (...a: unknown[]) => findManyMock(...a) }, + }, +})); + +const configuredMock = vi.fn(() => true); +vi.mock("@/lib/jobs/offhost-backup", () => ({ + offhostBackupConfigured: () => configuredMock(), +})); + +import { GET } from "../route"; +import type { BackupsList } from "@/types/backups"; + +const HOUR = 3_600_000; + +async function read(): Promise { + const res = await (GET as unknown as () => Promise)(); + expect(res.status).toBe(200); + return (await res.json()).data as BackupsList; +} + +beforeEach(() => { + vi.clearAllMocks(); + configuredMock.mockReturnValue(true); + queryRawMock.mockResolvedValue([]); + findManyMock.mockResolvedValue([]); +}); + +describe("GET /api/admin/backups — off-host freshness", () => { + it("rates every account against the nightly schedule without listing the bucket", async () => { + const now = Date.now(); + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastAttemptAt: new Date(now - 3 * HOUR), + lastSuccessAt: new Date(now - 3 * HOUR), + // The column is BigInt — an object may be 80 GB — and the wire type + // is a plain number. + sizeBytes: BigInt(4096), + }, + }, + { + id: "u2", + username: "account-two", + offhostBackupState: { + lastAttemptAt: new Date(now - 31 * HOUR), + lastSuccessAt: new Date(now - 31 * HOUR), + sizeBytes: BigInt(8192), + }, + }, + { + id: "u3", + username: "account-three", + offhostBackupState: { + lastAttemptAt: new Date(now - 100 * HOUR), + lastSuccessAt: new Date(now - 100 * HOUR), + sizeBytes: BigInt(128), + }, + }, + { + id: "u4", + username: "account-four", + // Walked by a run, nothing landed. + offhostBackupState: { + lastAttemptAt: new Date(now - 2 * HOUR), + lastSuccessAt: null, + sizeBytes: null, + }, + }, + // No run has recorded this one at all — the state of every account on + // the morning the ledger ships. + { id: "u5", username: "account-five", offhostBackupState: null }, + ]); + + const body = await read(); + + expect(body.offhost.configured).toBe(true); + expect(body.offhost.periodHours).toBe(24); + // Worst first, alphabetical within a verdict. The database hands them + // back by username; the one account that needs attention must not be + // wherever the alphabet leaves it on a hundred-account cohort. + expect( + body.offhost.rows.map((row) => [row.username, row.freshness]), + ).toEqual([ + ["account-three", "stale"], + ["account-two", "due"], + ["account-four", "never"], + ["account-five", "unknown"], + ["account-one", "fresh"], + ]); + const byName = new Map(body.offhost.rows.map((row) => [row.username, row])); + expect(byName.get("account-one")?.sizeBytes).toBe(4096); + expect(typeof byName.get("account-one")?.sizeBytes).toBe("number"); + expect(byName.get("account-one")?.ageHours).toBe(3); + expect(byName.get("account-four")?.lastSuccessAt).toBeNull(); + expect(byName.get("account-four")?.sizeBytes).toBeNull(); + expect(byName.get("account-four")?.lastAttemptAt).not.toBeNull(); + // `unknown` is the honest one: the ledger has no history for this + // account, which is not the same claim as "the bucket is empty". + expect(byName.get("account-five")?.lastAttemptAt).toBeNull(); + }); + + it("says so when neither the environment nor the ledger knows of any upload", async () => { + configuredMock.mockReturnValue(false); + findManyMock.mockResolvedValue([ + { id: "u1", username: "account-one", offhostBackupState: null }, + ]); + + const body = await read(); + + expect(body.offhost).toEqual({ + configured: false, + periodHours: 24, + rows: [], + }); + }); + + it("lets a ledger row outrank this process's own environment", async () => { + // A hand-rolled split deployment can give BACKUP_S3_* to the worker and + // not to the web process. The rows are proof uploads are happening; + // saying "nothing leaves this host" over them would be this process + // reporting on a job it cannot see. + configuredMock.mockReturnValue(false); + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastAttemptAt: new Date(), + lastSuccessAt: new Date(), + sizeBytes: BigInt(4096), + }, + }, + ]); + + const body = await read(); + + expect(body.offhost.configured).toBe(true); + expect(body.offhost.rows.map((row) => row.freshness)).toEqual(["fresh"]); + }); + + it("never ships a credential or an object key with the verdicts", async () => { + findManyMock.mockResolvedValue([ + { + id: "u1", + username: "account-one", + offhostBackupState: { + lastAttemptAt: new Date(), + lastSuccessAt: new Date(), + sizeBytes: BigInt(1), + }, + }, + ]); + + const body = await read(); + + expect(Object.keys(body.offhost.rows[0] ?? {}).sort()).toEqual([ + "ageHours", + "freshness", + "lastAttemptAt", + "lastSuccessAt", + "sizeBytes", + "userId", + "username", + ]); + expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i); + }); +}); diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 04a8c2139..3aab2ff45 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -13,6 +13,12 @@ * Without those two the page cannot tell a working backup from one that * stopped six weeks ago — every row it lists has a perfectly ordinary * timestamp either way. + * + * And alongside both, the off-host leg: one row per account saying when this + * host last put that account's encrypted copy in the operator's bucket and how + * stale that is against the nightly schedule. It comes from the ledger the + * worker writes, never from a bucket listing — the answer has to hold on a host + * whose worker holds no listing grant on the bucket. */ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; @@ -21,10 +27,20 @@ import { annotate } from "@/lib/logging/context"; import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; import { readLastQueueRun } from "@/lib/jobs/job-failures"; import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status"; +import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup"; +import { + classifyOffhostBackup, + OFFHOST_BACKUP_PERIOD_HOURS, + type OffhostBackupFreshness, +} from "@/lib/jobs/offhost-backup-freshness"; // v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts` // so callers (in particular `components/admin/backups-section.tsx`) don't // have to reach across the component → route-handler layer boundary. -import type { BackupRow, BackupsList } from "@/types/backups"; +import type { + BackupRow, + BackupsList, + OffhostAccountRow, +} from "@/types/backups"; export const dynamic = "force-dynamic"; @@ -67,6 +83,79 @@ export const GET = apiHandler(async () => { createdAt: row.created_at.toISOString(), })); + // The off-host leg, per account, read from the ledger the nightly worker + // writes rather than from the bucket. The page must be able to answer "which + // account has no recent copy off-host" on a host whose worker holds the + // three grants the runbook documents and no listing grant at all, and a + // listing call from a page render would put the operator's credentials on + // the request path of every page view. + const now = new Date(); + const offhostAccounts = await prisma.user.findMany({ + select: { + id: true, + username: true, + offhostBackupState: { + select: { + lastAttemptAt: true, + lastSuccessAt: true, + sizeBytes: true, + }, + }, + }, + orderBy: { username: "asc" }, + }); + + // Evidence beats this process's environment. `offhostBackupConfigured()` + // reads the variables of whichever process serves this request, and a + // hand-rolled split that gives `BACKUP_S3_*` only to the worker would make + // the web process say "nothing leaves this host" over a ledger full of + // fresh rows. A row in that table is proof an upload happened; no + // environment read can outrank it. + const configured = + offhostBackupConfigured() || + offhostAccounts.some((account) => account.offhostBackupState !== null); + + const offhostRows: OffhostAccountRow[] = offhostAccounts.map((account) => { + const state = account.offhostBackupState; + const verdict = classifyOffhostBackup({ + lastAttemptAt: state?.lastAttemptAt ?? null, + lastSuccessAt: state?.lastSuccessAt ?? null, + now, + }); + return { + userId: account.id, + username: account.username, + lastAttemptAt: state?.lastAttemptAt.toISOString() ?? null, + lastSuccessAt: state?.lastSuccessAt?.toISOString() ?? null, + // BigInt column -> number on the wire. The value is bytes of one + // object, capped by the uploader at 80 GB, so it is nowhere near + // Number.MAX_SAFE_INTEGER and JSON has no BigInt. + sizeBytes: + state?.sizeBytes === null || state?.sizeBytes === undefined + ? null + : Number(state.sizeBytes), + ageHours: verdict.ageHours, + freshness: verdict.freshness, + }; + }); + + // Worst first. Sorting by username puts the one account that needs + // attention wherever the alphabet happens to leave it, which on a + // hundred-account cohort is the same "one bad row hides in the crowd" + // problem this card was built to end, moved out of a count and into a list. + const FRESHNESS_ORDER: Record = { + stale: 0, + due: 1, + never: 2, + unknown: 3, + fresh: 4, + }; + offhostRows.sort( + (a, b) => + FRESHNESS_ORDER[a.freshness] - FRESHNESS_ORDER[b.freshness] || + a.username.localeCompare(b.username), + ); + const payload: BackupsList = { rows: list, // Matches the retention window the backup-prune job enforces so the @@ -77,8 +166,15 @@ export const GET = apiHandler(async () => { .filter((row) => row.type === SCHEDULED_BACKUP_TYPE) .map((row) => row.created_at), lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE), - now: new Date(), + now, }), + offhost: { + configured, + periodHours: OFFHOST_BACKUP_PERIOD_HOURS, + // An unconfigured host has nothing to list, and the card says so rather + // than painting an empty table that reads as "no problems". + rows: configured ? offhostRows : [], + }, }; return apiSuccess(payload); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index bfbaf9b89..3c32e5953 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -16,9 +16,32 @@ import { invalidateServerDefaultTimezone, isValidTimezone, } from "@/lib/tz/resolver"; +import { summariseGlitchtipDelivery } from "@/lib/monitoring/glitchtip-delivery"; export const dynamic = "force-dynamic"; +/** The delivery verdict, flattened onto the settings payload. */ +function summariseGlitchtipDeliveryFields(state: { + lastOkAt?: Date | null; + lastFailureAt?: Date | null; + lastFailureReason?: string | null; +}) { + const summary = summariseGlitchtipDelivery( + { + lastOkAt: state.lastOkAt ?? null, + lastFailureAt: state.lastFailureAt ?? null, + lastFailureReason: state.lastFailureReason ?? null, + }, + new Date(), + ); + return { + glitchtipReportsDelivering: summary.reportsDelivering, + glitchtipEverDelivered: summary.everDelivered, + glitchtipLastFailureReason: summary.lastFailureReason, + glitchtipDeliveryWindowHours: summary.windowHours, + }; +} + export const GET = apiHandler(async () => { await requireAdmin(); annotate({ action: { name: "admin.settings.get" } }); @@ -48,6 +71,15 @@ export const GET = apiHandler(async () => { glitchtipEnabled: settings?.glitchtipEnabled ?? false, glitchtipDsn: settings?.glitchtipDsn ?? null, glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production", + // The delivery outcome, not the intent, and resolved here rather than on + // the client: a DSN that is set and parses says a target was typed, only + // this says something left the host, and whether it left recently enough + // is a question about a clock that belongs with the ledger. + ...summariseGlitchtipDeliveryFields({ + lastOkAt: settings?.glitchtipLastOkAt ?? null, + lastFailureAt: settings?.glitchtipLastFailureAt ?? null, + lastFailureReason: settings?.glitchtipLastFailureReason ?? null, + }), reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, // Document vault limits (per-file cap + per-user quota default). BigInt @@ -274,6 +306,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { glitchtipEnabled: settings.glitchtipEnabled, glitchtipDsn: settings.glitchtipDsn, glitchtipEnvironment: settings.glitchtipEnvironment ?? "production", + ...summariseGlitchtipDeliveryFields({ + lastOkAt: settings.glitchtipLastOkAt, + lastFailureAt: settings.glitchtipLastFailureAt, + lastFailureReason: settings.glitchtipLastFailureReason, + }), reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, documentMaxFileBytes: settings.documentMaxFileBytes, diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts index db647d982..27ce847d4 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -2,10 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { - apiSuccess, apiError, + apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; @@ -58,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = generateSchema.safeParse(body ?? {}); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const existing = await prisma.appSettings.findUnique({ diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 6c3abfaae..56440ce29 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -6,7 +6,13 @@ import { type AITestOverride, } from "@/lib/ai/provider"; import { singleUserTurn } from "@/lib/ai/types"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { @@ -69,7 +75,11 @@ export const POST = apiHandler(async (request: NextRequest) => { if (error) return error; const parsed = aiTestOverrideSchema.safeParse(data); if (!parsed.success) { - return apiError("Invalid override payload", 422); + return apiValidationError( + "Invalid override payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } override = parsed.data; } diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index f4e69e40c..2f9d4d360 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -15,11 +15,13 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -84,7 +86,17 @@ async function postAllergy(request: NextRequest): Promise { // A READ or WRITE delegate reaches neither: the destination is not on their // Settings list, and this line refuses them with `sharing.access.denied` // regardless. - const { user } = await requireRecordAuth("manage", "profile"); + const { user, actor } = await requireRecordAuth("manage", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index cae5d1db0..1aaf77170 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -37,7 +37,12 @@ */ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -80,7 +85,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = bodySchema.safeParse(body); if (!parsed.success) { - return apiError("identifier required", 422); + return apiValidationError( + "identifier required", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const identifier = parsed.data.identifier; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 6d48a15b5..eb3272f8f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,7 +3,12 @@ import { loginPasswordSchema } from "@/lib/validations/auth"; import { verifyPassword } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; @@ -55,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = loginPasswordSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid credentials", 422); + return apiValidationError( + "Invalid credentials", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { email, password } = parsed.data; diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index d76c3fdb2..4e1bf8593 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -15,8 +15,13 @@ * helper. The Coach prompt builder + snapshot builder both read this * row on every turn — there's no caching layer to invalidate. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { @@ -57,7 +62,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "coach-prefs.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "coach-prefs.body.invalid_json", + }); } // v1.32.22 (M4) — pull the optimistic-concurrency base token off before the @@ -74,7 +81,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.coach-prefs.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - throw new HttpError(422, "coach-prefs.body.invalid_shape"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "coach-prefs.body.invalid_shape", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "coach-prefs.body.invalid_shape" }, + ); } // Persist the canonical defaulted form so the column shape stays diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index ec6d97132..81f145a57 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -65,7 +65,7 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - return apiError("Invalid JSON body", 422, { + return apiError("Invalid JSON body", 400, { errorCode: "cycle-prefs.body.invalid_json", }); } diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts index 5ed9ad5b2..0726fb835 100644 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts @@ -212,7 +212,11 @@ describe("PATCH /api/auth/me/disable-coach", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/disable-coach", { @@ -222,7 +226,11 @@ describe("PATCH /api/auth/me/disable-coach", () => { }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "disable-coach.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/disable-coach/route.ts b/src/app/api/auth/me/disable-coach/route.ts index a5cb1bd7f..7dddc8624 100644 --- a/src/app/api/auth/me/disable-coach/route.ts +++ b/src/app/api/auth/me/disable-coach/route.ts @@ -21,7 +21,7 @@ */ import { z } from "zod"; -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -78,7 +78,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "disable-coach.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "disable-coach.body.invalid_json", + }); } const parsed = patchBodySchema.safeParse(body); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 8e9718220..d1ec12e0f 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -20,8 +20,10 @@ import { import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -63,7 +65,11 @@ export const POST = apiHandler(async (req: Request) => { } const parsed = mfaDisableSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const factor = await verifyMfaFactor( diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 1251829d0..2aac38635 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -25,8 +25,10 @@ import { import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -78,7 +80,11 @@ export const POST = apiHandler(async (req: Request) => { } const parsed = totpConfirmSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid code", 422); + return apiValidationError( + "Invalid code", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // Decrypt is fail-closed — a missing/rotated key throws rather than diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 61af3c671..cff4259d6 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -13,8 +13,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -39,7 +41,11 @@ export const PATCH = apiHandler( const parsed = mfaWebauthnRenameSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const existing = await prisma.webauthnMfaCredential.findUnique({ diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 371e91174..e96388a1f 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -11,8 +11,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -34,7 +36,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { challengeId, credential, name } = parsed.data; diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index 7a14aef25..bb675fc73 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -285,7 +285,11 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/modules", { method: "PATCH", @@ -293,7 +297,11 @@ describe("PATCH /api/auth/me/modules", () => { headers: { "Content-Type": "application/json" }, }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "modules.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 05060be90..5c10e8d93 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -25,7 +25,7 @@ * carries it. The semantics are a DISABLED allowlist: a key set to * `false` disables that module; `true` (or absence) leaves it enabled. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -84,7 +84,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "modules.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "modules.body.invalid_json", + }); } // v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index c8d503649..dc485fe69 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -324,7 +324,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { ); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/notification-prefs", { @@ -334,7 +338,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "notification-prefs.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/notification-prefs/route.ts b/src/app/api/auth/me/notification-prefs/route.ts index b7e64cfd3..3770a380e 100644 --- a/src/app/api/auth/me/notification-prefs/route.ts +++ b/src/app/api/auth/me/notification-prefs/route.ts @@ -23,7 +23,7 @@ * extra round-trip. Rate-limit is intentionally generous (60/min) — * matches the disable-coach route which is the closest analogue. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -86,7 +86,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "notification-prefs.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "notification-prefs.body.invalid_json", + }); } // v1.32.22 (M1) — pull the optimistic-concurrency base token off the body diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index dd9c4b6a9..bbb85b9c2 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -16,8 +16,14 @@ * Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing * outside that route ever read. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; @@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "report-selection.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "report-selection.body.invalid_json", + }); } const parsed = savedReportProfileSchema.safeParse(body ?? {}); @@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.report-selection.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - throw new HttpError(422, "report-selection.body.invalid_shape"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "report-selection.body.invalid_shape", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "report-selection.body.invalid_shape" }, + ); } const minted = selectionFromRequest(parsed.data); @@ -66,7 +84,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.report-selection.put.invalid" }, meta: { unknownLeaves: minted.error.unknownLeaves }, }); - throw new HttpError(422, "report-selection.leaves.unknown"); + // Not a Zod refusal, so there is no issue list to carry — but the same + // rule applies: a sentence in `error`, the machine token in + // `meta.errorCode`, and the leaves that were refused named beside it. + return apiError( + "Report selection names leaves this build does not know", + 422, + { + errorCode: "report-selection.leaves.unknown", + unknownLeaves: minted.error.unknownLeaves, + }, + ); } // Persist the canonical ordering rather than the caller's, so two clients diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index c6a64acbe..52dcfc7cf 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -54,6 +54,14 @@ import { resolveModuleMap, getOperatorModuleAvailability, } from "@/lib/modules/gate"; +import { + emptyOnboardingNeeds, + readHeldUnitPreferences, +} from "@/lib/onboarding/needs"; +import { + loadOnboardingRecordRow, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; import { parseTourProgress } from "@/lib/onboarding/tour-progress"; import { parseNotificationPrefs } from "@/lib/validations/notification-prefs"; import { resolveAccountAccess } from "@/lib/sharing/account-access"; @@ -107,35 +115,63 @@ export const GET = apiHandler(async () => { // no switch at all, and the reason the own-record payload is untouched. const sections = accountAccess.active?.sections ?? null; - const [record, cycleProfile, resolvedModules, moduleAvailability] = - await Promise.all([ - // The actor's own row is already in hand; a switched session needs the - // record's `gender`, which is the column the cycle gate derives from. - recordId === user.id - ? Promise.resolve({ gender: user.gender }) - : prisma.user.findUnique({ - where: { id: recordId }, - select: { gender: true }, - }), - prisma.cycleProfile.findUnique({ - where: { userId: recordId }, - select: { cycleTrackingEnabled: true }, - }), - // v1.18.0 — resolved module enable/disable map for every toggleable - // module. cycle/coach reflect their real delegated state (the cycle - // gate / disableCoach + operator assistant flag); the rest read the - // disabled-allowlist `modulePreferencesJson`. Default-on. Clients - // hide a whole module surface end-to-end when its key is `false`. - resolveModuleMap(recordId), - // v1.18.0 — operator-layer availability map (server-wide kill-switch). - // The resolved `modules` map above already AND-s this in, so it cannot - // distinguish operator-off from user-off. The Modules hub needs that - // distinction to render an operator-disabled module as a read-only - // "disabled server-wide" row instead of a live toggle that no-ops. - // Server-WIDE, so it is the same map for every record and is not - // re-scoped here. - getOperatorModuleAvailability(), - ]); + const [ + record, + cycleProfile, + resolvedModules, + moduleAvailability, + onboardingRow, + ] = await Promise.all([ + // The actor's own row is already in hand; a switched session needs the + // record's `gender`, which is the column the cycle gate derives from. + recordId === user.id + ? Promise.resolve({ + gender: user.gender, + glucoseUnit: user.glucoseUnit, + unitPreference: user.unitPreference, + }) + : prisma.user.findUnique({ + where: { id: recordId }, + select: { + gender: true, + // v1.39 (C1) — the record's own unit columns, for the setup + // flow's "never re-ask a value the account holds" rule. + glucoseUnit: true, + unitPreference: true, + }, + }), + prisma.cycleProfile.findUnique({ + where: { userId: recordId }, + select: { cycleTrackingEnabled: true }, + }), + // v1.18.0 — resolved module enable/disable map for every toggleable + // module. cycle/coach reflect their real delegated state (the cycle + // gate / disableCoach + operator assistant flag); the rest read the + // disabled-allowlist `modulePreferencesJson`. Default-on. Clients + // hide a whole module surface end-to-end when its key is `false`. + resolveModuleMap(recordId), + // v1.18.0 — operator-layer availability map (server-wide kill-switch). + // The resolved `modules` map above already AND-s this in, so it cannot + // distinguish operator-off from user-off. The Modules hub needs that + // distinction to render an operator-disabled module as a read-only + // "disabled server-wide" row instead of a live toggle that no-ops. + // Server-WIDE, so it is the same map for every record and is not + // re-scoped here. + getOperatorModuleAvailability(), + // v1.39 (C1) — the needs-based setup state for the RECORD, like the two + // module fields above and for the same reason: it says what that record + // tracks, and its only consumer reads it beside that record's reading + // count, medication count and integration state. Ordering a record's rows + // by somebody else's answers is the one thing this field must not do. + // + // The three WRITE routes stay actor-only (`requireAuth()` refuses outright + // under a switch) until a guardian's route into a managed record's setup + // arrives with the rest of that record's configuration (#939 / C2). A read + // that is wider than the write is the honest shape here: the storage is + // keyed by record already, and a delegate seeing the record's own setup + // state is what makes the dashboard they are looking at coherent. + loadOnboardingRecordRow(prisma, recordId), + ]); // The masking step, and the one place the record scoping is narrowed rather // than resolved. `active` is set for ANY live grant — a READ grant scoped to @@ -149,6 +185,25 @@ export const GET = apiHandler(async () => { // exactly as it was, and `moduleAccess` is the same answer with the reason // attached — the record's own switch, the grant's edge, or the operator's. // Every client that only wants "paint it or not" keeps reading the boolean. + // v1.39 (C1) — the published steps carry the Q6 resolution: a record whose + // account already holds both unit preferences reads `units` as `done`, so + // the flow never re-asks a value the account holds. + const recordOnboarding = toOnboardingStateDto( + onboardingRow, + readHeldUnitPreferences({ + glucoseUnit: record?.glucoseUnit ?? null, + unitPreference: record?.unitPreference ?? null, + }), + ); + // The answers name the domains the record tracks, which is exactly what the + // module map below is masked for. A SCOPED grant therefore gets the shape of + // the flow — how far it got, what it ended on — with the answers emptied; + // an unscoped grant, and the record's own session, get them whole. + const onboarding = + sections === null + ? recordOnboarding + : { ...recordOnboarding, needs: emptyOnboardingNeeds() }; + const { modules, moduleAccess } = buildModuleDisclosure( resolvedModules, moduleAvailability, @@ -186,6 +241,24 @@ export const GET = apiHandler(async () => { timezone: user.timezone, onboardingCompletedAt: user.onboardingCompletedAt, onboardingTourCompleted: user.onboardingTourCompleted, + // v1.39 (C1) — the needs-based setup flow, published additively: + // `steps` (the nine stable ids and their `pending | done | skipped` + // status), `needs` (the answers as given), `completedAt` (the flow's OWN + // completion, distinct from `onboardingCompletedAt` above, which keeps its + // meaning as the first-run redirect's gate) and `firstResult` (the one + // task the flow offered, and whether it produced its result). + // + // Record-scoped, like `modules` and `cycleTrackingEnabled`: it says what + // the record tracks, and the checklist reads it beside that record's + // counts. Under a scoped grant the answers are emptied for the reason the + // module map is masked. + // + // Always present, never null: a record that never entered the flow reads + // as empty answers with nine `pending` steps, so a client branches on the + // step statuses rather than on the field's existence. A client that meets + // a step id it does not know skips it — the list is the contract, not the + // client's own copy of it. + onboarding, // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null // when never acknowledged; the onboarding welcome gate reads this to // decide whether to require the acknowledgment before "Get started". diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 2deba909d..d5897cd1d 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -18,8 +18,14 @@ * decides which source's rows the cached targets / derived / analytics * payloads were built from. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "source-priority.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "source-priority.body.invalid_json", + }); } const parsed = sourcePrioritySchema.safeParse(body ?? {}); @@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.source-priority.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - throw new HttpError(422, "source-priority.body.invalid_shape"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "source-priority.body.invalid_shape", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "source-priority.body.invalid_shape" }, + ); } // v1.4.25 W10 reconcile (security M-3): capture the previous shape diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 67a4028d9..cdd7b03af 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -17,7 +17,12 @@ * limited per IP on top of the per-ticket cap. */ import { NextRequest } from "next/server"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -61,7 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaVerifySchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { mfaTicket, method, code, rememberDevice } = parsed.data; diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts index aeff3e827..36992dd24 100644 --- a/src/app/api/auth/mfa/webauthn/verify/options/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -13,7 +13,13 @@ */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -45,7 +51,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const challenge = await loadActiveChallenge(parsed.data.mfaTicket); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 3ad7c0b57..00df78104 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -16,7 +16,12 @@ */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -59,7 +64,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaWebauthnLoginVerifySchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data; diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts index 5055a2024..0d74291b3 100644 --- a/src/app/api/auth/native/token/route.ts +++ b/src/app/api/auth/native/token/route.ts @@ -20,7 +20,12 @@ * `requireFreshMfa` step-up. */ import { NextRequest } from "next/server"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -83,7 +88,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = nativeHandoffTokenSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { code, codeVerifier } = parsed.data; diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index d0bd4674e..bc28bed5e 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -23,7 +23,12 @@ * `requireFreshMfa` step-up, so an SSO login can never satisfy step-up. */ import { NextRequest } from "next/server"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -89,7 +94,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = oidcNativeTokenSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { code, codeVerifier } = parsed.data; diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts index 30392af0c..6ec2f67ac 100644 --- a/src/app/api/auth/step-up/options/route.ts +++ b/src/app/api/auth/step-up/options/route.ts @@ -18,7 +18,13 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = stepUpOptionsSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } if (parsed.data.method === "webauthn") { diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 7744cc320..2514d777a 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -33,8 +33,10 @@ import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -112,7 +114,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = stepUpMintSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const method: StepUpMethod = parsed.data.method; diff --git a/src/app/api/biomarkers/__tests__/error-contract.test.ts b/src/app/api/biomarkers/__tests__/error-contract.test.ts index 044cf9d62..dcbfe3362 100644 --- a/src/app/api/biomarkers/__tests__/error-contract.test.ts +++ b/src/app/api/biomarkers/__tests__/error-contract.test.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() }, diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index b0d502964..8de993f70 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -9,6 +9,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -95,7 +96,17 @@ async function postBiomarker(request: NextRequest) { // duplicate pre-check and the `@@unique([userId, name])` backstop both scope // to the same resolved id, so a delegate adding a marker the owner already // tracks gets the ordinary 409 rather than a second copy. - const { user } = await requireRecordAuth("write", "labs"); + const { user, actor } = await requireRecordAuth("write", "labs"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts index c13967317..2f02eb2b3 100644 --- a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { customMetric: { findFirst: vi.fn() }, diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 5fdd9ff4b..166304e3f 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -9,6 +9,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -105,6 +106,16 @@ async function postCustomMetricEntry( // tracked value adds it back in the same diff: the argument for admitting it // was never wrong, and the GET arm above stays delegable meanwhile. const { user } = await requireAuth(); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } const { id } = await params; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/cycle/__tests__/external-id-floor.test.ts b/src/app/api/cycle/__tests__/external-id-floor.test.ts index 3b28effc5..5e1428830 100644 --- a/src/app/api/cycle/__tests__/external-id-floor.test.ts +++ b/src/app/api/cycle/__tests__/external-id-floor.test.ts @@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), rateLimitHeaders: () => ({}), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), })); vi.mock("@/lib/idempotency", () => ({ withIdempotency: diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 2c69eb869..007a75247 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -18,7 +18,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleCalendarQuerySchema } from "@/lib/validations/cycle"; import { @@ -55,9 +60,14 @@ export const GET = apiHandler(async (request: NextRequest) => { to: url.searchParams.get("to") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid calendar query", 422, { - errorCode: "cycle.calendar.invalid", - }); + return apiValidationError( + "Invalid calendar query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "cycle.calendar.invalid", + }, + ); } const tz = user.timezone ?? DEFAULT_TIMEZONE; @@ -111,6 +121,12 @@ export const GET = apiHandler(async (request: NextRequest) => { // Apple Watch wrist/skin temperature feeds the temperature-trend // ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly // values; the engine derives the trailing-mean deviation itself. + // + // This is a measurements read on a route declared under `cycle`, and it + // needs no section predicate for one reason: no value from it reaches the + // response. It goes into `predictCycle` and what comes out the other side + // is a shifted date. The crosstab next door reads the same channel and IS + // fenced, because there the values are the answer. prisma.measurement.findMany({ where: { userId: user.id, diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 6e1c65fb3..0e4a90692 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -12,7 +12,11 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleHistoryQuerySchema } from "@/lib/validations/cycle"; import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; @@ -39,9 +43,14 @@ export const GET = apiHandler(async (request: NextRequest) => { limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid history query", 422, { - errorCode: "cycle.cycles.invalid", - }); + return apiValidationError( + "Invalid history query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "cycle.cycles.invalid", + }, + ); } const limit = parsed.data.limit ?? DEFAULT_LIMIT; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 28e91b0c4..cacf927fd 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -26,6 +26,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { @@ -94,11 +95,21 @@ async function postDayLog(request: NextRequest): Promise { // v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means // here; the gate below resolves `user.gender` against the RECORD, so the // module follows the record rather than the caller. - const { user } = await requireRecordAuth("manage", "cycle"); + const { user, actor } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index d23d5c1da..287406817 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -16,6 +16,7 @@ */ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -50,7 +51,7 @@ const WINDOW_DAYS = 365; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "cycle"); + const { user, grantId } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -68,6 +69,19 @@ export const GET = apiHandler(async () => { const today = moodDateKey(new Date(), tz); const from = addDays(today, -WINDOW_DAYS); + // The crosstab is a cycle surface that reads two other sections. The phase + // labels are cycle data; the outcome columns are not. `lutealAvg`, + // `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration, + // steps, weight, temperature, glucose and the mood score in display units, + // and a grant scoped to the cycle alone was never consent for any of them. + // So the two foreign channels are fenced at the query, not at the response: + // a section the caller is outside of is never read, so nothing downstream + // can leak it back — the rows simply do not exist, exactly as they do not + // for a record with no measurements. + const visible = await actingDomainVisibility(prisma, grantId); + const seesMeasurements = visible("measurements"); + const seesMind = visible("mind"); + const [ cycles, dayLogRows, @@ -116,25 +130,28 @@ export const GET = apiHandler(async () => { select: { measuredAt: true, value: true }, }), // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, - }), + // canonical-source deduped per day inside `metricDayMap`. Skipped whole + // for a caller outside `measurements`. + seesMeasurements + ? prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }) + : [], prisma.user.findUnique({ where: { id: user.id }, select: { sourcePriorityJson: true }, @@ -142,15 +159,18 @@ export const GET = apiHandler(async () => { // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a // Measurement row, so read it here and inject it into the crosstab as a // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). - prisma.moodEntry.findMany({ - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }), + // Skipped whole for a caller outside `mind`. + seesMind + ? prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }) + : [], ]); // The latest open cycle runs to the predicted next-period start so the @@ -239,6 +259,8 @@ export const GET = apiHandler(async () => { lagged_discovered: lagged.discovered.length, lagged_pairs_tested: lagged.pairsTested, symptom_patterns: symptomPatterns.length, + measurements_visible: seesMeasurements, + mind_visible: seesMind, }, }); diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index 798a4a7e3..ac4d56572 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -26,6 +26,7 @@ import { } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -67,7 +68,23 @@ export const GET = apiHandler(async () => { return apiSuccess({ symptoms }); }); -export const POST = apiHandler(async (request: NextRequest) => { +/** + * Wrapped in `withIdempotency`: a custom symptom is minted from the client's + * offline outbox under the same `Idempotency-Key`, and nothing else here would + * catch the replay. The row's key is minted fresh per request + * (`custom:`), and the label it would duplicate lives in + * `labelEncrypted` — AES-GCM with a per-write IV, so no unique index can be + * put on it. A replay after a lost success response therefore wrote a SECOND + * row carrying the same label, up to the fifty-row cap: a silent duplicate in + * the person's own symptom vocabulary rather than a conflict anything refused. + * The wrapper answers the first attempt's 201 instead, and the in-flight 409 it + * defines is the only 409 this route can produce. + */ +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postCustomSymptom), +); + +async function postCustomSymptom(request: NextRequest): Promise { // v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log // writes the level admits need in order to say anything. const { user, actor } = await requireRecordAuth("manage", "cycle"); @@ -151,4 +168,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }, 201, ); -}); +} diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index 4a001abad..b0559de97 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -24,7 +24,13 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -93,9 +99,14 @@ export const POST = apiHandler( if (jsonError) return jsonError; const parsed = documentChatRequestSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid document chat request", 422, { - errorCode: "documents.chat.invalid", - }); + return apiValidationError( + "Invalid document chat request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.chat.invalid", + }, + ); } const { conversationId, message, locale: bodyLocale } = parsed.data; @@ -231,9 +242,14 @@ export const GET = apiHandler( limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid chat history query", 422, { - errorCode: "documents.chat.invalid", - }); + return apiValidationError( + "Invalid chat history query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.chat.invalid", + }, + ); } // With a conversationId → that one thread's messages (must hold this document). diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 93de5f7e9..757c012a7 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -30,8 +30,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -236,9 +238,14 @@ async function handleTextExtract( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const dateKey = buildDateKey(); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index d64166fe1..9a3cd6756 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -26,8 +26,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -154,9 +156,14 @@ async function handleTextIndex( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const { tokenCount } = await upsertContentIndex({ diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 22fef5f43..ee87db643 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -13,8 +13,10 @@ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -143,9 +145,14 @@ export const PATCH = apiHandler( const parsed = documentUpdateSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid document update", 422, { - errorCode: "documents.inbound.invalidUpdate", - }); + return apiValidationError( + "Invalid document update", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidUpdate", + }, + ); } const existing = await prisma.inboundDocument.findFirst({ diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 94f435641..b7feb1f51 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -18,8 +18,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -148,9 +150,14 @@ async function handleTextSuggest( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const dateKey = buildDateKey(); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 55fbea7ec..186e842d7 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -25,8 +25,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -297,9 +299,14 @@ async function handleTextSummary( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } // `mode=text` over posted OCR text is a pure echo — the text IS the diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f4ebcbe0a..15e79f51b 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -33,7 +33,13 @@ import { requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; @@ -236,9 +242,14 @@ async function processUpload( encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, }); if (!parsed.success) { - return apiError("Invalid document metadata", 422, { - errorCode: "documents.inbound.invalidMetadata", - }); + return apiValidationError( + "Invalid document metadata", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidMetadata", + }, + ); } let buffer: Buffer; @@ -518,9 +529,14 @@ export const GET = apiHandler(async (request: Request) => { kind: kinds.length > 0 ? kinds : undefined, }); if (!parsed.success) { - return apiError("Invalid list query", 422, { - errorCode: "documents.inbound.invalidQuery", - }); + return apiValidationError( + "Invalid list query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidQuery", + }, + ); } const { q, diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index 076ec4547..c22af968e 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -27,10 +27,8 @@ import { } from "@/lib/api-response"; import { encounterLinkSchema } from "@/lib/validations/encounters"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadEncounterLinks, -} from "@/lib/encounters/service"; +import { loadEncounterLinks } from "@/lib/encounters/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index c37cd9500..73c5731fd 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, loadEncounterLinks, resolveOwnerNotificationContext, shouldHaveReminder, } from "@/lib/encounters/service"; import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index b33bc64d9..930e04f4f 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, closeCheckupForVisit, findOwnAppointmentReminderId, @@ -54,6 +53,7 @@ import { type RestoreSkipLog, } from "@/lib/export/restore-skips"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 0fc4a5fa7..bdc74237c 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -25,6 +25,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -35,7 +36,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, loadEncounterLinksForMany, closeCheckupForVisit, @@ -50,6 +50,7 @@ import { summarizeRestoreSkips, type RestoreSkipLog, } from "@/lib/export/restore-skips"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; export const GET = apiHandler(async (request: NextRequest) => { const { user, grantId } = await requireRecordAuth("read", "profile"); @@ -154,7 +155,17 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); async function postEncounter(request: NextRequest): Promise { - const { user, grantId } = await requireRecordAuth("write", "profile"); + const { user, actor, grantId } = await requireRecordAuth("write", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index f3ca60494..4f6ea8736 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -28,6 +28,7 @@ vi.mock("@/lib/db", () => ({ // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index f24aef437..d98b3ebf5 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -26,6 +26,7 @@ vi.mock("@/lib/db", () => ({ // `buildIntradayProfileBackupSection`. intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts index bb694f3ed..e8a5f6444 100644 --- a/src/app/api/fitbit/credentials/__tests__/route.test.ts +++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts @@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: async (req: NextRequest) => { diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index bef303a15..d313e401d 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(fitbitCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/api/google-health/credentials/route.ts b/src/app/api/google-health/credentials/route.ts index d7edaa328..f8f2a271d 100644 --- a/src/app/api/google-health/credentials/route.ts +++ b/src/app/api/google-health/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(googleHealthCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } // Field-by-field assignment — never spread the parsed object, so no future diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e81f51a54..02bad9389 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -3,10 +3,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { - apiSuccess, apiError, + apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -175,8 +177,9 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = importSchema.safeParse(body); if (!parsed.success) { - return apiError( + return apiValidationError( parsed.error.issues[0]?.message ?? "Invalid import payload", + sanitiseZodIssues(parsed.error.issues), 422, ); } diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts index 2b1dcd488..a6c66fb75 100644 --- a/src/app/api/insights/chat/[id]/attachments/route.ts +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -10,7 +10,13 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -65,9 +71,14 @@ export const POST = apiHandler( if (jsonError) return jsonError; const parsed = coachAttachmentCreateSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid attachment request", 422, { - errorCode: "coach.fenced.invalid", - }); + return apiValidationError( + "Invalid attachment request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "coach.fenced.invalid", + }, + ); } const { documentId } = parsed.data; diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 338c938c1..1c7da325a 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -15,7 +15,12 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -57,9 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = fencedChatRequestSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid fenced chat request", 422, { - errorCode: "coach.fenced.invalid", - }); + return apiValidationError( + "Invalid fenced chat request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "coach.fenced.invalid", + }, + ); } const { conversationId, diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 973f9a264..4056d7eb9 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -34,8 +34,10 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -91,7 +93,17 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { action: { name: "insights.coach.message.feedback" }, meta: { outcome: "validation_failed" }, }); - throw new HttpError(422, "feedback.body.invalid"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "feedback.body.invalid", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "feedback.body.invalid" }, + ); } const body = parsed.data; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index a5c0e3191..9dc19d79d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -27,10 +27,15 @@ * 7. Persist user message + assistant message (encrypted) and bump * the day's CoachUsage token ledger. */ -import { NextResponse, type NextRequest } from "next/server"; +import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { redactOptional, redactSecrets } from "@/lib/logging/redact"; import { auditLog } from "@/lib/auth/audit"; @@ -209,9 +214,16 @@ async function handleChatRequest(request: NextRequest): Promise { action: { name: "insights.coach.invalid" }, meta: { issues: parsed.error.issues.length }, }); - return NextResponse.json( - { data: null, error: "coach.request.invalid" }, - { status: 422 }, + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "coach.request.invalid", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "coach.request.invalid" }, ); } const { diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index a8b30b5e7..6d1e22b36 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -66,7 +68,11 @@ async function handlePost(request: NextRequest) { action: { name: "insights.recommendation.feedback" }, meta: { outcome: "validation_failed" }, }); - return apiError("Invalid feedback payload", 422); + return apiValidationError( + "Invalid feedback payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const body = parsed.data; diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 82216c462..b5afc4379 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -2,7 +2,12 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { resolveProviderChain } from "@/lib/ai/provider"; import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; @@ -111,7 +116,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = chainBodySchema.safeParse(body); if (!parsed.success) { - throw new HttpError(422, "Invalid provider chain payload"); + return apiValidationError( + "Invalid provider chain payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // Defence-in-depth: reject duplicate provider types so a stale-tab diff --git a/src/app/api/labs/__tests__/error-contract.test.ts b/src/app/api/labs/__tests__/error-contract.test.ts index 302325c71..fb2d8c4a6 100644 --- a/src/app/api/labs/__tests__/error-contract.test.ts +++ b/src/app/api/labs/__tests__/error-contract.test.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { labResult: { diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index d6219657e..125af81dd 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -28,7 +28,13 @@ import { Buffer } from "node:buffer"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { @@ -154,9 +160,14 @@ async function handleTextExtract( const parsed = ocrTextExtractSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid OCR text payload", 422, { - errorCode: "labs.ocr.extractFailed", - }); + return apiValidationError( + "Invalid OCR text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "labs.ocr.extractFailed", + }, + ); } // Budget — text mode is a plain text→JSON structuring pass, far cheaper than diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 97fc344ce..d4fbf47d9 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -10,6 +10,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -148,7 +149,17 @@ async function postLabResult(request: NextRequest) { // it mints it under `user.id`: the marker joins the record's catalogue, not // the caller's, which is the only reading of "add a result to your record" // that leaves the owner with a usable catalogue afterwards. - const { user } = await requireRecordAuth("write", "labs"); + const { user, actor } = await requireRecordAuth("write", "labs"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/measurements/__tests__/arrival-wiring.test.ts b/src/app/api/measurements/__tests__/arrival-wiring.test.ts index c8f39732e..ee77a756a 100644 --- a/src/app/api/measurements/__tests__/arrival-wiring.test.ts +++ b/src/app/api/measurements/__tests__/arrival-wiring.test.ts @@ -12,6 +12,17 @@ const SELF = { timezone: "Europe/Berlin", }; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })), diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts index 10cca436b..34c4b295e 100644 --- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -9,6 +9,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { measurement: { diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 52672d2ae..776e870b7 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -45,8 +45,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { @@ -274,7 +276,11 @@ async function postBatch(request: NextRequest): Promise { const parsed = batchPayloadSchema.safeParse(rawBody); if (!parsed.success) { - return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid batch", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // A scoped credential may attribute MANUAL and nothing else. Refused loudly diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 8ede3dcb1..387db7ed4 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -38,8 +38,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; @@ -83,7 +85,11 @@ async function deleteByExternalIds(request: NextRequest): Promise { const parsed = payloadSchema.safeParse(rawBody); if (!parsed.success) { - return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid batch", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { externalIds } = parsed.data; diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6dcfe5456..ca5e59685 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -16,6 +16,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMeasurementSchema, createBatchMeasurementSchema, @@ -801,6 +802,16 @@ async function postMeasurement(request: NextRequest) { const { user } = auth; const scoped = isScopedCredential(auth); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(auth.actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 180badac9..46a18ac2e 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -28,6 +28,7 @@ import { import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { glp1PostBodySchema } from "@/lib/validations/medication"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { withIdempotency } from "@/lib/idempotency"; import { estimateDailyDoseCount, estimateRunwayDays, @@ -236,103 +237,71 @@ export const GET = apiHandler( * Rate-limit: 30/min/user (same as sibling POST routes). * Audit: `medication.glp1.update` for every successful mutation. */ +/** + * Wrapped in `withIdempotency`: a titration step is replayed out of the + * client's offline outbox under the same `Idempotency-Key`, and a replay after + * a lost success response would otherwise write a second dose change or a + * second supply delta. Neither row carries a natural key, so the wrapper is the + * only thing that can collapse the retry. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.37.0 — MANAGE. A titration step is record data and the dose is what - // the server acts on; administering one is the case the level names. - const { user, actor } = await requireRecordAuth("manage", "medications"); - const { id } = await params; + withIdempotency<[NextRequest, RouteParams]>(postGlp1Update), +); - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; +async function postGlp1Update( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.37.0 — MANAGE. A titration step is record data and the dose is what + // the server acts on; administering one is the case the level names. + const { user, actor } = await requireRecordAuth("manage", "medications"); + const { id } = await params; - // Per-caller POST rate-limit — matches the 30/min sibling routes - // (inventory, side-effects). Generous for a hand-driven session, - // tight enough to cut off the spam case. - // - // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own - // allowance and cannot collect a fresh one by switching records. - const rl = await checkRateLimit( - `medication-glp1:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, + // Per-caller POST rate-limit — matches the 30/min sibling routes + // (inventory, side-effects). Generous for a hand-driven session, + // tight enough to cut off the spam case. + // + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own + // allowance and cannot collect a fresh one by switching records. + const rl = await checkRateLimit( + `medication-glp1:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), }); - if (jsonError) return jsonError; + } - const parsed = glp1PostBodySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; - const ip = getClientIp(request); + const parsed = glp1PostBodySchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } - if (parsed.data.doseChange) { - const { effectiveFrom, doseValue, doseUnit, note } = - parsed.data.doseChange; - const created = await prisma.medicationDoseChange.create({ - data: { - medicationId: id, - effectiveFrom, - doseValue, - doseUnit, - // Encrypt the titration note at rest; the plaintext column stays null. - noteEncrypted: encryptNote(note), - note: null, - }, - }); + const ip = getClientIp(request); - await auditLog("medication.glp1.update", { - userId: user.id, - ipAddress: ip, - details: { - medicationId: id, - kind: "doseChange", - doseChangeId: created.id, - doseValue, - doseUnit, - }, - }); - - annotate({ - action: { - name: "medication.glp1.doseChange.create", - entity_type: "medication_dose_change", - entity_id: created.id, - }, - meta: { medication_id: id, doseValue, doseUnit }, - }); - - // A dose change shifts the daily-dose estimate the list payload's - // runway derivation uses. Hard-evict the medications + compliance - // buckets so the card reflects the new runway on the next read. - invalidateUserMedications(user.id, { evict: true }); - - return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); - } - - // The schema's XOR refinement guarantees `inventory` is present - // when `doseChange` is not, so the non-null assertion is safe. - // - // DEPRECATED branch (v1.16.10): this writes the legacy - // MedicationInventoryEvent running-sum ledger. The per-item - // endpoints (`POST /api/medications/[id]/inventory`, - // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; - // GET above and the Coach snapshot read the ledger only while the - // medication has zero inventory items. New callers must register - // containers instead of posting deltas. - const { delta, reason } = parsed.data.inventory!; - const created = await prisma.medicationInventoryEvent.create({ - data: { medicationId: id, delta, reason }, + if (parsed.data.doseChange) { + const { effectiveFrom, doseValue, doseUnit, note } = parsed.data.doseChange; + const created = await prisma.medicationDoseChange.create({ + data: { + medicationId: id, + effectiveFrom, + doseValue, + doseUnit, + // Encrypt the titration note at rest; the plaintext column stays null. + noteEncrypted: encryptNote(note), + note: null, + }, }); await auditLog("medication.glp1.update", { @@ -340,27 +309,70 @@ export const POST = apiHandler( ipAddress: ip, details: { medicationId: id, - kind: "inventory", - inventoryEventId: created.id, - delta, - reason, + kind: "doseChange", + doseChangeId: created.id, + doseValue, + doseUnit, }, }); annotate({ action: { - name: "medication.glp1.inventory.create", - entity_type: "medication_inventory_event", + name: "medication.glp1.doseChange.create", + entity_type: "medication_dose_change", entity_id: created.id, }, - meta: { medication_id: id, delta }, + meta: { medication_id: id, doseValue, doseUnit }, }); - // A legacy ledger delta changes the stock the list payload reports - // for a medication with no per-item containers. Hard-evict so the - // card reflects it on the next read. + // A dose change shifts the daily-dose estimate the list payload's + // runway derivation uses. Hard-evict the medications + compliance + // buckets so the card reflects the new runway on the next read. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess({ inventory: created }, 201); - }, -); + return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); + } + + // The schema's XOR refinement guarantees `inventory` is present + // when `doseChange` is not, so the non-null assertion is safe. + // + // DEPRECATED branch (v1.16.10): this writes the legacy + // MedicationInventoryEvent running-sum ledger. The per-item + // endpoints (`POST /api/medications/[id]/inventory`, + // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; + // GET above and the Coach snapshot read the ledger only while the + // medication has zero inventory items. New callers must register + // containers instead of posting deltas. + const { delta, reason } = parsed.data.inventory!; + const created = await prisma.medicationInventoryEvent.create({ + data: { medicationId: id, delta, reason }, + }); + + await auditLog("medication.glp1.update", { + userId: user.id, + ipAddress: ip, + details: { + medicationId: id, + kind: "inventory", + inventoryEventId: created.id, + delta, + reason, + }, + }); + + annotate({ + action: { + name: "medication.glp1.inventory.create", + entity_type: "medication_inventory_event", + entity_id: created.id, + }, + meta: { medication_id: id, delta }, + }); + + // A legacy ledger delta changes the stock the list payload reports + // for a medication with no per-item containers. Hard-evict so the + // card reflects it on the next read. + invalidateUserMedications(user.id, { evict: true }); + + return apiSuccess({ inventory: created }, 201); +} diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 60569dcb6..5e068be58 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -22,6 +22,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 2d1b52967..b7a9a4519 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -18,6 +18,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 635ee69f1..b61961363 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -10,6 +10,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { intakeSchema, listIntakeEventsSchema, @@ -58,6 +59,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { "medications", ); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + // v1.36.1 follow-up — the sibling route `POST /api/medications/intake` // refuses a delegate changing a dose the owner already recorded. This route // reaches the same rows through the slot upsert, so without the same rule it diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 1b63f0ed8..cc1bf78d9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medicationIntakeEvent: { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index fd0b5c2ca..193966b4e 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -21,6 +21,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -203,6 +204,16 @@ export const POST = apiHandler(async (request: NextRequest) => { // notification at the end of this handler. const { user, actor } = await requireRecordAuth("write", "medications"); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 4229ecfd3..74c81f181 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise { // into the record: the ordinary way this instrument is used when the person // it is about cannot run their own record. The row records what the person // in the room reported; the audit row says who typed it. + // + // It writes ONE row outside its declared section, and the crossing is + // deliberate rather than an oversight: the `*_SCORE` Measurement below is a + // projection of this administration, minted `COMPUTED`, keyed on + // `assessment:`, and it exists only so the trend reads through the chart + // and rollup tier without ever touching the encrypted item content. Fencing + // it on `measurements` the way the booster mint is fenced would file a + // screener whose own total is missing from the record — an inconsistency + // inside the section the grant DOES open, to avoid a derived point in one it + // does not. The consent copy for this section says so, which is where a + // widening belongs. const { user, actor } = await requireRecordAuth("manage", "mind"); // Opt-in module (default OFF) — same gate as GET. diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index d84d38d87..0d444cc59 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -1,6 +1,12 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { getGlitchtipSettings } from "@/lib/monitoring-settings"; import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; @@ -42,7 +48,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = clientErrorSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid error data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid error data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const delivery = await sendGlitchtipEvent({ dsn: settings.glitchtipDsn, diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 383671464..3af7aca11 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -31,6 +31,17 @@ const txClient = { }, }; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index c4faecd3b..96fb20d0a 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -8,6 +8,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -105,6 +106,16 @@ async function postMoodEntry(request: NextRequest) { // the `externalId` refusal below. const { user, actor } = await requireRecordAuth("manage", "mind"); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index f1cde5a7f..bb7b545a3 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -42,6 +42,28 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: ( error: string, diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 57a221e2d..77bc5f383 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -3,7 +3,13 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -53,7 +59,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = z.safeParse(nightscoutConnectSchema, body); if (!result.success) { - return apiError("A valid Nightscout URL is required", 422); + return apiValidationError( + "A valid Nightscout URL is required", + sanitiseZodIssues(result.error.issues), + 422, + ); } const { url, token } = result.data; diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 9b98b2ca4..0fbe2fa4d 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -2,7 +2,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationPreferenceSchema } from "@/lib/validations/notifications"; import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; import type { ChannelType } from "@/lib/notifications/types"; @@ -106,7 +111,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = notificationPreferenceSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { channelId, eventType, enabled } = parsed.data; diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 54fb387cc..5adf11a2a 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -2,7 +2,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types"; import type { ChannelType, EventType } from "@/lib/notifications/types"; import { reEnableChannel } from "@/lib/notifications/channel-state"; @@ -156,7 +161,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = reEnableChannelSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const channel = await prisma.notificationChannel.findFirst({ where: { id: parsed.data.channelId, userId: user.id }, diff --git a/src/app/api/onboarding/answers/route.ts b/src/app/api/onboarding/answers/route.ts new file mode 100644 index 000000000..d8cc35898 --- /dev/null +++ b/src/app/api/onboarding/answers/route.ts @@ -0,0 +1,186 @@ +/** + * v1.39 (C1) — `PATCH /api/onboarding/answers`. + * + * One step at a time, saved the moment it is answered, so leaving the flow and + * coming back resumes where it stopped (design spec §Principles 4). The body + * names the step and either its answer or a deliberate skip; the union in + * `src/lib/validations/onboarding-needs.ts` is what makes "skipped, and here is + * the answer anyway" unrepresentable. + * + * Idempotent: the state is computed by a pure function from the stored row and + * the body, so sending the same step twice writes the same value twice and the + * second write changes nothing a reader can see. The one instant that could + * drift — the first-result completion stamp — is kept from the existing row + * rather than re-taken. + * + * `requireAuth()` rather than `requireRecordAuth`: this route writes the + * CALLER's own record and refuses outright while the session is acting on + * somebody else's (403 `sharing.not_permitted`), at every grant level. The + * storage is keyed by record so a guardian running a managed profile's setup + * has a row to write, but the route into it belongs with the rest of the + * managed-record configuration (#939 / C2), not here — a delegable declaration + * is a reviewed entry in the sharing surface guard, not a default. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { applyOnboardingAnswer } from "@/lib/onboarding/needs-apply"; +import { readHeldUnitPreferences } from "@/lib/onboarding/needs"; +import { + ONBOARDING_RECORD_SELECT, + readOnboardingRecordState, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs"; + +export const dynamic = "force-dynamic"; + +const RATE_LIMIT = 120; +const RATE_WINDOW_MS = 10 * 60 * 1000; + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "onboarding.needs.save" } }); + + const rl = await checkRateLimit( + `onboarding-answers:${user.id}`, + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ + action: { name: "onboarding.needs.save" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many onboarding writes, try again later", 429, { + errorCode: "onboarding.answers.rateLimited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = onboardingAnswerSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "onboarding.needs.save" }, + meta: { outcome: "validation_failed" }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "onboarding.answers.invalid", + }); + } + const answer = parsed.data; + + const existing = await prisma.onboardingRecord.findUnique({ + where: { userId: user.id }, + select: ONBOARDING_RECORD_SELECT, + }); + const next = applyOnboardingAnswer( + readOnboardingRecordState(existing), + answer, + new Date(), + ); + + // The unit answers are the one step whose consequence lives outside this + // table: `User.glucoseUnit` and `User.unitPreference` are what every display + // surface already reads, so the flow writes them rather than inventing a + // second source of truth. What is kept beside them in `needs.units` is the + // answer as given, so a restart can show it back without re-deriving it from + // a column Settings may have changed since. + const unitData: { glucoseUnit?: string; unitPreference?: string } = {}; + if (answer.step === "units" && answer.status !== "skipped") { + if (answer.units.glucoseUnit) { + unitData.glucoseUnit = answer.units.glucoseUnit; + } + if (answer.units.unitPreference) { + unitData.unitPreference = answer.units.unitPreference; + } + } + + const upsert = prisma.onboardingRecord.upsert({ + where: { userId: user.id }, + // Field-by-field, never a spread of the parsed body: the three columns are + // the pure function's output, and nothing the client sent reaches Prisma + // without passing through it. + create: { + userId: user.id, + needsJson: toJson(next.needs), + stepsJson: toJson(next.steps), + firstResultJson: next.firstResult ? toJson(next.firstResult) : undefined, + }, + update: { + needsJson: toJson(next.needs), + stepsJson: toJson(next.steps), + ...(next.firstResult + ? { firstResultJson: toJson(next.firstResult) } + : {}), + }, + select: ONBOARDING_RECORD_SELECT, + }); + + // One transaction, because the two writes are one answer: a failure between + // them would leave the display columns changed with no record of the answer + // that changed them. + const [written] = + Object.keys(unitData).length > 0 + ? await prisma.$transaction([ + upsert, + prisma.user.update({ where: { id: user.id }, data: unitData }), + ]) + : await prisma.$transaction([upsert]); + + const held = readHeldUnitPreferences({ + glucoseUnit: unitData.glucoseUnit ?? user.glucoseUnit, + unitPreference: unitData.unitPreference ?? user.unitPreference, + }); + + // Both columns have a dedicated settings route that writes an audit row, and + // the trail should not depend on which surface the person changed them from. + // Same event names, so the activity panel groups them. + if (unitData.glucoseUnit !== undefined) { + await auditLog("user.glucose-unit.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + previous: user.glucoseUnit, + next: unitData.glucoseUnit, + source: "onboarding", + }, + }); + } + if (unitData.unitPreference !== undefined) { + await auditLog("user.unit-preference.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + previous: user.unitPreference, + next: unitData.unitPreference, + source: "onboarding", + }, + }); + } + + annotate({ + action: { name: "onboarding.needs.save" }, + meta: { + step: answer.step, + status: answer.status === "skipped" ? "skipped" : "done", + }, + }); + + return apiSuccess({ onboarding: toOnboardingStateDto(written, held) }); +}); diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts index 667d16d7e..ccd333d57 100644 --- a/src/app/api/onboarding/complete/__tests__/route.test.ts +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -1,12 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - user: { update: vi.fn() }, - auditLog: { create: vi.fn() }, - }, -})); +vi.mock("@/lib/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + prisma: { + user: { update: vi.fn(), findUnique: vi.fn() }, + auditLog: { create: vi.fn() }, + // v1.39 (C1) — the needs half of the route looks for a setup row before + // it does anything. These cases are the LEGACY caller, which has none, + // so the lookup answers null and the route falls straight through to the + // acknowledgement it always returned. + onboardingRecord: { findUnique: vi.fn(), update: vi.fn() }, + }, + }; +}); vi.mock("@/lib/auth/session", async (importOriginal) => { const actual = await importOriginal(); @@ -58,6 +67,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.onboardingRecord.findUnique).mockResolvedValue(null); }); describe("POST /api/onboarding/complete", () => { diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 4bfe35382..1a6d0e699 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,8 +1,34 @@ -import { prisma } from "@/lib/db"; +import { prisma, toJson } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + getClientIp, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; +import { normalisePrefs } from "@/lib/modules/gate"; +import { modulesHoldingRecordData } from "@/lib/modules/domain-data"; +import { + deriveOnboardingModuleDefaults, + mergeDerivedModulePreferences, + OWNED_MODULE_KEYS, +} from "@/lib/modules/registry"; +import { + everyOnboardingQuestionSettled, + readHeldUnitPreferences, + resolveOnboardingSteps, + type HeldUnitPreferences, +} from "@/lib/onboarding/needs"; +import { + ONBOARDING_RECORD_SELECT, + readOnboardingRecordState, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; +import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; @@ -10,6 +36,23 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; /** * Complete the onboarding flow. Saves optional profile data and marks * onboarding as completed. + * + * v1.39 (C1) — this is also the confirm screen's endpoint for the needs-based + * flow, and the two live side by side rather than one replacing the other. + * The legacy half is unchanged: the optional profile fields, the completion + * stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The + * needs half runs only for a record that actually FINISHED the questions — + * every one of them answered or deliberately passed — and is what turns them + * into a module + * map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a + * re-derivation would re-apply the questionnaire over decisions taken in + * Settings since. `POST /api/onboarding/restart` clears that marker when the + * person asks for the questions again. + * + * The goal slugs of today (`src/lib/onboarding/goals.ts`, the four-step wizard + * at `POST /api/onboarding/step`) are untouched and stay live until C2 + * replaces that surface. They decide TILE ORDER; the answers below decide + * WHICH MODULES ARE ON. Neither reads the other. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -22,9 +65,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const result = z.safeParse(onboardingCompleteSchema, body); if (!result.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.complete.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(result.error.issues), + 422, + { + errorCode: "onboarding.complete.invalid", + }, + ); } const data: Record = { @@ -59,5 +107,157 @@ export const POST = apiHandler(async (request: NextRequest) => { // next navigation drops the /onboarding redirect immediately. await setOnboardingPendingCookie(false); - return apiSuccess({ completed: true }); + const onboarding = await completeNeedsFlow( + user.id, + readHeldUnitPreferences(user), + request, + ); + + return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) }); }); + +/** + * The needs half: derive the module map once, mark the confirm step, stamp the + * flow's own completion. + * + * Returns the published state, or `null` for a caller that never entered the + * needs flow — a legacy wizard completion then answers exactly the body it + * always did. + */ +async function completeNeedsFlow( + userId: string, + held: HeldUnitPreferences, + request: NextRequest, +) { + const record = await prisma.onboardingRecord.findUnique({ + where: { userId }, + select: ONBOARDING_RECORD_SELECT, + }); + if (!record) return null; + + const state = readOnboardingRecordState(record); + // The units question answers itself for an account that already holds both + // preferences, so the gate and the published state read the same resolved + // ledger rather than the raw one. + const steps = resolveOnboardingSteps(state.steps, held); + if (state.needs.recordTarget === null) { + // The row exists but Q1 was never answered, so there is nothing to derive + // from. Stamping a completion here would claim a setup that did not happen. + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "no_answers" }, + }); + return toOnboardingStateDto(record, held); + } + if (!everyOnboardingQuestionSettled(steps)) { + // The confirm screen is the only caller that may derive, and it is reached + // only once every question has an answer or a deliberate pass. Q1 alone is + // not enough: an abandoned flow would have every remaining question read + // as its conservative default, switch off the surfaces that default + // implies, and latch the result so nobody could re-derive it. This route + // is also the LEGACY wizard's endpoint, and that wizard knows nothing + // about these questions — so a person who answered Q1, left, and later + // finished the old wizard must not have a module map derived for them. + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "incomplete" }, + }); + return toOnboardingStateDto(record, held); + } + + let derived = false; + let keptForData: string[] = []; + if (record.modulesDerivedAt === null) { + const defaults = deriveOnboardingModuleDefaults({ + recordTarget: state.needs.recordTarget, + areas: state.needs.areas, + // An unanswered (skipped) question is the conservative answer: no + // medication surface, no doctor report. Both stay one click away in + // Settings, which is the whole ordering-versus-removal decision. + medication: state.needs.medication ?? "no", + visit: state.needs.visit ?? "no", + }); + + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { modulePreferencesJson: true }, + }); + // Only the modules the answers would switch OFF are worth asking about, + // and a record that already holds rows in one of those domains keeps its + // surface: the flow orders a record, and it cannot order away content + // somebody already has. + const wouldSwitchOff = OWNED_MODULE_KEYS.filter( + (key) => defaults.preferences[key] === false, + ); + const holdsData = await modulesHoldingRecordData( + prisma, + userId, + wouldSwitchOff, + ); + const merged = mergeDerivedModulePreferences( + normalisePrefs(row?.modulePreferencesJson), + defaults.preferences, + holdsData, + ); + // The same record-keyed write the guardian's modules route uses, so the + // two surfaces that decide a record's modules cannot drift apart on how + // the decision is stored or what it invalidates. `cycle` delegates to + // `CycleProfile.cycleTrackingEnabled`, so the module blob never owns it, + // and it is only ever switched ON from here: the gate derives an + // unanswered cycle from recorded sex, and an untick is not a request to + // retract that. + await writeRecordModulePreferences({ + recordId: userId, + modulePreferences: merged, + ...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}), + }); + + // The same trail the dedicated modules route writes, under the same event + // name, because it is the same column and the activity panel is where + // somebody goes to ask why their modules changed. The flow was writing + // three audited settings columns and leaving no trail at all, which made + // the answer to that question "nothing did it". + const stored = normalisePrefs(row?.modulePreferencesJson); + const changed = [ + ...Object.keys(merged).filter((key) => merged[key] !== stored[key]), + ...(defaults.cycleTracking ? ["cycleTrackingEnabled"] : []), + ]; + await auditLog("user.modules.update", { + userId, + ipAddress: getClientIp(request), + details: { changed, keptForData: [...holdsData], source: "onboarding" }, + }); + + keptForData = [...holdsData]; + derived = true; + } + + const now = new Date(); + const confirmed = steps.map((step) => + step.id === "confirm" && step.status === "pending" + ? { ...step, status: "done" as const } + : step, + ); + const written = await prisma.onboardingRecord.update({ + where: { userId }, + data: { + stepsJson: toJson(confirmed), + // Stamped once. The derivation is latched and the stamp should be too, + // or a replay of the confirm quietly moves the instant the setup + // finished at. + completedAt: record.completedAt ?? now, + ...(derived ? { modulesDerivedAt: now } : {}), + }, + select: ONBOARDING_RECORD_SELECT, + }); + + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { + outcome: derived ? "derived" : "already_derived", + keptForData: keptForData.length, + }, + }); + + return toOnboardingStateDto(written, held); +} diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts index e9cf2dd5a..acb33fff5 100644 --- a/src/app/api/onboarding/disclaimer/route.ts +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -3,7 +3,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; /** @@ -38,9 +43,14 @@ export const POST = apiHandler(async (request) => { const parsed = z.safeParse(bodySchema, rawBody); if (!parsed.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.disclaimer.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.disclaimer.invalid", + }, + ); } // The server pins the canonical version it stamps — the body version is diff --git a/src/app/api/onboarding/restart/route.ts b/src/app/api/onboarding/restart/route.ts new file mode 100644 index 000000000..a64a23914 --- /dev/null +++ b/src/app/api/onboarding/restart/route.ts @@ -0,0 +1,114 @@ +/** + * v1.39 (C1) — `POST /api/onboarding/restart`. + * + * "Set up again", from Settings. It puts the nine steps back to `pending`, + * clears the flow's completion stamp and clears the derivation marker so the + * next confirm may derive a module map again. + * + * What it deliberately does NOT do: + * + * - touch a single module. The design spec's ordering-versus-removal + * decision says a restart never turns off a module the person turned on by + * hand, and the safest way to hold that is for this route to write no + * module state at all. The protection against the SECOND confirm is + * elsewhere, in `mergeDerivedModulePreferences`. + * - clear the answers. They come back as the prefill for the re-run, which + * is what "never re-ask a value the account holds" means here. + * - clear `User.onboardingCompletedAt`. That column gates the first-run + * redirect in `src/proxy.ts`; clearing it would drop the person back into + * the wizard of today rather than into the questions they asked for. + * - forget a first result that really happened. The task produced a reading, + * a medication or a connection; a re-run of the questions does not unmake + * it. + * + * `requireAuth()` for the reason the answers route gives: the caller's own + * record, refused outright under an acting-account switch. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + defaultOnboardingSteps, + readHeldUnitPreferences, +} from "@/lib/onboarding/needs"; +import { + ONBOARDING_RECORD_SELECT, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { onboardingRestartSchema } from "@/lib/validations/onboarding-needs"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "onboarding.needs.restart" } }); + + const rl = await checkRateLimit( + `onboarding-restart:${user.id}`, + 10, + 10 * 60 * 1000, + ); + if (!rl.allowed) { + annotate({ + action: { name: "onboarding.needs.restart" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many onboarding writes, try again later", 429, { + errorCode: "onboarding.restart.rateLimited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = onboardingRestartSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "onboarding.needs.restart" }, + meta: { outcome: "validation_failed" }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "onboarding.restart.invalid", + }); + } + + const steps = defaultOnboardingSteps(); + const written = await prisma.onboardingRecord.upsert({ + where: { userId: user.id }, + create: { userId: user.id, stepsJson: toJson(steps) }, + update: { + stepsJson: toJson(steps), + completedAt: null, + modulesDerivedAt: null, + }, + select: ONBOARDING_RECORD_SELECT, + }); + + await auditLog("onboarding.restart", { + userId: user.id, + ipAddress: getClientIp(request), + details: { steps: steps.length }, + }); + + annotate({ + action: { name: "onboarding.needs.restart" }, + meta: { outcome: "reset" }, + }); + + return apiSuccess({ + onboarding: toOnboardingStateDto(written, readHeldUnitPreferences(user)), + }); +}); diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 177e42831..c21dc2cc8 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; @@ -80,9 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "onboarding.step" }, meta: { outcome: "validation_failed" }, }); - return apiError("Invalid step payload", 422, { - errorCode: "onboarding.step.invalid", - }); + return apiValidationError( + "Invalid step payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.step.invalid", + }, + ); } const { step, goals } = parsed.data; diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 08e9df6a2..b68d24e7b 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -4,7 +4,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; import { Prisma } from "@/generated/prisma/client"; @@ -56,9 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = z.safeParse(tourBodySchema, body); if (!parsed.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.tour.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.tour.invalid", + }, + ); } // Build the update field-by-field (no mass assignment). `completed` diff --git a/src/app/api/oura/credentials/__tests__/route.test.ts b/src/app/api/oura/credentials/__tests__/route.test.ts index 800be41a7..d59052f89 100644 --- a/src/app/api/oura/credentials/__tests__/route.test.ts +++ b/src/app/api/oura/credentials/__tests__/route.test.ts @@ -23,6 +23,28 @@ vi.mock("@/lib/oura/credentials", () => ({ })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: vi.fn(), diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index 177718127..b5bb0a6e9 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { markDisconnected } from "@/lib/integrations/status"; @@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(ouraCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storeOuraClientCredentials( diff --git a/src/app/api/polar/credentials/__tests__/route.test.ts b/src/app/api/polar/credentials/__tests__/route.test.ts index 3b744bc78..2952491f7 100644 --- a/src/app/api/polar/credentials/__tests__/route.test.ts +++ b/src/app/api/polar/credentials/__tests__/route.test.ts @@ -26,6 +26,28 @@ vi.mock("@/lib/polar/credentials", () => ({ clearPolarClientCredentials: clearMock, })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: vi.fn(async (req: { _body: unknown }) => ({ diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts index 7dbab091b..6d23712f0 100644 --- a/src/app/api/polar/credentials/route.ts +++ b/src/app/api/polar/credentials/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { markDisconnected } from "@/lib/integrations/status"; import { @@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(polarCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storePolarClientCredentials( diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 089715897..5b91fa3de 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -14,7 +14,6 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { - invalidateUserHealthScore, invalidateUserInsightsLayout, invalidateUserMedications, invalidateUserProfile, @@ -24,6 +23,7 @@ import { getAllEffectiveRanges, type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; +import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { isManagedRecordSettingsFamily, managedModulePreferencesFrom, @@ -31,7 +31,6 @@ import { resolveGuardianRecordSettingsAccess, } from "@/lib/record-settings"; import { isCycleEnabled } from "@/lib/cycle/gate"; -import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { resolveInsightsLayout, serializeInsightsLayout, @@ -251,34 +250,25 @@ export const PATCH = apiHandler( ...managedModulePreferencesFrom(current?.modulePreferencesJson), ...patch.modulePreferences, }; - if (patch.modulePreferences !== undefined) { - await prisma.user.update({ - where: { id: access.recordId }, - data: { modulePreferencesJson: toJson(modulePreferences) }, - }); - } - // The delegated key writes its own column. An explicit boolean is + // One write path, shared with the setup flow's derivation. The + // delegated key writes its own column there: an explicit boolean is // what the cycle gate honours over the sex-derived default, so a // guardian turning Cycle off for a record whose sex says otherwise // is recorded as the deliberate answer it is. - if (patch.cycleTrackingEnabled !== undefined) { - await getOrCreateCycleProfile(access.recordId); - await prisma.cycleProfile.update({ - where: { userId: access.recordId }, - data: { cycleTrackingEnabled: patch.cycleTrackingEnabled }, - }); - } - const cycleProfile = await prisma.cycleProfile.findUnique({ - where: { userId: access.recordId }, - select: { cycleTrackingEnabled: true }, + const written = await writeRecordModulePreferences({ + recordId: access.recordId, + ...(patch.modulePreferences !== undefined + ? { modulePreferences } + : {}), + ...(patch.cycleTrackingEnabled !== undefined + ? { cycleTrackingEnabled: patch.cycleTrackingEnabled } + : {}), }); - invalidateUserHealthScore(access.recordId); settings = { modulePreferences, - cycleTrackingEnabled: isCycleEnabled( - current?.gender ?? null, - cycleProfile, - ), + cycleTrackingEnabled: isCycleEnabled(current?.gender ?? null, { + cycleTrackingEnabled: written.cycleTrackingEnabled, + }), }; // The module keys themselves, not the wrapper field: the record's // trail should say which module moved. `cycleTrackingEnabled` is diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts index bd2dc592d..c8a2afb2c 100644 --- a/src/app/api/settings/email/route.ts +++ b/src/app/api/settings/email/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { emailSettingsSchema, notificationChannelEnabledSchema, @@ -102,7 +108,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = emailSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { recipient, enabled } = parsed.data; diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index d0fff0ead..1fb7d455a 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationChannelEnabledSchema, ntfySettingsSchemaWith, @@ -135,6 +141,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = ntfySettingsSchema.safeParse(body); if (!parsed.success) { + // Every shape refusal carries the issue list; the private-origin case + // adds the code and a message naming the operator's lever (#947). + const issues = sanitiseZodIssues(parsed.error.issues); const candidate = (body as { serverUrl?: unknown } | null)?.serverUrl; if ( typeof candidate === "string" && @@ -145,11 +154,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { action: { name: "settings.ntfy.update" }, meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, }); - return apiError(PRIVATE_ORIGIN_REFUSAL, 422, { + return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, { errorCode: PRIVATE_ORIGIN_NOT_APPROVED, }); } - return apiError("Invalid data", 422); + return apiValidationError("Invalid data", issues, 422); } const { serverUrl, topic, authToken, enabled } = parsed.data; diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index c837ce010..545392e9a 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { decrypt, encrypt } from "@/lib/crypto"; import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram"; import { telegramSettingsSchema } from "@/lib/validations/telegram"; @@ -171,7 +177,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(telegramSettingsSchema, body); if (!result.success) { - return apiError("Invalid input", 422); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(result.error.issues), + 422, + ); } const { botToken, chatId, enabled } = result.data; diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index d8f1c2280..4d4fba24e 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationChannelEnabledSchema, webhookSettingsSchemaWith, @@ -129,6 +135,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = webhookSettingsSchema.safeParse(body); if (!parsed.success) { + // Every shape refusal carries the issue list; the private-origin case + // adds the code and a message naming the operator's lever (#947). + const issues = sanitiseZodIssues(parsed.error.issues); const candidate = (body as { url?: unknown } | null)?.url; if ( typeof candidate === "string" && @@ -139,11 +148,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { action: { name: "settings.webhook.update" }, meta: { refused: PRIVATE_ORIGIN_NOT_APPROVED }, }); - return apiError(PRIVATE_ORIGIN_REFUSAL, 422, { + return apiValidationError(PRIVATE_ORIGIN_REFUSAL, issues, 422, { errorCode: PRIVATE_ORIGIN_NOT_APPROVED, }); } - return apiError("Invalid data", 422); + return apiValidationError("Invalid data", issues, 422); } const { url, headerName, headerValue, enabled } = parsed.data; diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts index ac53d9c47..7605775ef 100644 --- a/src/app/api/strava/credentials/route.ts +++ b/src/app/api/strava/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { markDisconnected } from "@/lib/integrations/status"; @@ -52,7 +57,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(stravaCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storeStravaClientCredentials( diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 3688a6863..694929cc0 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -50,7 +50,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { readNote } from "@/lib/crypto/note-cipher"; @@ -211,7 +216,11 @@ export const GET = apiHandler(async (request: NextRequest) => { limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid sync query", 422); + return apiValidationError( + "Invalid sync query", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const limit = parsed.data.limit ?? DEFAULT_LIMIT; diff --git a/src/app/api/vaccinations/[id]/booster/route.ts b/src/app/api/vaccinations/[id]/booster/route.ts index da406db47..19a87d846 100644 --- a/src/app/api/vaccinations/[id]/booster/route.ts +++ b/src/app/api/vaccinations/[id]/booster/route.ts @@ -13,11 +13,21 @@ * even when a delegate is transcribing. The antigen is read from the dose's * catalogue entry server-side, never from the body — a client cannot key a * reminder onto an antigen the dose does not contain. + * + * The row it mints is a `MeasurementReminder`, and that model belongs to the + * `measurements` section: every direct route over it declares that domain, and + * creating one needs MANAGE there. So the act crosses a seam, and a grant + * scoped to the health background alone was never consent for the other side + * of it — it would produce a row on the owner's checkup list that the delegate + * can neither read nor change through the section that owns it. The seam is + * fenced the way the visits service fences a checkup closure: the mint runs + * only while the caller is inside `measurements`. */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("write", "profile"); + const { user, grantId } = await requireRecordAuth("write", "profile"); const { id } = await params; + // Refused rather than skipped, and that is the difference from the visit's + // checkup closure: there the closure is one effect of a larger save and + // saying "not done" keeps the save honest, while here the reminder IS the + // request. Reporting success over a row that was never written would tell + // the person their booster is planned when nothing rings. + const visible = await actingDomainVisibility(prisma, grantId); + if (!visible("measurements")) { + annotate({ meta: { sharing_refusal: "booster_out_of_scope" } }); + return apiError( + "Planning a booster needs access to this record's measurements", + 403, + { errorCode: "vaccination.booster-out-of-scope" }, + ); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 8 * 1024, }); diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index 7a9b10442..d8ba5cbed 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -26,10 +26,8 @@ import { } from "@/lib/api-response"; import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadVaccinationDocuments, -} from "@/lib/vaccinations/service"; +import { loadVaccinationDocuments } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts index 685e61657..0d5600b60 100644 --- a/src/app/api/vaccinations/[id]/restore/route.ts +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, loadVaccinationDocuments, resolveSeriesFor, } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 8a7c77bf2..1b85c023d 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, applyVaccinationLinks, loadVaccinationDocuments, resolveOwnedEncounter, @@ -40,6 +39,7 @@ import { resolveSeriesFor, } from "@/lib/vaccinations/service"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 16d2be8b7..b1ddc7ae5 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -29,6 +29,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -112,7 +113,17 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); async function postVaccination(request: NextRequest): Promise { - const { user } = await requireRecordAuth("write", "profile"); + const { user, actor } = await requireRecordAuth("write", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts index 6ca1e67ea..091c9835d 100644 --- a/src/app/api/whoop/credentials/__tests__/route.test.ts +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: async (req: NextRequest) => { diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index ffc7e9183..26448bbc9 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -45,7 +50,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(whoopCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 3829ed3d6..56e1f83e8 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; @@ -46,7 +51,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const result = z.safeParse(withingsCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx index ab7a29e57..a4ab8c9aa 100644 --- a/src/app/cycle/page.tsx +++ b/src/app/cycle/page.tsx @@ -4,7 +4,6 @@ import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { CycleView } from "@/components/cycle/cycle-view"; @@ -23,10 +22,9 @@ import { CycleView } from "@/components/cycle/cycle-view"; */ export default function CyclePage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); - const enabled = inSharedRecord || user?.modules?.cycle !== false; + const enabled = user?.modules?.cycle !== false; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx index c0275553a..98e4c174c 100644 --- a/src/app/illness/[id]/page.tsx +++ b/src/app/illness/[id]/page.tsx @@ -14,7 +14,6 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail"; import { BackLink } from "@/components/ui/back-link"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; @@ -25,12 +24,11 @@ export default function IllnessEpisodePage({ }) { const { id } = use(params); const { user, isAuthenticated, isLoading } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const mounted = useMounted(); const router = useRouter(); const { t } = useTranslations(); - const enabled = inSharedRecord || user?.modules?.illness === true; + const enabled = user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx index 9511acf3c..6fef680cc 100644 --- a/src/app/illness/page.tsx +++ b/src/app/illness/page.tsx @@ -4,7 +4,6 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessView } from "@/components/illness/illness-view"; @@ -21,11 +20,10 @@ import { IllnessView } from "@/components/illness/illness-view"; */ export default function IllnessPage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); // Born-gated: enabled ONLY when the resolved map says `true`. - const enabled = inSharedRecord || user?.modules?.illness === true; + const enabled = user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 768d7e5b5..65824629e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -44,7 +44,8 @@ export default function LabsPage() { // Scanning a report is not: it files a document and commits through the OCR // route, and neither is delegated. So a WRITE delegate keeps the manual add // and loses the choice menu around it. - const { canAdd, inSharedRecord } = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); @@ -62,7 +63,7 @@ export default function LabsPage() { // absent key reads as enabled, so a direct URL hit only bounces on an // explicit `false`. Every `/api/labs/*` route also enforces the gate // server-side, so this is a UX redirect, not the security boundary. - const enabled = inSharedRecord || user?.modules?.labs !== false; + const enabled = user?.modules?.labs !== false; const ocrCapability = useOcrCapability( shouldProbeOcrCapability({ isAuthenticated, @@ -152,7 +153,7 @@ export default function LabsPage() { - ) : canAdd ? ( + ) : canAddLab ? ( )} - {canAdd && ( + {canAddAnything && ( {/* v1.4.33 maintainer-item-7 — restore proportional sizing @@ -152,10 +158,12 @@ export function DashboardHeader({ phase-A3 fix #1 hardened this with a unit guard at `src/app/__tests__/quick-add-labels.test.ts` — both labels must differ from each other AND from `common.add`. */} - onQuickEntry("measurement")}> - + {canAddMeasurement && ( + onQuickEntry("measurement")}> + + )} {canAddMood && ( onQuickEntry("mood")}> )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 4b39eaef5..189958163 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -34,17 +34,7 @@ import type { ShareDomain } from "@/lib/sharing/scope"; export type QuickEntryDialog = "measurement" | "mood" | "medicationIntake" | null; -/** - * v1.36.x — which of the three the delegation admits, and the same rule the - * capture picker states at length. - * - * A WRITE grant covers entering a reading and marking a dose. A mood entry is - * not an admitted verb and the server refuses it under a switch. - */ -const DELEGABLE_QUICK_ENTRIES: ReadonlySet> = - new Set(["measurement", "medicationIntake"]); - -/** v1.38.12 — the section each sheet writes to; see the capture picker. */ +/** The section each sheet writes to; see the capture picker. */ const QUICK_ENTRY_DOMAIN: Readonly< Record, ShareDomain> > = { @@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly< */ export function admittedQuickEntry( open: QuickEntryDialog, - caps: Pick, + caps: Pick, ): QuickEntryDialog { if (open === null) return open; - if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open; - return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null; + return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null; } /** diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 6b3f5c60d..67e5ac4aa 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -251,8 +251,7 @@ export function DocumentsView() { const queryClient = useQueryClient(); const coachLaunch = useCoachLaunch(); - const moduleEnabled = - inSharedRecord || user?.modules?.inboundDocuments === true; + const moduleEnabled = user?.modules?.inboundDocuments === true; // UX redirect only — the API routes enforce the gate server-side. useEffect(() => { diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 26e2a8d0e..abac9c3fb 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -20,6 +20,16 @@ * saved and a plain sentence saying the checkup stayed open. That is reported * rather than skipped in silence: somebody who believes a checkup was closed * will not look at it again. + * + * Capability note: this sheet carries no capability check of its own, by + * decision. Save is a create on one mount and an edit on the other, and Delete + * exists only in edit mode — so the question belongs to whoever opens it. + * `VisitsSection` hands it a record only under `canManageDomain("profile")`, + * and the two mounts that can only create (`EpisodeVisitsCard`, + * `VorsorgeSection`) pass `encounter={null}` behind their own write check. A + * THIRD mount that hands this sheet a record without asking the manage + * question reintroduces an ungated Delete, and nothing here will fail. Ask at + * the caller, or move the check in here for everyone. */ import { useState } from "react"; import { Trash2 } from "lucide-react"; diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 7f4ecdf32..0baaf512e 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -35,7 +35,14 @@ export function VisitCard({ onOpen, }: { encounter: Encounter; - onOpen: (encounter: Encounter) => void; + /** + * Opens the edit sheet. Omitted when the caller may not change the visit — + * `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`, + * so a READ or WRITE delegate gets the card as a card and not as a button. + * A row that reads as tappable and answers 403 is the affordance saying + * something about the grant that is not true. + */ + onOpen?: (encounter: Encounter) => void; }) { const { t } = useTranslations(); const format = useFormatters(); @@ -71,11 +78,7 @@ export function VisitCard({ className="gap-2 py-3 md:py-4" > - + ); } + +/** + * The row's content, tappable or not. + * + * One element either way rather than two copies of the content: the read-only + * form keeps the same spacing the button had, so a card that loses its edit + * affordance does not also change shape. + */ +function Body({ + onOpen, + encounter, + children, +}: { + onOpen?: (encounter: Encounter) => void; + encounter: Encounter; + children: React.ReactNode; +}) { + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 2af5b67d0..3b2ad8c9a 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card"; export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); - const { canWriteDomain } = useRecordCapabilities(); - // `POST /api/encounters` answers at WRITE under the profile section. + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/encounters` answers at WRITE under the profile section; the + // edit and the delete behind the row are both MANAGE. Only the add was + // asked, so every row stayed tappable into a sheet whose Save and Delete + // the server refuses. const canAddVisit = canWriteDomain("profile"); + const canManageVisits = canManageDomain("profile"); const list = useEncounters(enabled); // `null` closed, `"new"` create, a row edits it. @@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} @@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 74855f074..509f9de17 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -320,7 +320,8 @@ export function IllnessView() { // v1.36.x — opening an illness entry is an admitted delegated write. Logging // a day against one, editing it, resolving it and deleting it are not, so a // delegate can start the record of an illness and nothing more. - const { canAdd, inSharedRecord } = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddEpisode = canWriteDomain("illness"); const { data: episodes, isLoading, @@ -412,7 +413,7 @@ export function IllnessView() { )} {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every other module, not the bespoke "neue Episode". */} - {canAdd && ( + {canAddEpisode && ( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 2f077bee4..e11c4ed98 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); - const { canAdd } = useRecordCapabilities(); + const { canWriteDomain } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const { prefs } = useModuleListPrefs("labs"); // v1.22 — a short, factual line under each marker heading describing what the @@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { title={t("labs.emptyTitle")} description={t("labs.emptyDescription")} action={ - onAddFirst && canAdd ? ( + onAddFirst && canAddLab ? ( ) : undefined } diff --git a/src/components/layout/__tests__/capture-picker-discard.test.tsx b/src/components/layout/__tests__/capture-picker-discard.test.tsx index a2c0b3819..f701b3a94 100644 --- a/src/components/layout/__tests__/capture-picker-discard.test.tsx +++ b/src/components/layout/__tests__/capture-picker-discard.test.tsx @@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/__tests__/capture-picker.test.tsx b/src/components/layout/__tests__/capture-picker.test.tsx index 52a3fb878..ecb0d3463 100644 --- a/src/components/layout/__tests__/capture-picker.test.tsx +++ b/src/components/layout/__tests__/capture-picker.test.tsx @@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index c92c0a859..b3415e891 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -51,24 +51,18 @@ import { type CaptureKind = "measurement" | "medication" | "mood"; /** - * v1.36.x — which of the capture surfaces a delegation admits. + * The section each capture surface writes to. * - * A WRITE grant covers entering a reading and marking a dose. It does not - * cover a mood entry: that verb stays on the owner's own authentication and - * the server refuses it under a switch. The picker is the fastest path into - * all three on a phone, so it is the surface where offering an unadmitted one - * costs the most. - */ -const DELEGABLE_CAPTURE_KINDS: ReadonlySet = new Set([ - "measurement", - "medication", -]); - -/** - * v1.38.12 — the section each capture surface writes to. A MANAGE grant that - * reaches the section's routes is offered the surface; a mood entry is a - * MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does - * not. + * One question per kind, asked of that kind's own section. The picker used to + * ask a coarse one as well — a closed list of kinds a WRITE grant admits, + * offered whenever the grant could write anywhere — and that answer is the + * grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` + * read as true and got a weight form the server refuses; one scoped to + * `["documents"]`, which takes no delegated write at any level, got both. + * + * `writableDomains` is already the level × scope × route-table intersection, + * so asking it per section answers both cases and the mood entry's + * MANAGE-only create as well. */ const CAPTURE_KIND_DOMAIN: Readonly> = { measurement: "measurements", @@ -117,13 +111,10 @@ export function admittedCaptureKind( * cannot tap the button that opens it. */ export function visibleCaptureKinds( - caps: Pick, + caps: Pick, kinds: ReadonlyArray, ): CaptureKind[] { - return kinds.filter((kind) => { - if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true; - return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind); - }); + return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind])); } interface CapturePickerProps { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 300dcec0e..47f9c9fbd 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -224,11 +224,28 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-documents", requiresModule: "inboundDocuments", }, - // v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they - // drop out under a switch. Both are AI surfaces, and AI egress of a - // person's health data rides the consent THAT person gave for their own - // use; a delegate triggering it would create a consent-shaped act the owner - // never made. Non-delegable in v1, server-side and here (design §4). + // Insights and the Coach carry no `sharedRecord` flag, so they drop out + // under a switch. The reason has changed since v1.36.0 and the old wording + // ("non-delegable, server-side and here") is no longer true of the tiles: + // twenty-seven `/api/insights/*` reads plus `/api/dashboard/summary` and + // `/api/export/health-record` declare `("manage", "record")`, and the + // AI-egress objection they were closed for is answered by construction — + // the record resolver stamps `setDelegatedGenerationSuppressed`, so a + // delegate reads what the owner's own account generated and causes no + // egress. + // + // What keeps the door shut is the OVERVIEW's own reads rather than the + // tiles': `/api/insights/layout` (`insights/layout/route.ts`) and + // `/api/insights/generate` (`insights/generate/route.ts`) both resolve + // `requireAuth()`, which refuses any switch at any level. Opening the entry + // without deciding what those two mean for a delegate — whose saved layout, + // and whether the briefing reads or regenerates — would hand a MANAGE + // delegate a page whose chrome 403s while its tiles answer. That is a + // decision about two more routes, not a flag on this one. + // + // The Coach stays closed on the original argument, unchanged: + // `/api/insights/chat` is genuinely not delegable, and the page is a + // generation surface rather than a read of one. { href: "/insights", tKey: "nav.insights", diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx index 584e016f4..74b48d6a2 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ canManage: true, - canAdd: true, + canWriteDomain: () => true, canManageDomain: () => true, }), })); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index efa88d127..d85ad6a8c 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -292,8 +292,9 @@ export function MeasurementList({ // v1.36.x — inside somebody else's record, only the add path survives: // entering a reading is an admitted delegated write, editing and deleting // one are not. The row controls are dropped rather than disabled. - const { canAdd, canManage, inSharedRecord, canWrite } = + const { canWriteDomain, canManage, inSharedRecord, canWrite } = useRecordCapabilities(); + const canAddMeasurement = canWriteDomain("measurements"); const activeRecord = user?.accountAccess?.active ?? null; // Named only when the sentence below is owed: a delegate who may add but // not change. In the caller's own record, and for a read-only delegate who @@ -1068,7 +1069,7 @@ export function MeasurementList({ > {t("measurements.emptyResetFilter")} - ) : onAddFirst && canAdd ? ( + ) : onAddFirst && canAddMeasurement ? ( + ) : null; + return (
} description={t("vaccinations.subtitle")} - actions={ - - } + actions={addButton} /> { // Offer the booster only when the catalogue entry carries an // interval; a free-text or one-off dose never prompts. - if (boosterOfferFor(created)) setBoosterFor(created); + if (canPlanBooster && boosterOfferFor(created)) + setBoosterFor(created); }} /> @@ -102,21 +119,26 @@ export function VaccinationsView() { onRetry={() => void refetch()} /> ) : records.length > 0 ? ( - + ) : ( } title={t("vaccinations.empty.title")} description={t("vaccinations.empty.description")} action={ - + canAddDose ? ( + + ) : undefined } /> )} diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index ef791c475..473404d93 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -10,10 +10,10 @@ * Mutation checks, run: * - return `canManage: true` for a WRITE record → "a delegate may not change * what is already there" goes red. - * - return `canAdd: true` for a READ record → "a read-only delegate adds - * nothing" goes red. - * - make the `!active` branch return `canAdd: false` → "the caller's own - * record keeps everything" goes red. + * - answer `canWriteDomain` from `level` instead of the published list → + * "a read-only delegate adds nothing" and the scoped legs go red. + * - make the `!active` branch answer `canWriteDomain: false` → "the caller's + * own record keeps everything" goes red. * - answer `canManageDomain` from `level === "manage"` instead of the list → * "the vault is never manageable" goes red. */ @@ -76,7 +76,6 @@ describe("resolveRecordCapabilities", () => { expect(own).toMatchObject({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, // No grant, so no level and no narrowing — not a fabricated "manage" // over all eight sections, which a consumer would then have to tell @@ -91,10 +90,12 @@ describe("resolveRecordCapabilities", () => { } expect(resolveRecordCapabilities(undefined)).toMatchObject({ inSharedRecord: false, - canAdd: true, canManage: true, recordKind: "self", }); + expect(resolveRecordCapabilities(undefined).canWriteDomain("labs")).toBe( + true, + ); }); it("refuses a malformed published block instead of treating it as own record", () => { @@ -102,7 +103,6 @@ describe("resolveRecordCapabilities", () => { expect(refused).toMatchObject({ inSharedRecord: true, canWrite: false, - canAdd: false, canManage: false, level: null, sections: [], @@ -120,7 +120,6 @@ describe("resolveRecordCapabilities", () => { expect(reader).toMatchObject({ inSharedRecord: true, canWrite: false, - canAdd: false, canManage: false, level: "read", sections: null, @@ -139,7 +138,6 @@ describe("resolveRecordCapabilities", () => { expect(writer).toMatchObject({ inSharedRecord: true, canWrite: true, - canAdd: true, canManage: false, level: "write", sections: null, @@ -155,6 +153,26 @@ describe("resolveRecordCapabilities", () => { } }); + it("a grant that can write nothing is still a WRITE grant, and offers nothing", () => { + // The reason the coarse answer is gone. `canWrite` is the LEVEL and has no + // scope term, so a WRITE grant scoped to the vault — which takes no + // delegated write at any level — answers true while every section answers + // false. A control gating on the level would paint an upload button that + // 403s at `grantCoversDomain`. + const vaultOnly = resolveRecordCapabilities({ + ...WRITABLE, + sections: ["documents"], + writableDomains: [], + manageableDomains: [], + }); + expect(vaultOnly.canWrite).toBe(true); + for (const domain of SHARE_DOMAINS) { + expect(vaultOnly.canWriteDomain(domain), domain).toBe(false); + expect(vaultOnly.canManageDomain(domain), domain).toBe(false); + } + expect(vaultOnly.canManage).toBe(false); + }); + it("carries the level and the sections through untouched", () => { // The two facts v1.37.0 adds are bound, not interpreted. `sections` is // the server's list in the server's order; `level` is the server's word. @@ -177,7 +195,6 @@ describe("resolveRecordCapabilities", () => { expect(manager).toMatchObject({ inSharedRecord: true, canWrite: true, - canAdd: true, canManage: true, level: "manage", sections: null, @@ -219,7 +236,12 @@ describe("resolveRecordCapabilities", () => { access: "write", canWrite: false, }; - expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false); + for (const domain of SHARE_DOMAINS) { + expect( + resolveRecordCapabilities(disagreeing).canWriteDomain(domain), + domain, + ).toBe(false); + } // The same posture for the lists: a scoped WRITE grant is writable only // where its list says so, not wherever its level would reach. const scopedWriter: AccountAccessEntry = { @@ -246,7 +268,7 @@ describe("resolveRecordCapabilities", () => { * every delegable route will refuse, while `active` being null would otherwise * make this hook answer "your own record, all controls". That combination * paints an add button on a page whose every write is about to 403 — the exact - * failure `canAdd` exists to end. + * failure the per-section answers exist to end. */ describe("an unprovable record context withholds every control", () => { it("holds when the raw selector names a record the grant no longer opens", () => { @@ -254,7 +276,9 @@ describe("an unprovable record context withholds every control", () => { recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null), ).toBe(true); const held = resolveRecordCapabilities(null, false, false, true); - expect(held.canAdd).toBe(false); + for (const domain of SHARE_DOMAINS) { + expect(held.canWriteDomain(domain), domain).toBe(false); + } expect(held.canManage).toBe(false); // REFUSED, not pending. `recordSessionPending` renders a bare spinner with // no controls, which is right for a switch in flight and a wedge here: this diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index c1f5058a4..11e67fa72 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -23,6 +23,7 @@ import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format"; import { storeTimezone } from "@/lib/timezone-mirror"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ModuleAccessState } from "@/lib/sharing/module-disclosure"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { @@ -81,6 +82,19 @@ export interface AuthUser { * `onboardingTourCompleted` boolean, which stays the auto-launch gate. */ onboardingTourProgress: AuthTourProgress | null; + /** + * v1.39 (C1) — the needs-based setup flow's state for the record the payload + * describes — the active one under a switch: the nine ordered steps with + * their `pending | done | skipped` status, the answers as given, the flow's + * own completion stamp (distinct from `onboardingCompletedAt` above, which + * stays the first-run redirect's gate) and the one task the flow offered. + * Under a scoped grant the answers come back empty. + * + * Optional in the type so a stale /me payload — an older server image, or a + * test fixture written before the field existed — coerces to "no flow" in + * the mapper below rather than failing the shape. + */ + onboarding?: OnboardingStateDto | null; /** * v1.18.6 (DISC-02) — ISO timestamp of the one-time medical-disclaimer * acknowledgment, or null when never acknowledged. The onboarding welcome @@ -386,6 +400,10 @@ export async function fetchMe(): Promise { // v1.18.6 — coerce against a stale /me payload (older server image // without the field) to null so the tour starts from the top. onboardingTourProgress: data.onboardingTourProgress ?? null, + // v1.39 (C1) — coerce against a stale /me payload (an older server image + // without the field) to null, which every reader treats as "no flow was + // ever run" rather than as an unfinished one. + onboarding: data.onboarding ?? null, // v1.7.0 — coerce against a stale /me payload (older server image // without the field) so the display defaults to metric. unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index 524c075a8..2aaf563bd 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -43,9 +43,8 @@ const ALWAYS = () => true; * server's table (`domain-write-support.ts`), published per grant as two * lists on `accountAccess.active`. Four answers carry that: * - * - `canAdd` — may this person add one of the admitted kinds. * - `canWriteDomain(domain)` — is there a delegated write in this section - * the grant satisfies. + * the grant satisfies. The only question a create affordance asks. * - `canManageDomain(domain)` — may this person change what is already * there in this section, or add what a WRITE grant does not cover. * - `canManage` — the coarse switch: is there ANY section this person may @@ -53,7 +52,11 @@ const ALWAYS = () => true; * because a control belongs to one section and the answer differs per * section (the vault is read-only under every grant). * - * A surface that offers an admitted create asks `canAdd`. A control that + * A surface that offers an admitted create asks `canWriteDomain` for its own + * section. There is deliberately no coarse "may this person add anything": + * `canWrite` is the grant's level with no scope term, so a WRITE grant scoped + * to `["labs"]` answers true and a weight form built on that answer 403s at + * `grantCoversDomain`. A control that * edits, deletes or creates what WRITE does not cover asks `canManageDomain` * for its own section. A control whose route resolves the caller rather than * the record (settings, credentials, AI budget, chart preferences) asks @@ -92,10 +95,13 @@ export interface RecordCapabilities { accessRefused?: boolean; /** Is this browser acting on somebody else's record right now. */ inSharedRecord: boolean; - /** The grant's resolved level for the record on screen. */ + /** + * The grant's resolved level for the record on screen — LEVEL ONLY, with no + * scope term. A WRITE grant scoped to the document vault answers true here + * and can write nothing anywhere. Never gate a control on it; ask + * {@link canWriteDomain} for the control's own section. + */ canWrite: boolean; - /** May the caller add an entry of a kind the delegation admits. */ - canAdd: boolean; /** * May the caller change what exists somewhere in this record, or add what * the delegation excludes. In one's own record, always. In a shared record, @@ -166,7 +172,6 @@ export function resolveRecordCapabilities( recordSessionPending: recordSessionPending || undefined, inSharedRecord: true, canWrite: false, - canAdd: false, canManage: false, canWriteDomain: NEVER, canManageDomain: NEVER, @@ -179,7 +184,6 @@ export function resolveRecordCapabilities( return { inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: ALWAYS, canManageDomain: ALWAYS, @@ -201,7 +205,6 @@ export function resolveRecordCapabilities( return { inSharedRecord: true, canWrite: active.canWrite, - canAdd: active.canWrite, canManage: manageable.size > 0, canWriteDomain: (domain) => writable.has(domain), canManageDomain: (domain) => manageable.has(domain), @@ -243,7 +246,7 @@ export function resolveRecordCapabilities( * opens: every delegable route will refuse, while `active` being null makes * this hook answer "your own record, all controls". That combination paints an * add button on a page whose every write is about to 403 — the exact failure - * `canAdd` was introduced to end. So a disagreement holds instead. + * the per-section answers exist to end. So a disagreement holds instead. * * A null or absent `recordSession` is not a disagreement. It is the Bearer * transport (no session row, no switch state) or a server image that predates diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 43fab3294..0dd1d3a53 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -210,9 +210,20 @@ export const WIPE_MODELS = [ // just returned. "IdempotencyKey", "DataBackup", + // What this host last put in the operator's bucket for this account. The + // objects themselves stay — the worker holds no DeleteObject grant and the + // bucket's lifecycle rule owns their retirement — but the row saying when + // they were written is about this account and goes with it. + "OffhostBackupState", "AuditLog", "ConsentReceipt", "UserKnownDevice", + // v1.39 — the setup answers and the step ledger. The USER_RESET block below + // already puts the wizard of today back to the start; this is the same + // decision for the needs-based flow, and it belongs on the same side of the + // line for the same reason: the record the answers were about is gone, so + // the answers describe nothing. + "OnboardingRecord", // ── Parents, last ─────────────────────────────────────────────────────── "Medication", diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 705596437..7f080ca56 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -18,7 +18,6 @@ import { replaceTargets, type LinkTargetKind, } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { @@ -59,27 +58,6 @@ export const ENCOUNTER_INCLUDE = { reminder: { select: { nextDueAt: true } }, } as const satisfies Prisma.EncounterInclude; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * Which section of the record each link family's TARGET belongs to. * diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 99d035567..93626363b 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -373,6 +373,7 @@ function makePrisma() { ]), }, healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) }, + onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, // The visit tables. Empty here: this file asserts the sections it seeds, // and an account with no visits is the ordinary case the builder must // still answer for. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 5f47cadf2..e650fb705 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -176,6 +176,12 @@ export const BACKED_UP_MODELS = [ // for a restored preference to attach to. "ReminderPhaseConfig", "ConsentReceipt", + // The setup answers. Nothing recomputes them: the module map they produced + // survives on the `User` row, but the map says what is switched on, not that + // the person said they take medication daily and have a visit next month. A + // restore without the row opens a blank questionnaire for somebody who has + // already answered it. + "OnboardingRecord", ] as const; /** @@ -236,6 +242,9 @@ export const BACKUP_WRITER_FILES: readonly string[] = [ // the reason the visits comment gives. "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + // The needs-based setup answers, both ends beside each other for the reason + // the visits comment gives. + "src/lib/export/onboarding-backup.ts", // The ECG strips, same arrangement again. The recording reads through its // OWN delegate here rather than riding the measurement it references, // because that reference is a pointer between two independently carried @@ -257,6 +266,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [ "src/lib/export/document-filing-backup.ts", "src/lib/export/awards-backup.ts", "src/lib/export/environment-backup.ts", + "src/lib/export/onboarding-backup.ts", "src/lib/export/ecg-backup.ts", "src/lib/cycle/backup.ts", ]; @@ -495,6 +505,18 @@ export const TWO_ENDED_MODELS = [ // theoretical. The reference is resolved against what the restore wrote, // nulled when it cannot be, and reported either way. "EcgRecording", + // The setup answers, carried from the release that introduces them. There is + // no debt entry to write instead: the row is one small object with no + // reference to resolve, so "carried" and "owed" would have cost the same + // afternoon and only one of them returns a record that can say "set up + // again" and mean it. + // + // `modulesDerivedAt` is the field that makes this more than history. It is + // the once-only latch on the module derivation, so a restore that let it + // default to null would hand the next confirm permission to re-apply the + // questionnaire over every module decision taken in Settings since. It is + // carried and asserted, not defaulted. + "OnboardingRecord", ] as const; /** One model claimed to travel both ways. */ @@ -642,6 +664,8 @@ export const NOT_IN_BACKUP_MODELS: Readonly> = { "Replay protection for requests this host already answered. Restoring it would suppress a legitimate new request.", DataBackup: "The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.", + OffhostBackupState: + "When this host last put this account's copy in this operator's bucket, and how big it was. It describes one deployment's relationship with one bucket, so restoring it elsewhere would assert an off-host copy that host has never written.", ImportJob: "A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.", InviteToken: diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 9aa30beb9..89f63c045 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -112,6 +112,12 @@ import { type IntradayProfileBackupCounts, type IntradayProfileBackupSection, } from "@/lib/export/intraday-profile-backup"; +import { + buildOnboardingBackupSection, + countOnboardingBackupSection, + type OnboardingBackupCounts, + type OnboardingBackupSection, +} from "@/lib/export/onboarding-backup"; export interface FullBackupCounts extends @@ -128,6 +134,7 @@ export interface FullBackupCounts DocumentFilingBackupCounts, AwardsBackupCounts, EnvironmentBackupCounts, + OnboardingBackupCounts, EcgBackupCounts { measurements: number; medications: number; @@ -696,6 +703,7 @@ export async function buildFullBackupPayload( documentFiling, awards, environment, + onboardingRecord, ecg, nutrientDays, ] = await Promise.all([ @@ -860,6 +868,12 @@ export async function buildFullBackupPayload( // `src/lib/export/environment-backup.ts`, and the purpose is absent for // the same reason as the awards above. buildEnvironmentBackupSection(prisma, userId), + // The needs-based setup answers. One small row, and the only copy of what + // the person told the flow. Both ends live in + // `src/lib/export/onboarding-backup.ts` beside each other. + buildOnboardingBackupSection(prisma, userId, { + purpose: disasterRecovery ? "disaster-recovery" : "portable-export", + }), // The ECG strips, their rhythm verdicts and their traces. Both ends live // in `src/lib/export/ecg-backup.ts` beside each other, the same // arrangement as the sections above and for the same reason. @@ -892,6 +906,7 @@ export async function buildFullBackupPayload( const profileSection: ProfileBackupSection = profile; const intradaySection: IntradayProfileBackupSection = intradayProfiles; const healthScoreSection: HealthScoreBackupSection = healthScoreRecords; + const onboardingSection: OnboardingBackupSection = onboardingRecord; const visitsSection: VisitsBackupSection = visits; const vaccinationsSection: VaccinationsBackupSection = vaccinations; const remindersSection: RemindersBackupSection = reminders; @@ -1238,6 +1253,7 @@ export async function buildFullBackupPayload( ...profileSection, ...intradaySection, ...healthScoreSection, + ...onboardingSection, ...visitsSection, ...vaccinationsSection, ...remindersSection, @@ -1306,6 +1322,7 @@ export async function buildFullBackupPayload( ...countProfileBackupSection(profile), ...countIntradayProfileBackupSection(intradayProfiles), ...countHealthScoreBackupSection(healthScoreRecords), + ...countOnboardingBackupSection(onboardingRecord), ...countVisitsBackupSection(visits), ...countVaccinationsBackupSection(vaccinations), ...countRemindersBackupSection(reminders), diff --git a/src/lib/export/onboarding-backup.ts b/src/lib/export/onboarding-backup.ts new file mode 100644 index 000000000..48c3e31af --- /dev/null +++ b/src/lib/export/onboarding-backup.ts @@ -0,0 +1,199 @@ +/** + * The needs-based setup answers, with both backup ends in one file. + * + * The builder and the restore live together deliberately, the way + * `health-score-backup.ts` and `src/lib/cycle/backup.ts` do: a reader asking + * "is this carried at both ends?" answers it here, and a reader who greps only + * the restore ROUTE gets a false negative, because the route delegates. + * + * Why it is `BACKED_UP` rather than `DERIVED`. Nothing recomputes an answer. + * The module map the answers produced survives on the `User` row, but the map + * is not the answers: it says what is switched on, not that the person said + * they take medication daily and have a visit next month. Losing the row would + * make "Set up again" open a blank questionnaire for someone who already + * answered it, and would leave the getting-started checklist ordering itself + * from nothing. It is one small row and it is the only copy. + * + * Nothing here is encrypted, so there is no portable / disaster-recovery split + * on the values: the three JSON columns hold closed enum vocabularies rather + * than free text. The only difference between the two purposes is identity — a + * canonical disaster-recovery payload carries the row id and its timestamps so + * the row comes back as itself. + */ +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +import { + parseOnboardingFirstResult, + parseOnboardingNeeds, + parseOnboardingSteps, + type OnboardingFirstResult, + type OnboardingNeeds, + type OnboardingStepState, +} from "@/lib/onboarding/needs"; + +export interface OnboardingBackupOptions { + purpose?: "portable-export" | "disaster-recovery"; +} + +/** The record's setup state, exactly as it was answered. */ +export interface OnboardingBackupEntry { + /** Present in canonical DR payloads so the row keeps a stable identity. */ + id?: string; + /** + * Read through the same fail-soft parsers the account payload uses rather + * than copied out of the column raw. A blob written by a release that named + * an answer this one retired therefore reaches the file as the answers this + * release can honour, and a restore cannot re-import a value its own + * validation would refuse. + */ + needs: OnboardingNeeds; + steps: OnboardingStepState[]; + firstResult: OnboardingFirstResult | null; + /** + * When the module map was derived. Carried, and this is the field that makes + * the row worth carrying rather than defaulting: a restore that dropped it + * would let the next confirm re-derive the whole map over whatever the + * account has changed in Settings since. + */ + modulesDerivedAt: string | null; + completedAt: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface OnboardingBackupSection { + onboardingRecord: OnboardingBackupEntry | null; +} + +export interface OnboardingBackupCounts { + onboardingRecords: number; +} + +/** + * Build the setup-answers slice of a user's full backup. + * + * Takes the delegate it uses rather than a whole `PrismaClient`, matching the + * other section builders, so the route's global client and the worker's local + * one share one read. + */ +export async function buildOnboardingBackupSection( + prisma: Pick, + userId: string, + options: OnboardingBackupOptions = {}, +): Promise { + const disasterRecovery = options.purpose === "disaster-recovery"; + + const row = await prisma.onboardingRecord.findUnique({ where: { userId } }); + if (!row) return { onboardingRecord: null }; + + return { + onboardingRecord: { + ...(disasterRecovery + ? { + id: row.id, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } + : {}), + needs: parseOnboardingNeeds(row.needsJson), + steps: parseOnboardingSteps(row.stepsJson), + firstResult: parseOnboardingFirstResult(row.firstResultJson), + modulesDerivedAt: row.modulesDerivedAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + }, + }; +} + +/** Row counts for the audit trail, mirroring the other section counters. */ +export function countOnboardingBackupSection( + section: OnboardingBackupSection, +): OnboardingBackupCounts { + return { onboardingRecords: section.onboardingRecord ? 1 : 0 }; +} + +/** Counts the setup-state restore wiped, for the audit trail. */ +export interface OnboardingRestoreCleared { + onboardingRecords: number; +} + +/** + * One entry as the parsed FILE carries it. + * + * The three answer objects arrive as opaque JSON rather than as the typed + * shapes the builder wrote, and that is deliberate: the file's Zod schema + * validates them loosely so a value this release retired cannot refuse a whole + * account's restore, and the narrowing happens below, through the same + * fail-soft parsers the account payload reads with. A file hand-edited to name + * an area that no longer exists therefore restores without it, rather than + * either failing or writing a value nothing can read. + */ +export interface OnboardingRestoreEntry { + id?: string; + needs: unknown; + steps: unknown; + firstResult: unknown; + modulesDerivedAt: string | null; + completedAt: string | null; + createdAt?: string; + updatedAt?: string; +} + +/** + * The slice of a parsed backup this restore consumes. + * + * Required rather than optional: `BackupPayload` defaults it, so every caller + * already satisfies this, and a caller that stops satisfying it fails to + * compile instead of passing `undefined` into a branch that does nothing and + * reports success. + */ +export interface OnboardingRestoreInput { + onboardingRecord: OnboardingRestoreEntry | null; +} + +/** + * Re-create the account's setup state. + * + * Delete-then-recreate inside the caller's transaction, matching every other + * section. A file carrying no row leaves the account with none, which is the + * honest state for a record that never entered the flow — and the delete still + * runs, because the restore replaces the account's data rather than merging + * into it. + */ +export async function restoreOnboardingData( + tx: Prisma.TransactionClient, + ownerId: string, + payload: OnboardingRestoreInput, +): Promise { + const cleared = await tx.onboardingRecord.deleteMany({ + where: { userId: ownerId }, + }); + + const entry = payload.onboardingRecord; + if (entry) { + const firstResult = parseOnboardingFirstResult(entry.firstResult); + await tx.onboardingRecord.create({ + data: { + ...(entry.id ? { id: entry.id } : {}), + userId: ownerId, + needsJson: parseOnboardingNeeds( + entry.needs, + ) as unknown as Prisma.InputJsonValue, + stepsJson: parseOnboardingSteps( + entry.steps, + ) as unknown as Prisma.InputJsonValue, + firstResultJson: + firstResult === null + ? undefined + : (firstResult as unknown as Prisma.InputJsonValue), + modulesDerivedAt: entry.modulesDerivedAt + ? new Date(entry.modulesDerivedAt) + : null, + completedAt: entry.completedAt ? new Date(entry.completedAt) : null, + ...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}), + ...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}), + }, + }); + } + + return { onboardingRecords: cleared.count }; +} diff --git a/src/lib/format/bytes.ts b/src/lib/format/bytes.ts new file mode 100644 index 000000000..c59732ce2 --- /dev/null +++ b/src/lib/format/bytes.ts @@ -0,0 +1,22 @@ +import type { useFormatters } from "@/lib/i18n/context"; + +/** + * One spelling of "1.25 MB" for every surface that shows a byte count from + * the admin backups response. + * + * It lives here rather than beside the card that happened to need it first: + * the second reader was importing a formatter out of a thousand-line client + * component, which is a dependency on a page's worth of code for four lines of + * arithmetic. Both cards render on the same route today, so it costs nothing + * today — and it becomes a cost the moment either one moves. + */ +export function formatBytes( + bytes: number, + fmt: ReturnType, +): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) { + return `${fmt.integer(Math.round(bytes / 1024))} KB`; + } + return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; +} diff --git a/src/lib/jobs/__tests__/offhost-backup-freshness.test.ts b/src/lib/jobs/__tests__/offhost-backup-freshness.test.ts new file mode 100644 index 000000000..7de8e0fa4 --- /dev/null +++ b/src/lib/jobs/__tests__/offhost-backup-freshness.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + classifyOffhostBackup, + OFFHOST_BACKUP_GRACE_HOURS, + OFFHOST_BACKUP_PERIOD_HOURS, +} from "@/lib/jobs/offhost-backup-freshness"; + +const NOW = new Date("2026-09-10T09:00:00.000Z"); +const HOUR = 3_600_000; +const PERIOD = OFFHOST_BACKUP_PERIOD_HOURS * HOUR; +const GRACE = OFFHOST_BACKUP_GRACE_HOURS * HOUR; + +/** `age` hours before NOW. */ +function ago(ms: number): Date { + return new Date(NOW.getTime() - ms); +} + +describe("classifyOffhostBackup", () => { + it("says unknown, not never, when no run has recorded this account", () => { + // The ledger starts empty. On the first morning after the upgrade every + // account on a perfectly healthy host looks like this, and `never` would + // assert something about the bucket the host has not looked at. + expect( + classifyOffhostBackup({ + lastAttemptAt: null, + lastSuccessAt: null, + now: NOW, + }), + ).toEqual({ freshness: "unknown", ageHours: null }); + }); + + it("says never once a run has walked the account and put nothing there", () => { + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(HOUR), + lastSuccessAt: null, + now: NOW, + }), + ).toEqual({ freshness: "never", ageHours: null }); + }); + + it("is fresh right up to one period plus the grace and due one millisecond past it", () => { + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: ago(PERIOD + GRACE), + now: NOW, + }).freshness, + ).toBe("fresh"); + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: ago(PERIOD + GRACE + 1), + now: NOW, + }).freshness, + ).toBe("due"); + }); + + it("is due right up to two periods plus the grace and stale one millisecond past it", () => { + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: ago(PERIOD * 2 + GRACE), + now: NOW, + }).freshness, + ).toBe("due"); + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: ago(PERIOD * 2 + GRACE + 1), + now: NOW, + }).freshness, + ).toBe("stale"); + }); + + it("keeps the whole cohort fresh across the Berlin DST fall-back night", () => { + // 2026-10-25 is the fall-back Sunday in Europe/Berlin: the clocks go back + // at 03:00 CEST, so 02:30 local happens 25 hours after the previous + // 02:30 local. Both instants are pinned in UTC because that is what the + // ledger stores, and the test must not depend on the runner's own zone. + const beforeTheChange = new Date("2026-10-24T00:30:00.000Z"); // 02:30 CEST + const afterTheChange = new Date("2026-10-25T01:30:00.000Z"); // 02:30 CET + const gapHours = + (afterTheChange.getTime() - beforeTheChange.getTime()) / HOUR; + expect(gapHours).toBe(25); + + // Without slack the whole cohort would read `due` on that one morning, + // on every host, with nothing wrong anywhere. + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: beforeTheChange, + now: afterTheChange, + }).freshness, + ).toBe("fresh"); + }); + + it("still calls a genuinely missed night due", () => { + // 31 hours: past one period even with six hours of slack, so a run that + // produced nothing for this account still shows. + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: ago(31 * HOUR), + now: NOW, + }).freshness, + ).toBe("due"); + }); + + it("reports whole hours of age", () => { + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: ago(90 * 60_000), + now: NOW, + }), + ).toEqual({ freshness: "fresh", ageHours: 1 }); + }); + + it("honours a caller's own period rather than the nightly default", () => { + // A six-hour schedule: the same 12-hour-old object that is fresh under the + // nightly cron is two periods behind under this one. The grace is passed + // as zero so the thresholds under test are the caller's period alone. + const twelveHours = ago(12 * HOUR); + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: twelveHours, + now: NOW, + }).freshness, + ).toBe("fresh"); + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: twelveHours, + now: NOW, + periodHours: 6, + graceHours: 0, + }).freshness, + ).toBe("due"); + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: twelveHours, + now: NOW, + periodHours: 5, + graceHours: 0, + }).freshness, + ).toBe("stale"); + }); + + it("does not call a copy stale because the host clock ran backwards", () => { + expect( + classifyOffhostBackup({ + lastAttemptAt: ago(0), + lastSuccessAt: new Date(NOW.getTime() + 2 * HOUR), + now: NOW, + }), + ).toEqual({ freshness: "fresh", ageHours: 0 }); + }); +}); diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts index 5a630b088..a9d07113c 100644 --- a/src/lib/jobs/__tests__/offhost-backup.test.ts +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -184,6 +184,7 @@ describe("runOffhostBackup", () => { s3.store.set("2020-01-01/user-old.json.enc", Buffer.from([0])); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), }, @@ -229,11 +230,28 @@ describe("runOffhostBackup", () => { // Nothing went through the whole-buffer arm. expect(s3.putObject).not.toHaveBeenCalled(); expect(s3.putStream).toHaveBeenCalledTimes(2); + + // Every account that got an object gets a ledger row, because the run's + // own counts cannot say WHICH account has a copy off-host and which one + // has been failing quietly since March. + expect(prisma.offhostBackupState.upsert).toHaveBeenCalledTimes(2); + const ledgered = prisma.offhostBackupState.upsert.mock.calls.map( + (call) => (call[0] as { where: { userId: string } }).where.userId, + ); + expect(ledgered).toEqual(["u1", "u2"]); + const first = prisma.offhostBackupState.upsert.mock.calls[0]?.[0] as { + create: { sizeBytes: bigint; lastSuccessAt: Date }; + }; + // BigInt, because the uploader permits an 80 GB object and a 32-bit + // column would refuse the biggest account on the host. + expect(first.create.sizeBytes).toBe(BigInt(ct.byteLength)); + expect(first.create.lastSuccessAt).toBeInstanceOf(Date); }); it("uploads the canonical builder output without reshaping it", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, }; const canonicalPayload = { @@ -290,7 +308,9 @@ describe("runOffhostBackup", () => { it("counts per-user failures without aborting the whole run", async () => { const s3 = makeS3Mock(); + const upsert = vi.fn(); const prisma = { + offhostBackupState: { upsert }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), }, @@ -314,11 +334,55 @@ describe("runOffhostBackup", () => { expect(report.uploaded).toBe(1); expect(report.failed).toBe(1); + // Both accounts are recorded as walked — that is what separates "a run + // looked and found nothing" from "no run has looked at this account" — + // but only the one whose object landed carries a success. + type Upsert = { + where: { userId: string }; + update: Record; + create: Record; + }; + const calls = upsert.mock.calls.map((call) => call[0] as Upsert); + expect(calls.map((call) => call.where.userId)).toEqual(["u1", "u2"]); + expect(calls[0]?.update).toHaveProperty("lastSuccessAt"); + // The guarantee an operator leans on: an account whose upload failed keeps + // whatever `lastSuccessAt` it already had. The failed arm names only the + // walk instant, so yesterday's good copy is still what the console + // reports — the run does not overwrite it with today's failure. + expect(calls[1]?.update).not.toHaveProperty("lastSuccessAt"); + expect(calls[1]?.update).not.toHaveProperty("sizeBytes"); + expect(calls[1]?.create).not.toHaveProperty("lastSuccessAt"); + }); + + it("still reports a landed object as uploaded when the ledger write fails", async () => { + const s3 = makeS3Mock(); + const prisma = { + offhostBackupState: { + upsert: vi.fn().mockRejectedValue(new Error("pool timeout")), + }, + user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, + }; + + const report = await runOffhostBackup( + prisma as never, + s3, + new Date("2026-05-08T00:00:00Z"), + ); + + // The object is in the bucket. Only the record of it is missing, and that + // is a different fact: counting it as a failure would tell the operator + // their disaster-recovery copy does not exist when it does. + expect(report.uploaded).toBe(1); + expect(report.failed).toBe(0); + expect(report.failures).toEqual([]); + expect(s3.store.has("2026-05-08/user-u1.json.enc")).toBe(true); + expect(prisma.offhostBackupState.upsert).toHaveBeenCalledTimes(1); }); it("refuses an account whose object outgrows one multipart upload", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, }; mocks.buildFullBackupPayload.mockResolvedValue({ @@ -355,6 +419,7 @@ describe("runOffhostBackup", () => { it("reports the largest object it wrote", async () => { const s3 = makeS3Mock(); const prisma = { + offhostBackupState: { upsert: vi.fn() }, user: { findMany: vi.fn().mockResolvedValue([{ id: "u1" }]) }, }; const report = await runOffhostBackup( diff --git a/src/lib/jobs/offhost-backup-freshness.ts b/src/lib/jobs/offhost-backup-freshness.ts new file mode 100644 index 000000000..29d2f5a3d --- /dev/null +++ b/src/lib/jobs/offhost-backup-freshness.ts @@ -0,0 +1,105 @@ +/** + * How fresh one account's newest off-host copy is. + * + * The nightly off-host job reports counts — uploaded, failed, total — and a + * count is exactly the shape that hides a single account. A cohort where one + * account's object has failed every night for a month reports "99 uploaded, 1 + * failed" every night, which reads like weather rather than like an account + * that has no disaster-recovery copy at all. The per-account ledger the worker + * writes (`OffhostBackupState`) turns that into a fact the console can state + * per row, and this function is the only place that fact is judged. + * + * Deliberately pure and deliberately not talking to the bucket: the admin page + * computes staleness from the timestamp the worker wrote, so the answer is the + * same whether or not the app process holds a read grant on the bucket, and a + * listing of somebody's backups never crosses the network to show a page. + * + * Five states, because the run is nightly and one missed night is not the same + * event as four — and because "we have not looked yet" is not a verdict: + * + * unknown — no nightly run has recorded this account. The ledger starts + * empty, so this is what every account on a perfectly healthy host + * reads between the upgrade that adds the table and the first run + * after it. Saying "never" there would assert something the host + * cannot know. + * never — a run walked this account and put no object there, and no earlier + * run ever did either. + * fresh — inside one schedule period plus the grace below. This is what + * every account looks like on a host where the cron is doing its + * job. + * due — past one period, inside two. One night produced nothing: a retry, a + * long run, a restart during the window. Worth showing, not worth + * alarming about. + * stale — past two periods. Two consecutive nights produced nothing for this + * account, which no ordinary hiccup explains. + */ + +/** + * Hours between two scheduled off-host runs. Tracks `OFFHOST_BACKUP_CRON` + * (`30 2 * * *`) in `src/lib/jobs/reminder/register-maintenance.ts`, which + * every schedule runs in `Europe/Berlin` (`tz` in `registrar-shared.ts`). + * This is the number the card quotes — the schedule's own period, not the + * threshold a verdict is measured against. + */ +export const OFFHOST_BACKUP_PERIOD_HOURS = 24; + +/** + * Slack on top of one period, before a verdict moves. + * + * Two things make a healthy host miss a bare 24 hours. The schedule is + * Berlin-local, so on the DST fall-back night the gap between two 02:30 runs + * is 25 hours and every account on every host would cross one period on the + * same morning. And the ledger instant is when THAT account's object landed, + * deliberately: on a cohort walked one account at a time, an account reached + * at 05:30 one night and 06:30 the next is 25 hours old with two entirely + * successful runs behind it. + * + * Six hours is the budget for both. A card whose argument is that a count + * trains an operator to stop reading it cannot afford an annual cohort-wide + * false `due`. + */ +export const OFFHOST_BACKUP_GRACE_HOURS = 6; + +export type OffhostBackupFreshness = + "unknown" | "never" | "fresh" | "due" | "stale"; + +export interface OffhostBackupVerdict { + freshness: OffhostBackupFreshness; + /** Whole hours since the newest object landed; null when there is none. */ + ageHours: number | null; +} + +export function classifyOffhostBackup(args: { + /** + * When a run last walked this account, or null when none has. Null is the + * only thing that separates `unknown` from `never`. + */ + lastAttemptAt: Date | null; + lastSuccessAt: Date | null; + now: Date; + /** Defaults to the nightly schedule. Tests pass their own. */ + periodHours?: number; + /** Defaults to `OFFHOST_BACKUP_GRACE_HOURS`. Tests pass their own. */ + graceHours?: number; +}): OffhostBackupVerdict { + const { lastAttemptAt, lastSuccessAt, now } = args; + if (lastAttemptAt === null) return { freshness: "unknown", ageHours: null }; + if (lastSuccessAt === null) return { freshness: "never", ageHours: null }; + + const periodMs = + (args.periodHours ?? OFFHOST_BACKUP_PERIOD_HOURS) * 3_600_000; + const graceMs = (args.graceHours ?? OFFHOST_BACKUP_GRACE_HOURS) * 3_600_000; + const ageMs = now.getTime() - lastSuccessAt.getTime(); + // A clock that ran backwards between the upload and this read reports a + // negative age. That is a host-clock problem, not a backup problem, and + // calling the copy stale over it would point the operator at the wrong + // thing. + const ageHours = Math.max(0, Math.floor(ageMs / 3_600_000)); + + // The grace rides on each threshold rather than on the period, so `due` + // still means "one scheduled run produced nothing" and `stale` still means + // two — the slack only keeps a long night from being read as a missed one. + if (ageMs <= periodMs + graceMs) return { freshness: "fresh", ageHours }; + if (ageMs <= periodMs * 2 + graceMs) return { freshness: "due", ageHours }; + return { freshness: "stale", ageHours }; +} diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index 0f55273c0..3627b1d2b 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -28,7 +28,7 @@ import { createGzip, gunzipSync, gzipSync } from "node:zlib"; import type { PrismaClient } from "@/generated/prisma/client"; import { createRawStreamEncryptor, decryptRawStream } from "@/lib/crypto"; import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; @@ -61,6 +61,27 @@ function decodeBackupKey(raw: string): Buffer { ); } +/** + * Whether this host has the five variables the nightly job needs. + * + * Separate from `loadOffhostConfig()` because a surface that only wants to say + * "off-host backup is not set up here" must not decode the encryption key to + * find out — `decodeBackupKey` throws on a malformed one, and an operator + * whose key has a typo should see the admin page, not a 500. + */ +export function offhostBackupConfigured(): boolean { + return OFFHOST_ENV_VARS.every((name) => Boolean(process.env[name])); +} + +/** The five variables the nightly job needs, in one place. */ +const OFFHOST_ENV_VARS = [ + "BACKUP_S3_ENDPOINT", + "BACKUP_S3_BUCKET", + "BACKUP_S3_ACCESS_KEY", + "BACKUP_S3_SECRET_KEY", + "BACKUP_ENCRYPTION_KEY", +] as const; + export function loadOffhostConfig(): OffhostBackupConfig | null { const endpoint = process.env.BACKUP_S3_ENDPOINT; const bucket = process.env.BACKUP_S3_BUCKET; @@ -555,10 +576,12 @@ export async function runOffhostBackup( let oversized = 0; let largestObjectBytes = 0; const failures: Array<{ userId: string; message: string }> = []; + let ledgerWriteFailures = 0; const evt = getEvent(); for (const user of users) { + let objectBytes: number | null = null; try { - const objectBytes = await uploadEncryptedBackup( + objectBytes = await uploadEncryptedBackup( s3, `${dateKey}/user-${user.id}.json.enc`, cfg.encryptionKey, @@ -586,6 +609,62 @@ export async function runOffhostBackup( `offhost-backup user ${user.id} failed: ${message.slice(0, 200)}`, ); } + + // The per-account ledger, and deliberately NOT inside the upload's `try`. + // `uploadEncryptedBackup` resolves only after the multipart completes, so + // by here the object is durably in the bucket and this account's backup + // has succeeded whatever the database does next. A pool timeout on the + // row would otherwise un-count a copy that exists — the run would report + // one failure too many and the console would read stale for an account + // whose disaster-recovery object is fine, which is exactly the inversion + // the ledger exists to remove. + // + // The row is written whether or not an object came of the walk, because a + // ledger keyed only on success cannot tell "no run has reached this + // account" from "a run reached it and nothing landed" — and the first of + // those is what every account on a running host looks like the day this + // table ships. A failed walk touches `lastAttemptAt` and leaves + // `lastSuccessAt` exactly as it was, so yesterday's good copy still reads + // as the copy this account has. + // + // `new Date()` rather than the run's `now`, because on a large cohort the + // two are hours apart and the row is meant to say when this account was + // reached. + const at = new Date(); + try { + await prisma.offhostBackupState.upsert({ + where: { userId: user.id }, + update: + objectBytes === null + ? { lastAttemptAt: at } + : { + lastAttemptAt: at, + lastSuccessAt: at, + sizeBytes: BigInt(objectBytes), + }, + create: + objectBytes === null + ? { userId: user.id, lastAttemptAt: at } + : { + userId: user.id, + lastAttemptAt: at, + lastSuccessAt: at, + sizeBytes: BigInt(objectBytes), + }, + }); + } catch (err) { + ledgerWriteFailures++; + const message = (err as Error).message ?? "unknown"; + evt?.addWarning( + `offhost-backup ledger write failed for ${user.id}: ${message.slice(0, 200)}`, + ); + } + } + if (ledgerWriteFailures > 0) { + // A distinct fact from a failed upload, and one an operator has to be able + // to tell apart: the copies are in the bucket, the console's per-account + // view of them is behind. + annotate({ meta: { offhost_ledger_write_failures: ledgerWriteFailures } }); } return { diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts index 357d9ac76..ca146e1e9 100644 --- a/src/lib/medications/import/auto-export-parse.ts +++ b/src/lib/medications/import/auto-export-parse.ts @@ -92,8 +92,14 @@ export interface AutoExportRefusal { reason: AutoExportRefusalReason; } -/** A file-level problem: nothing can be read at all. */ -export const AUTO_EXPORT_FATAL_REASONS = [ +/** + * A file-level problem: nothing can be read at all. + * + * These values ship as `meta.errorCode` on the import route's 422, which is + * why the constant is named for what they are on the wire rather than for the + * field that carries them internally. + */ +export const AUTO_EXPORT_FATAL_ERROR_CODES = [ "empty_file", "missing_required_columns", "unreadable_json", @@ -102,10 +108,11 @@ export const AUTO_EXPORT_FATAL_REASONS = [ "json_carries_no_intake_time", ] as const; -export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number]; +export type AutoExportFatalErrorCode = + (typeof AUTO_EXPORT_FATAL_ERROR_CODES)[number]; export interface AutoExportParseOutcome { - fatal?: { reason: AutoExportFatalReason; detail?: string }; + fatal?: { reason: AutoExportFatalErrorCode; detail?: string }; doses: AutoExportDose[]; refusals: AutoExportRefusal[]; /** Data rows read, refusals included. `doses + refusals` must equal it. */ diff --git a/src/lib/modules/__tests__/onboarding-needs-map.test.ts b/src/lib/modules/__tests__/onboarding-needs-map.test.ts new file mode 100644 index 000000000..342fc2bd0 --- /dev/null +++ b/src/lib/modules/__tests__/onboarding-needs-map.test.ts @@ -0,0 +1,268 @@ +/** + * v1.39 (C1) — the needs → modules mapping, and the merge that protects a + * decision somebody already made. + * + * The mapping is the one piece of the flow with a consequence that outlives + * the screens: a module switched off here disappears from the navigation until + * somebody finds it again in Settings. So the properties below are stated as + * properties over the whole registry rather than as a table of examples — a + * module key added to `MODULE_KEYS` next year is covered by them on the day it + * lands, and an area that stops naming anything fails here rather than + * shipping as a chip with no effect. + */ +import { describe, expect, it } from "vitest"; + +import { + deriveOnboardingModuleDefaults, + isModuleKey, + mergeDerivedModulePreferences, + MODULE_REGISTRY, + ONBOARDING_ALWAYS_ON_MODULES, + ONBOARDING_AREA_KEYS, + ONBOARDING_AREA_MODULES, + OWNED_MODULE_KEYS, + type OnboardingModuleNeeds, +} from "../registry"; + +/** A record with nothing in it — the state a first setup describes. */ +const NO_DATA: ReadonlySet = new Set(); + +function needs( + overrides: Partial = {}, +): OnboardingModuleNeeds { + return { + recordTarget: "me", + areas: [], + medication: "no", + visit: "no", + ...overrides, + }; +} + +describe("the area → module map", () => { + it("gives every area at least one module", () => { + expect(ONBOARDING_AREA_KEYS.length).toBe(9); + for (const area of ONBOARDING_AREA_KEYS) { + expect( + ONBOARDING_AREA_MODULES[area]?.length ?? 0, + `area \`${area}\` names no module, so ticking it changes nothing`, + ).toBeGreaterThan(0); + } + }); + + it("names only real module keys", () => { + for (const area of ONBOARDING_AREA_KEYS) { + for (const key of ONBOARDING_AREA_MODULES[area]) { + expect(isModuleKey(key), `\`${key}\` is not a module key`).toBe(true); + } + } + }); + + it("never names the Coach, which the flow deliberately does not offer", () => { + const named = ONBOARDING_AREA_KEYS.flatMap( + (area) => ONBOARDING_AREA_MODULES[area], + ); + expect(named).not.toContain("coach"); + }); +}); + +describe("deriveOnboardingModuleDefaults", () => { + it("answers for every directly-owned module and for none of the delegated ones", () => { + const derived = deriveOnboardingModuleDefaults(needs()); + expect(Object.keys(derived.preferences).sort()).toEqual( + [...OWNED_MODULE_KEYS].sort(), + ); + expect(derived.preferences).not.toHaveProperty("cycle"); + expect(derived.preferences).not.toHaveProperty("coach"); + for (const key of OWNED_MODULE_KEYS) { + expect(MODULE_REGISTRY[key].delegatesTo).toBeUndefined(); + } + }); + + it("keeps the always-on modules on however the questions are answered", () => { + const answers: OnboardingModuleNeeds[] = [ + needs(), + needs({ areas: [...ONBOARDING_AREA_KEYS], medication: "yes" }), + needs({ recordTarget: "someone-else", visit: "within-a-month" }), + needs({ recordTarget: "both", medication: "sometimes", visit: "later" }), + ]; + for (const answer of answers) { + const { preferences } = deriveOnboardingModuleDefaults(answer); + for (const key of ONBOARDING_ALWAYS_ON_MODULES) { + expect( + preferences[key as keyof typeof preferences], + `always-on module \`${key}\` was derived off`, + ).toBe(true); + } + } + }); + + it("switches on exactly what the chosen areas name", () => { + const { preferences, cycleTracking } = deriveOnboardingModuleDefaults( + needs({ areas: ["glucose", "sleep", "mood"] }), + ); + expect(preferences.glucose).toBe(true); + expect(preferences.sleep).toBe(true); + expect(preferences.recovery).toBe(true); + expect(preferences.mood).toBe(true); + expect(preferences.mentalHealth).toBe(true); + expect(preferences.labs).toBe(false); + expect(preferences.workouts).toBe(false); + expect(preferences.illness).toBe(false); + expect(cycleTracking).toBe(false); + }); + + it("reports the cycle area separately, because the module blob does not own it", () => { + expect(deriveOnboardingModuleDefaults(needs({ areas: ["cycle"] }))).toEqual( + expect.objectContaining({ cycleTracking: true }), + ); + expect(deriveOnboardingModuleDefaults(needs()).cycleTracking).toBe(false); + }); + + it("follows the medication answer", () => { + for (const answer of ["yes", "sometimes"] as const) { + expect( + deriveOnboardingModuleDefaults(needs({ medication: answer })) + .preferences.medications, + ).toBe(true); + } + expect( + deriveOnboardingModuleDefaults(needs({ medication: "no" })).preferences + .medications, + ).toBe(false); + }); + + it("switches the doctor report on for any visit that is actually coming", () => { + for (const visit of ["within-a-month", "later"] as const) { + expect( + deriveOnboardingModuleDefaults(needs({ visit })).preferences + .doctorReport, + ).toBe(true); + } + expect( + deriveOnboardingModuleDefaults(needs({ visit: "no" })).preferences + .doctorReport, + ).toBe(false); + }); + + it("switches the immunization log on for a record somebody else runs", () => { + for (const recordTarget of ["someone-else", "both"] as const) { + expect( + deriveOnboardingModuleDefaults(needs({ recordTarget })).preferences + .vaccinations, + ).toBe(true); + } + expect( + deriveOnboardingModuleDefaults(needs({ recordTarget: "me" })).preferences + .vaccinations, + ).toBe(false); + }); + + it("leaves the opt-in surfaces off unless something asks for them", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: [...ONBOARDING_AREA_KEYS] }), + ); + expect(preferences.mcp).toBe(false); + expect(preferences.nutrients).toBe(false); + expect(preferences.environment).toBe(false); + }); +}); + +describe("mergeDerivedModulePreferences", () => { + it("never turns off a module the person switched on by hand", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + expect(preferences.mcp).toBe(false); + expect(preferences.labs).toBe(false); + + const merged = mergeDerivedModulePreferences( + { mcp: true, labs: true }, + preferences, + NO_DATA, + ); + expect(merged.mcp).toBe(true); + expect(merged.labs).toBe(true); + }); + + it("does turn off a module the record only had by default", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + const merged = mergeDerivedModulePreferences({}, preferences, NO_DATA); + expect(merged.labs).toBe(false); + expect(merged.workouts).toBe(false); + }); + + it("switches on what the answers named even where the record had it off", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: ["labs"] }), + ); + const merged = mergeDerivedModulePreferences( + { labs: false }, + preferences, + NO_DATA, + ); + expect(merged.labs).toBe(true); + }); + + it("drops a stored key that is not a module", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + const merged = mergeDerivedModulePreferences( + { weight: false, nonsense: true }, + preferences, + NO_DATA, + ); + expect(merged).not.toHaveProperty("weight"); + expect(merged).not.toHaveProperty("nonsense"); + }); + + it("never switches off a domain the record already holds rows in", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + expect(preferences.labs).toBe(false); + expect(preferences.mood).toBe(false); + + // An established record with no stored preference at all: default-on, and + // nobody ever touched the toggle. A derived `false` would take the nav + // entry, the dashboard widget and the settings entry away from a domain + // holding years of rows, so the key is left exactly as it was — and + // absence means on. + const merged = mergeDerivedModulePreferences( + {}, + preferences, + new Set(["labs", "mood"]), + ); + expect(merged).not.toHaveProperty("labs"); + expect(merged).not.toHaveProperty("mood"); + expect(merged.workouts).toBe(false); + }); + + it("keeps a stored explicit off where the domain holds rows", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + // Somebody switched labs off by hand and still has old results. The flow + // must not switch it back on, and must not restate the decision either. + const merged = mergeDerivedModulePreferences( + { labs: false }, + preferences, + new Set(["labs"]), + ); + expect(merged.labs).toBe(false); + }); + + it("still switches a domain ON when the answers name it, rows or not", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: ["labs"] }), + ); + const merged = mergeDerivedModulePreferences( + {}, + preferences, + new Set(["labs"]), + ); + expect(merged.labs).toBe(true); + }); + + it("leaves the two delegated keys alone in both directions", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: ["cycle"] }), + ); + const merged = mergeDerivedModulePreferences({}, preferences, NO_DATA); + expect(merged).not.toHaveProperty("cycle"); + expect(merged).not.toHaveProperty("coach"); + }); +}); diff --git a/src/lib/modules/domain-data.ts b/src/lib/modules/domain-data.ts new file mode 100644 index 000000000..cadb16efd --- /dev/null +++ b/src/lib/modules/domain-data.ts @@ -0,0 +1,225 @@ +/** + * v1.39 (C1) — does a record already hold data in a module's domain? + * + * The setup flow's derivation writes an explicit `false` for every module the + * answers did not name, and that is the right answer for a record that is + * being set up. It is the wrong answer for a record that already has years of + * rows: an established self-hoster upgrading into the flow has, for most + * modules, no stored preference at all — they were default-on and nobody ever + * touched the toggle — so the first confirm would switch off the nav entry, + * the dashboard widget and the settings entry for domains that hold content. + * The rows would survive; the surfaces would not. The design spec's second + * principle is that nothing is lost by a choice, and it says that about a NEW + * record; it says nothing about one with content, so the derivation asks here + * before it retracts anything. + * + * The ownership question is not re-answered here. The measurement-level + * domains come from {@link measurementTypesOwnedBy}, which is the same map the + * MCP reads and the Coach snapshot gate off, so a type that changes hands + * cannot drift out of sync with this. What this file adds is the per-module + * table each domain's non-measurement content lives in, one named entry per + * module, exhaustive over `OwnedModuleKey` so a module added next year fails + * to compile until somebody names where its content lives. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { measurementTypesOwnedBy } from "./measurement-scope"; +import type { OwnedModuleKey } from "./registry"; + +/** The delegates this file reads. Narrow so a worker's client fits too. */ +type DomainDataClient = Pick< + PrismaClient, + | "measurement" + | "moodEntry" + | "workout" + | "labResult" + | "illnessEpisode" + | "userAchievement" + | "insightNarrative" + | "medication" + | "encounter" + | "environmentContext" + | "mcpOAuthConnection" + | "inboundDocument" + | "mentalHealthAssessment" + | "nutrientIntakeDay" + | "vaccinationRecord" +>; + +type DomainProbe = ( + client: DomainDataClient, + recordId: string, +) => Promise; + +/** One indexed existence read; a soft-deleted row is not content. */ +async function anyRow(find: PromiseLike): Promise { + return (await find) !== null; +} + +/** Does the record hold any measurement in the types this module owns? */ +function measurementProbe(key: OwnedModuleKey): DomainProbe { + const types = measurementTypesOwnedBy(key); + return async (client, recordId) => { + if (types.length === 0) return false; + return anyRow( + client.measurement.findFirst({ + where: { userId: recordId, deletedAt: null, type: { in: types } }, + select: { id: true }, + }), + ); + }; +} + +/** Either the module's own table or the measurements it owns. */ +function eitherProbe(a: DomainProbe, b: DomainProbe): DomainProbe { + return async (client, recordId) => + (await a(client, recordId)) || (await b(client, recordId)); +} + +/** + * Where each module's content lives. + * + * Exhaustive by type. Every entry names one table (or one measurement domain) + * whose rows mean "this record uses this module", and nothing here reads a + * preference, a connection state or a cache — the question is content, not + * configuration. + */ +const MODULE_DOMAIN_PROBES: Record = { + mood: eitherProbe( + (client, recordId) => + anyRow( + client.moodEntry.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), + measurementProbe("mood"), + ), + sleep: measurementProbe("sleep"), + glucose: measurementProbe("glucose"), + workouts: eitherProbe( + (client, recordId) => + anyRow( + client.workout.findFirst({ + where: { userId: recordId }, + select: { id: true }, + }), + ), + measurementProbe("workouts"), + ), + recovery: measurementProbe("recovery"), + labs: (client, recordId) => + anyRow( + client.labResult.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), + illness: (client, recordId) => + anyRow( + client.illnessEpisode.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), + achievements: (client, recordId) => + anyRow( + client.userAchievement.findFirst({ + where: { userId: recordId }, + select: { id: true }, + }), + ), + insights: (client, recordId) => + anyRow( + client.insightNarrative.findFirst({ + where: { userId: recordId }, + select: { id: true }, + }), + ), + medications: (client, recordId) => + anyRow( + client.medication.findFirst({ + where: { userId: recordId }, + select: { id: true }, + }), + ), + // The doctor report is built from the visits, so a recorded encounter is + // what "this record uses the report surface" looks like in the data. + doctorReport: (client, recordId) => + anyRow( + client.encounter.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), + environment: eitherProbe( + (client, recordId) => + anyRow( + client.environmentContext.findFirst({ + where: { userId: recordId }, + select: { id: true }, + }), + ), + measurementProbe("environment"), + ), + // The endpoint holds no content of its own; a live authorisation is the + // nearest thing, and switching it off under a connected assistant would + // break a wire somebody set up on purpose. + mcp: (client, recordId) => + anyRow( + client.mcpOAuthConnection.findFirst({ + where: { userId: recordId }, + select: { id: true }, + }), + ), + inboundDocuments: (client, recordId) => + anyRow( + client.inboundDocument.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), + mentalHealth: (client, recordId) => + anyRow( + client.mentalHealthAssessment.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), + nutrients: (client, recordId) => + anyRow( + client.nutrientIntakeDay.findFirst({ + where: { userId: recordId }, + // Keyed on the day and the nutrient, not on an id of its own. + select: { userId: true }, + }), + ), + vaccinations: (client, recordId) => + anyRow( + client.vaccinationRecord.findFirst({ + where: { userId: recordId, deletedAt: null }, + select: { id: true }, + }), + ), +}; + +/** + * Which of `candidates` already hold data for this record. + * + * Only the candidates are read, because the caller only ever asks about the + * modules it is about to switch off — a confirm that names everything reads + * nothing. Each probe is one indexed existence read. + */ +export async function modulesHoldingRecordData( + client: DomainDataClient, + recordId: string, + candidates: readonly OwnedModuleKey[], +): Promise> { + const holding = new Set(); + await Promise.all( + candidates.map(async (key) => { + if (await MODULE_DOMAIN_PROBES[key](client, recordId)) holding.add(key); + }), + ); + return holding; +} diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index 58d4b58ac..2ff632964 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -224,3 +224,20 @@ export function moduleForMeasurementType( ): ModuleKey | null { return OWNER_BY_MEASUREMENT_TYPE.get(type) ?? null; } + +/** + * The inverse: every measurement type `key` owns, or an empty list for a + * module that owns no measurement-level domain. + * + * Derived from the same map {@link moduleForMeasurementType} answers from, so + * the two can never disagree about who owns what. Read by the setup flow's + * derivation, which asks "does this record already hold data in that domain" + * before it switches a module off. + */ +export function measurementTypesOwnedBy(key: ModuleKey): MeasurementType[] { + const out: MeasurementType[] = []; + for (const [type, owner] of OWNER_BY_MEASUREMENT_TYPE) { + if (owner === key) out.push(type); + } + return out; +} diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 7025e129f..281944342 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -423,3 +423,203 @@ export function moduleDelegatesTo( export function isOptInModule(key: ModuleKey): boolean { return MODULE_REGISTRY[key].optIn === true; } + +/* ─── Needs-based onboarding: the areas a person names → the modules ───────── */ + +/** + * v1.39 (C1) — the nine areas the needs questionnaire offers (design spec + * §"The questions", Q2), as stable slugs. + * + * They live here rather than in the flow because the answer only means + * something as a set of modules, and the mapping below is what gives it that + * meaning. Putting the map next to `MODULE_KEYS` is what keeps a module key + * renamed or retired from leaving a dangling area behind: the record type + * makes it a compile error rather than a silently empty selection. + * + * All nine stay visible, cycle included — it is one chip, and hiding it behind + * recorded sex would make the flow guess at something the person is being + * asked about anyway. + */ +export const ONBOARDING_AREA_KEYS = [ + "blood-pressure", + "weight-body", + "glucose", + "sleep", + "mood", + "cycle", + "activity", + "labs", + "illness", +] as const; + +export type OnboardingAreaKey = (typeof ONBOARDING_AREA_KEYS)[number]; + +/** + * The area → module map. Every area names at least one module; blood pressure + * and weight name only `insights`, because the readings themselves are CORE + * (weight / blood pressure / pulse have no key and can never be switched off) + * and the narrative layer is the only thing an answer can decide there. + * + * `cycle` is in the map and is NOT written through `modulePreferencesJson`: + * that key delegates to `CycleProfile.cycleTrackingEnabled`, so the derivation + * reports it separately (see {@link DerivedModuleDefaults.cycleTracking}) and + * the caller writes the real column. `coach` appears nowhere: the flow never + * talks to an AI provider, and turning the Coach on is a Settings decision + * with a cost attached to it. + */ +export const ONBOARDING_AREA_MODULES: Readonly< + Record +> = Object.freeze({ + "blood-pressure": ["insights"], + "weight-body": ["insights"], + glucose: ["glucose", "insights"], + sleep: ["sleep", "recovery"], + // "Mood and mental wellbeing" is one chip over two modules: the daily mood + // entry and the WHO-5 / PHQ-9 / GAD-7 check-in live on the same surface as + // far as the person answering is concerned. + mood: ["mood", "mentalHealth"], + cycle: ["cycle"], + activity: ["workouts"], + labs: ["labs"], + illness: ["illness"], +}); + +/** + * Modules that stay on whatever the answers say (design spec §"Needs → modules + * → first result"): the measurement engine is CORE and has no key, and these + * three are the surfaces a record is unusable without — the narrative layer, + * the badges, and the document vault. + */ +export const ONBOARDING_ALWAYS_ON_MODULES: readonly ModuleKey[] = [ + "insights", + "achievements", + "inboundDocuments", +]; + +/** + * The answers the module derivation actually reads. The flow stores more than + * this (the sources, the units); nothing else changes which modules are on. + * + * The literal unions are written here rather than imported so this file keeps + * depending on nothing. `src/lib/onboarding/needs.ts` declares the same + * vocabulary with `satisfies`, and the derivation call site is typed against + * this interface — a value that drifts apart fails to compile there. + */ +export interface OnboardingModuleNeeds { + recordTarget: "me" | "someone-else" | "both"; + areas: readonly OnboardingAreaKey[]; + medication: "yes" | "no" | "sometimes"; + visit: "within-a-month" | "later" | "no"; +} + +/** A toggleable module whose state `modulePreferencesJson` really owns. */ +export type OwnedModuleKey = Exclude; + +/** Toggleable modules minus the two delegated keys, in registry order. */ +export const OWNED_MODULE_KEYS: readonly OwnedModuleKey[] = MODULE_KEYS.filter( + (key): key is OwnedModuleKey => + MODULE_REGISTRY[key].delegatesTo === undefined, +); + +export interface DerivedModuleDefaults { + /** + * An explicit value for every directly-owned module: `true` for what the + * answers named, `false` for the rest. Explicit `false` rather than absence + * is the "ordering versus removal" decision from the design spec — the + * stored map is a disabled allowlist, so absence would mean ON. + */ + preferences: Record; + /** + * Whether the answers asked for cycle tracking. Only ever `true` here: the + * gate derives an unanswered cycle from recorded sex, and an area the person + * did not tick is not a statement that they want it switched off. The caller + * writes `CycleProfile.cycleTrackingEnabled` when this is true and leaves + * the column alone when it is not. + */ + cycleTracking: boolean; +} + +/** + * Turn the needs answers into the module map the record starts from. + * + * Pure and total: every directly-owned key gets a value, so the caller never + * has to reason about which keys the answers happened to mention. It says + * nothing about what the record already holds — merging is + * {@link mergeDerivedModulePreferences}, deliberately a second step, because + * "what the answers imply" and "what this person already decided by hand" are + * different questions and only one of them may be overwritten. + */ +export function deriveOnboardingModuleDefaults( + needs: OnboardingModuleNeeds, +): DerivedModuleDefaults { + const on = new Set(ONBOARDING_ALWAYS_ON_MODULES); + + for (const area of needs.areas) { + for (const key of ONBOARDING_AREA_MODULES[area] ?? []) on.add(key); + } + if (needs.medication === "yes" || needs.medication === "sometimes") { + on.add("medications"); + } + // A visit that is coming at all is what the doctor report is for; "no" is + // the only answer that leaves the export surface off. + if (needs.visit === "within-a-month" || needs.visit === "later") { + on.add("doctorReport"); + } + // A record somebody else runs is usually a child's or a parent's, and the + // immunization log is the part of it that matters most there. + if (needs.recordTarget === "someone-else" || needs.recordTarget === "both") { + on.add("vaccinations"); + } + + const preferences = {} as Record; + for (const key of OWNED_MODULE_KEYS) preferences[key] = on.has(key); + + return { preferences, cycleTracking: on.has("cycle") }; +} + +/** + * Merge a derived map into the one the record already holds, without ever + * retracting a decision the person made by hand or a domain they already use. + * + * The rule is one-directional: a derived `true` always lands, a derived + * `false` lands only where the stored map does not already carry an explicit + * `true`. Someone who switched the MCP endpoint on, then ran the flow again + * and did not tick anything that implies it, keeps it on — the questionnaire + * orders a record, it does not undo it. + * + * `holdsData` is the second thing a derived `false` cannot cross, and it is + * about a record with content rather than a record with an opinion. An + * established account upgrading into the setup flow has, for most modules, no + * stored preference at all — they were default-on and nobody ever touched the + * toggle — so without this the first confirm would switch off the navigation + * entry, the dashboard widget and the settings entry for every domain the + * person did not tick, including the ones holding years of rows. The design + * spec's "nothing is lost by a choice" is written about a NEW record; for one + * that already has content the honest answer is to write nothing and leave the + * stored value exactly where it was. Absence means ON, so leaving the key + * alone is what keeps the surface. + * + * Keys the stored map carries that are not module keys are dropped; the caller + * is expected to have normalised it (`normalisePrefs`), and this is the second + * belt on the same trousers rather than a place to be clever. + */ +export function mergeDerivedModulePreferences( + existing: Readonly>, + derived: Readonly>, + holdsData: ReadonlySet, +): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(existing)) { + if (isModuleKey(key)) merged[key] = value; + } + for (const [key, value] of Object.entries(derived)) { + if (value) { + merged[key] = true; + continue; + } + if (merged[key] === true) continue; // switched on by hand — never retracted + if (holdsData.has(key)) continue; // the domain holds rows — never retracted + merged[key] = false; + } + return merged; +} diff --git a/src/lib/monitoring/__tests__/glitchtip-delivery.test.ts b/src/lib/monitoring/__tests__/glitchtip-delivery.test.ts new file mode 100644 index 000000000..da8e799f4 --- /dev/null +++ b/src/lib/monitoring/__tests__/glitchtip-delivery.test.ts @@ -0,0 +1,144 @@ +/** + * The write end of the delivery ledger. + * + * The badge is only as honest as what gets recorded, and two things have to + * hold: a failure must never carry a remote host's response body into this + * database, and a send that could not be recorded must not become a second + * error on a path that is already handling one. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const updateMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { appSettings: { update: (...a: unknown[]) => updateMock(...a) } }, +})); + +import { + classifyGlitchtipFailure, + GLITCHTIP_DELIVERY_WINDOW_HOURS, + recordGlitchtipDelivery, + summariseGlitchtipDelivery, +} from "../glitchtip-delivery"; + +const NOW = new Date("2026-09-10T09:00:00.000Z"); +const HOUR = 3_600_000; + +beforeEach(() => { + vi.clearAllMocks(); + updateMock.mockResolvedValue({}); +}); + +describe("classifyGlitchtipFailure", () => { + it("keeps the classification short and free of the remote body", () => { + expect( + classifyGlitchtipFailure({ + ok: false, + status: 403, + details: "forbidden, and here is our whole error page", + }), + ).toBe("http_403"); + expect( + classifyGlitchtipFailure({ ok: false, details: "invalid_dsn" }), + ).toBe("invalid_dsn"); + expect(classifyGlitchtipFailure({ ok: false })).toBe("network"); + }); +}); + +describe("recordGlitchtipDelivery", () => { + it("stamps a success and leaves the failure fields alone", async () => { + await recordGlitchtipDelivery({ ok: true, status: 200 }); + + const data = updateMock.mock.calls[0]?.[0]?.data as Record; + expect(data.glitchtipLastOkAt).toBeInstanceOf(Date); + expect(data).not.toHaveProperty("glitchtipLastFailureAt"); + }); + + it("records a failure by classification, never by response body", async () => { + await recordGlitchtipDelivery({ + ok: false, + status: 401, + details: "invalid public key: abcdef0123456789", + }); + + const data = updateMock.mock.calls[0]?.[0]?.data as Record; + expect(data.glitchtipLastFailureReason).toBe("http_401"); + expect(JSON.stringify(data)).not.toContain("abcdef0123456789"); + expect(data).not.toHaveProperty("glitchtipLastOkAt"); + }); + + it("swallows a write failure rather than replacing the original error", async () => { + updateMock.mockRejectedValue(new Error("no singleton row yet")); + await expect( + recordGlitchtipDelivery({ ok: false, status: 500 }), + ).resolves.toBeUndefined(); + }); +}); + +describe("summariseGlitchtipDelivery", () => { + it("claims delivery only for a success inside the window", () => { + const inside = new Date( + NOW.getTime() - (GLITCHTIP_DELIVERY_WINDOW_HOURS - 1) * HOUR, + ); + const outside = new Date( + NOW.getTime() - (GLITCHTIP_DELIVERY_WINDOW_HOURS + 1) * HOUR, + ); + + expect( + summariseGlitchtipDelivery( + { lastOkAt: inside, lastFailureAt: null, lastFailureReason: null }, + NOW, + ).reportsDelivering, + ).toBe(true); + + const aged = summariseGlitchtipDelivery( + { lastOkAt: outside, lastFailureAt: null, lastFailureReason: null }, + NOW, + ); + // Still true that something once got through, no longer true that the + // target is working — two different claims, and only one is a badge. + expect(aged.reportsDelivering).toBe(false); + expect(aged.everDelivered).toBe(true); + }); + + it("says nothing has been proven when the ledger is empty", () => { + expect( + summariseGlitchtipDelivery( + { lastOkAt: null, lastFailureAt: null, lastFailureReason: null }, + NOW, + ), + ).toEqual({ + reportsDelivering: false, + everDelivered: false, + lastFailureReason: null, + windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS, + }); + }); + + it("drops a failure that a later success has superseded", () => { + const summary = summariseGlitchtipDelivery( + { + lastOkAt: new Date(NOW.getTime() - HOUR), + lastFailureAt: new Date(NOW.getTime() - 5 * HOUR), + lastFailureReason: "http_403", + }, + NOW, + ); + // The target refused a report and then accepted one. Showing the refusal + // would report a problem that has since been fixed. + expect(summary.lastFailureReason).toBeNull(); + expect(summary.reportsDelivering).toBe(true); + }); + + it("keeps a failure that is newer than the last success", () => { + expect( + summariseGlitchtipDelivery( + { + lastOkAt: new Date(NOW.getTime() - 5 * HOUR), + lastFailureAt: new Date(NOW.getTime() - HOUR), + lastFailureReason: "http_403", + }, + NOW, + ).lastFailureReason, + ).toBe("http_403"); + }); +}); diff --git a/src/lib/monitoring/glitchtip-delivery.ts b/src/lib/monitoring/glitchtip-delivery.ts new file mode 100644 index 000000000..c18ea0020 --- /dev/null +++ b/src/lib/monitoring/glitchtip-delivery.ts @@ -0,0 +1,112 @@ +/** + * What actually happened to the last error report this host tried to send. + * + * The admin card used to derive "configured" from the enabled flag plus a DSN + * that parses. Those two say a target was typed; they say nothing about the + * target accepting anything. A wrong public key, a wrong project id, a host + * that stopped answering — every one of them parses, and every one of them + * would have shown a green badge and a line naming the host, which is the + * false reassurance an operator only discovers when they go looking for a + * crash that was never reported. + * + * So the outcome is recorded where the DSN already lives, next to the flag it + * qualifies, and the badge reads it. `sendGlitchtipEvent` is the one writer — + * every caller in the tree goes through it, so no send path can skip the + * ledger by construction. + */ +import { prisma } from "@/lib/db"; +import type { GlitchtipDeliveryResult } from "@/lib/monitoring/glitchtip"; + +/** + * How long a successful send stands for. A self-hoster's instance can go days + * without an unhandled error, and a badge that expired overnight would train + * the operator to ignore it; a fortnight is long enough that only a target + * that has genuinely stopped working falls out of it, and the test button + * refreshes it on demand. + */ +export const GLITCHTIP_DELIVERY_WINDOW_HOURS = 14 * 24; + +export interface GlitchtipDeliveryState { + lastOkAt: Date | null; + lastFailureAt: Date | null; + lastFailureReason: string | null; +} + +/** + * A short, safe classification of a failure. Never the response body: that is + * text from a remote host, and an incident target's error page has no business + * being rendered on the admin page or stored in this database. + */ +export function classifyGlitchtipFailure( + result: GlitchtipDeliveryResult, +): string { + if (result.details === "invalid_dsn") return "invalid_dsn"; + if (typeof result.status === "number") return `http_${result.status}`; + return "network"; +} + +/** + * Record the outcome of one send. Best-effort by design — a report that could + * not be filed must not become a second error, and the singleton row may not + * exist yet on a host that has never opened the admin page. + */ +export async function recordGlitchtipDelivery( + result: GlitchtipDeliveryResult, +): Promise { + const now = new Date(); + try { + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: result.ok + ? { glitchtipLastOkAt: now } + : { + glitchtipLastFailureAt: now, + glitchtipLastFailureReason: classifyGlitchtipFailure(result), + }, + }); + } catch { + // Nothing to do and nothing to report to: the caller is already on an + // error path, and throwing here would replace the original failure with + // this one. + } +} + +/** + * The judgement, resolved server-side. + * + * The client gets the verdict, not the timestamps: whether a report is inside + * the window is a question about a clock, and the process that holds the + * ledger is the one that should answer it. A failure older than the last + * success is not surfaced at all — it has been superseded by a report that + * got through, and showing it would be reporting a fixed problem. + */ +export interface GlitchtipDeliverySummary { + /** A report left this host successfully inside the window. */ + reportsDelivering: boolean; + /** A report has left this host successfully at some point. */ + everDelivered: boolean; + /** Why the last attempt failed, when that failure is the newest fact. */ + lastFailureReason: string | null; + /** How long a success stands for, so the card can say it. */ + windowHours: number; +} + +export function summariseGlitchtipDelivery( + state: GlitchtipDeliveryState, + now: Date, +): GlitchtipDeliverySummary { + const windowMs = GLITCHTIP_DELIVERY_WINDOW_HOURS * 3_600_000; + const okAge = + state.lastOkAt === null ? null : now.getTime() - state.lastOkAt.getTime(); + const failureIsNewest = + state.lastFailureAt !== null && + (state.lastOkAt === null || + state.lastFailureAt.getTime() > state.lastOkAt.getTime()); + + return { + reportsDelivering: okAge !== null && okAge >= 0 && okAge <= windowMs, + everDelivered: state.lastOkAt !== null, + lastFailureReason: failureIsNewest ? state.lastFailureReason : null, + windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS, + }; +} diff --git a/src/lib/monitoring/glitchtip.ts b/src/lib/monitoring/glitchtip.ts index bfcbb91ac..779d45daa 100644 --- a/src/lib/monitoring/glitchtip.ts +++ b/src/lib/monitoring/glitchtip.ts @@ -214,13 +214,34 @@ async function tryStoreHeader( }; } +/** + * Record what became of one send, in the one place every caller passes + * through. Dynamically imported so this module stays free of a database + * dependency at import time, and awaited so a test can observe it; the + * recorder itself never throws. + */ +async function record(result: GlitchtipDeliveryResult): Promise { + try { + const { recordGlitchtipDelivery } = + await import("@/lib/monitoring/glitchtip-delivery"); + await recordGlitchtipDelivery(result); + } catch { + // The report is the point; its bookkeeping is not worth an exception. + } +} + export async function sendGlitchtipEvent(params: { dsn: string; input: GlitchtipEventPayloadInput; }): Promise { const config = parseGlitchtipDsn(params.dsn); if (!config) { - return { ok: false, details: "invalid_dsn" }; + const invalid: GlitchtipDeliveryResult = { + ok: false, + details: "invalid_dsn", + }; + await record(invalid); + return invalid; } const eventId = createEventId(); @@ -228,15 +249,24 @@ export async function sendGlitchtipEvent(params: { const envelopeBody = buildEnvelopeBody(config, eventId, payload); const envelopeResult = await tryEnvelope(config, envelopeBody); - if (envelopeResult.ok) return envelopeResult; + if (envelopeResult.ok) { + await record(envelopeResult); + return envelopeResult; + } const storeQueryResult = await tryStoreQuery(config, payload); - if (storeQueryResult.ok) return storeQueryResult; + if (storeQueryResult.ok) { + await record(storeQueryResult); + return storeQueryResult; + } const storeHeaderResult = await tryStoreHeader(config, payload); - if (storeHeaderResult.ok) return storeHeaderResult; + if (storeHeaderResult.ok) { + await record(storeHeaderResult); + return storeHeaderResult; + } - return { + const failure: GlitchtipDeliveryResult = { ok: false, method: storeHeaderResult.method, status: @@ -248,4 +278,6 @@ export async function sendGlitchtipEvent(params: { storeQueryResult.details ?? envelopeResult.details, }; + await record(failure); + return failure; } diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index dc734d5d3..1a807a769 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -2,13 +2,38 @@ import { describe, expect, it } from "vitest"; import { buildChecklist, + checklistOrderFromNeeds, checklistProgress, + CHECKLIST_ITEM_IDS, isProfileComplete, shouldShowChecklist, trendHintFor, visibleChecklist, type ChecklistItemId, } from "../checklist"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + type OnboardingNeeds, + type OnboardingStateDto, +} from "../needs"; + +/** A setup state the flow really produced: Q1 answered, confirm reached. */ +function onboardingState( + overrides: Partial = {}, + needs: Partial = {}, +): OnboardingStateDto { + return { + steps: defaultOnboardingSteps().map((step) => ({ + ...step, + status: "done" as const, + })), + needs: { ...emptyOnboardingNeeds(), recordTarget: "me", ...needs }, + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: null, + ...overrides, + }; +} const completeProfile = { heightCm: 175, @@ -16,7 +41,9 @@ const completeProfile = { gender: "MALE", }; -function inputs(overrides: Partial[0]> = {}) { +function inputs( + overrides: Partial[0]> = {}, +): Parameters[0] { return { profile: completeProfile, measurementCount: 0, @@ -25,6 +52,7 @@ function inputs(overrides: Partial[0]> = {}) { notificationsConfigured: false, insightsConfigured: false, dismissedIds: new Set(), + onboarding: null, ...overrides, }; } @@ -145,6 +173,7 @@ describe("visibleChecklist + checklistProgress", () => { it("returns 0% when nothing done", () => { const items = buildChecklist({ profile: { heightCm: null, dateOfBirth: null, gender: null }, + onboarding: null, measurementCount: 0, medicationCount: 0, dataSourceConnected: false, @@ -243,3 +272,224 @@ describe("trendHintFor", () => { expect(trendHintFor(99)).toEqual({ kind: "hidden" }); }); }); + +describe("checklistOrderFromNeeds", () => { + function order(needs: Partial) { + return checklistOrderFromNeeds({ ...emptyOnboardingNeeds(), ...needs }); + } + + it("keeps profile first whatever the answers say", () => { + for (const needs of [ + {}, + { medication: "yes" as const }, + { sources: ["withings" as const] }, + { areas: ["sleep" as const] }, + ]) { + expect(order(needs)[0]).toBe("profile"); + } + }); + + it("puts medication second for somebody who takes one on a schedule", () => { + expect(order({ medication: "yes" })[1]).toBe("medication"); + expect(order({ medication: "sometimes" })[1]).toBe("medication"); + expect(order({ medication: "no" })[1]).not.toBe("medication"); + }); + + it("promotes the data source only for a source that is a connection", () => { + expect(order({ sources: ["withings"] }).indexOf("dataSource")).toBe(1); + // Typing readings in and uploading a file are not connections, so they + // say nothing about that row. + expect(order({ sources: ["manual", "file"] }).indexOf("dataSource")).toBe( + 3, + ); + }); + + it("promotes the reading row once an area was chosen", () => { + expect(order({ areas: ["blood-pressure"] })[1]).toBe("measurement"); + }); + + it("orders medication ahead of the data source when both are answered", () => { + const ids = order({ medication: "yes", sources: ["oura"] }); + expect(ids.indexOf("medication")).toBeLessThan(ids.indexOf("dataSource")); + }); + + it("never drops or duplicates a row, for any answer combination", () => { + const combinations: Partial[] = [ + {}, + { medication: "yes" }, + { sources: ["manual"] }, + { sources: ["fitbit"] }, + { areas: ["mood", "labs"] }, + { medication: "sometimes", sources: ["nightscout"], areas: ["glucose"] }, + ]; + for (const needs of combinations) { + const ids = order(needs); + expect([...ids].sort()).toEqual([...CHECKLIST_ITEM_IDS].sort()); + } + }); +}); + +describe("buildChecklist, ordered by the setup answers", () => { + it("keeps the fixed order for a record that never entered the flow", () => { + expect(buildChecklist(inputs()).map((i) => i.id)).toEqual([ + ...CHECKLIST_ITEM_IDS, + ]); + }); + + it("keeps the fixed order while the answers are still being given", () => { + const items = buildChecklist( + inputs({ + onboarding: onboardingState( + { completedAt: null }, + { medication: "yes" }, + ), + }), + ); + // Re-ordering the dashboard under somebody mid-question would be movement + // they did not ask for. + expect(items.map((i) => i.id)).toEqual([...CHECKLIST_ITEM_IDS]); + }); + + it("orders the rows from the answers once the flow is confirmed", () => { + const items = buildChecklist( + inputs({ + onboarding: onboardingState( + {}, + { medication: "yes", sources: ["withings"] }, + ), + }), + ); + expect(items.map((i) => i.id).slice(0, 3)).toEqual([ + "profile", + "medication", + "dataSource", + ]); + expect([...items.map((i) => i.id)].sort()).toEqual( + [...CHECKLIST_ITEM_IDS].sort(), + ); + }); + + it("carries each row's own state through the reordering", () => { + const items = buildChecklist( + inputs({ + medicationCount: 2, + dismissedIds: new Set(["insights"]), + onboarding: onboardingState({}, { medication: "yes" }), + }), + ); + expect(items.find((i) => i.id === "medication")?.done).toBe(true); + expect(items.find((i) => i.id === "insights")?.dismissed).toBe(true); + }); +}); + +describe("shouldShowChecklist and the setup flow", () => { + const doneItems = () => + buildChecklist( + inputs({ + measurementCount: 3, + medicationCount: 1, + dataSourceConnected: true, + notificationsConfigured: true, + insightsConfigured: true, + }), + ); + + it("no longer disappears at five readings for a record that ran the flow", () => { + const items = buildChecklist(inputs({ measurementCount: 10 })); + expect( + shouldShowChecklist({ + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 10, + dismissedAll: false, + items, + onboarding: onboardingState(), + }), + ).toBe(true); + }); + + it("goes when its rows are done, even for a record that ran the flow", () => { + expect( + shouldShowChecklist({ + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 10, + dismissedAll: false, + items: doneItems(), + onboarding: onboardingState(), + }), + ).toBe(false); + }); + + it("goes when the person hides the whole list", () => { + expect( + shouldShowChecklist({ + onboardingCompletedAt: null, + measurementCount: 0, + dismissedAll: true, + items: buildChecklist(inputs()), + onboarding: onboardingState(), + }), + ).toBe(false); + }); + + it("stays while the flow is unfinished, even with every row done", () => { + // The one task the flow offered is not one of the six rows, so "every row + // done" is not the same statement as "the setup finished". + expect( + shouldShowChecklist({ + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 10, + dismissedAll: false, + items: doneItems(), + onboarding: onboardingState({ + firstResult: { + task: "log-reading", + target: null, + completedAt: null, + }, + }), + }), + ).toBe(true); + }); + + it("does not stay pinned by a first-result task the person passed", () => { + const state = onboardingState({ + firstResult: { task: "log-reading", target: null, completedAt: null }, + }); + expect( + shouldShowChecklist({ + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 10, + dismissedAll: false, + items: doneItems(), + onboarding: { + ...state, + steps: state.steps.map((step) => + step.id === "first-result" + ? { ...step, status: "skipped" as const } + : step, + ), + }, + }), + ).toBe(false); + }); + + it("leaves an account that predates the flow on the rule it already had", () => { + // The payload publishes the field for every record, so "nine pending + // steps" must not read as an unfinished setup. + const untouched: OnboardingStateDto = { + steps: defaultOnboardingSteps(), + needs: emptyOnboardingNeeds(), + completedAt: null, + firstResult: null, + }; + expect( + shouldShowChecklist({ + onboardingCompletedAt: "2026-01-01T00:00:00Z", + measurementCount: 10, + dismissedAll: false, + items: buildChecklist(inputs({ measurementCount: 10 })), + onboarding: untouched, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/onboarding/__tests__/needs.test.ts b/src/lib/onboarding/__tests__/needs.test.ts new file mode 100644 index 000000000..d3ff3e666 --- /dev/null +++ b/src/lib/onboarding/__tests__/needs.test.ts @@ -0,0 +1,514 @@ +/** + * v1.39 (C1) — the stored onboarding state: reading it back, and applying one + * answered step to it. + * + * The two claims worth pinning here are the ones the route promises on the + * wire. `PATCH /api/onboarding/answers` says it is idempotent, and the reason + * it can say so is that the whole computation is this pure function — so the + * property is checkable without a database. The readers say they are + * fail-soft, and the reason that matters is that they sit on the account + * payload that rides every app boot: a column written by a release that named + * an area this one retired must not 500 the shell. + */ +import { describe, expect, it } from "vitest"; + +import { applyOnboardingAnswer } from "../needs-apply"; +import { + accountHoldsBothUnits, + defaultOnboardingSteps, + everyOnboardingQuestionSettled, + hasEnteredOnboardingFlow, + ONBOARDING_QUESTION_STEP_IDS, + emptyOnboardingNeeds, + isOnboardingSettled, + ONBOARDING_STEP_IDS, + parseOnboardingFirstResult, + parseOnboardingNeeds, + parseOnboardingSteps, + readHeldUnitPreferences, + resolveOnboardingSteps, + type OnboardingStateDto, +} from "../needs"; + +function blank() { + return { + needs: emptyOnboardingNeeds(), + steps: defaultOnboardingSteps(), + firstResult: null, + }; +} + +const AT = new Date("2026-09-10T08:00:00.000Z"); + +describe("parseOnboardingSteps", () => { + it("returns the full ordered nine whatever the column holds", () => { + expect(parseOnboardingSteps(null).map((s) => s.id)).toEqual([ + ...ONBOARDING_STEP_IDS, + ]); + expect( + parseOnboardingSteps(null).every((s) => s.status === "pending"), + ).toBe(true); + }); + + it("keeps a stored status and ignores an id or status it does not know", () => { + const parsed = parseOnboardingSteps([ + { id: "areas", status: "done" }, + { id: "visit", status: "skipped" }, + { id: "no-such-step", status: "done" }, + { id: "who", status: "half-done" }, + ]); + expect(parsed.find((s) => s.id === "areas")?.status).toBe("done"); + expect(parsed.find((s) => s.id === "visit")?.status).toBe("skipped"); + expect(parsed.find((s) => s.id === "who")?.status).toBe("pending"); + expect(parsed.map((s) => s.id)).toEqual([...ONBOARDING_STEP_IDS]); + }); +}); + +describe("parseOnboardingNeeds", () => { + it("reads nothing out of a corrupt column rather than throwing", () => { + expect(parseOnboardingNeeds("not an object")).toEqual( + emptyOnboardingNeeds(), + ); + expect(parseOnboardingNeeds({ areas: "sleep" }).areas).toEqual([]); + }); + + it("drops unknown answers and de-duplicates the lists", () => { + const parsed = parseOnboardingNeeds({ + recordTarget: "nobody", + areas: ["sleep", "sleep", "astrology"], + medication: "yes", + sources: ["withings", "carrier-pigeon"], + visit: "later", + units: { glucoseUnit: "mmol/L", unitPreference: "furlongs" }, + }); + expect(parsed.recordTarget).toBeNull(); + expect(parsed.areas).toEqual(["sleep"]); + expect(parsed.medication).toBe("yes"); + expect(parsed.sources).toEqual(["withings"]); + expect(parsed.visit).toBe("later"); + expect(parsed.units).toEqual({ + glucoseUnit: "mmol/L", + unitPreference: null, + }); + }); +}); + +describe("parseOnboardingFirstResult", () => { + it("is null without a known task", () => { + expect(parseOnboardingFirstResult(null)).toBeNull(); + expect(parseOnboardingFirstResult({ task: "invent-a-cure" })).toBeNull(); + }); + + it("reads the task, its target and its stamp", () => { + expect( + parseOnboardingFirstResult({ + task: "connect-source", + target: "withings", + completedAt: "2026-09-10T08:00:00.000Z", + }), + ).toEqual({ + task: "connect-source", + target: "withings", + completedAt: "2026-09-10T08:00:00.000Z", + }); + }); +}); + +describe("applyOnboardingAnswer", () => { + it("marks the step done and records the answer", () => { + const next = applyOnboardingAnswer( + blank(), + { step: "areas", areas: ["sleep", "labs"] }, + AT, + ); + expect(next.needs.areas).toEqual(["sleep", "labs"]); + expect(next.steps.find((s) => s.id === "areas")?.status).toBe("done"); + expect(next.steps.find((s) => s.id === "who")?.status).toBe("pending"); + }); + + it("is idempotent — the same body twice is the same state", () => { + const once = applyOnboardingAnswer( + blank(), + { step: "medication", medication: "yes" }, + AT, + ); + const twice = applyOnboardingAnswer( + once, + { step: "medication", medication: "yes" }, + new Date("2026-09-11T09:00:00.000Z"), + ); + expect(twice).toEqual(once); + }); + + it("keeps the first-result stamp across a replay rather than re-taking it", () => { + const first = applyOnboardingAnswer( + blank(), + { + step: "first-result", + firstResult: { task: "log-reading", target: "sleep", completed: true }, + }, + AT, + ); + expect(first.firstResult?.completedAt).toBe(AT.toISOString()); + + const replay = applyOnboardingAnswer( + first, + { + step: "first-result", + firstResult: { task: "log-reading", target: "sleep", completed: true }, + }, + new Date("2026-09-12T10:00:00.000Z"), + ); + expect(replay.firstResult?.completedAt).toBe(AT.toISOString()); + }); + + it("re-offers a DIFFERENT task without inheriting the old stamp", () => { + const first = applyOnboardingAnswer( + blank(), + { + step: "first-result", + firstResult: { task: "log-reading", completed: true }, + }, + AT, + ); + const second = applyOnboardingAnswer( + first, + { + step: "first-result", + firstResult: { task: "add-medication" }, + }, + AT, + ); + expect(second.firstResult).toEqual({ + task: "add-medication", + target: null, + completedAt: null, + }); + }); + + it("records a skip without erasing an answer already given", () => { + const answered = applyOnboardingAnswer( + blank(), + { step: "visit", visit: "within-a-month" }, + AT, + ); + const skipped = applyOnboardingAnswer( + answered, + { step: "visit", status: "skipped" }, + AT, + ); + expect(skipped.steps.find((s) => s.id === "visit")?.status).toBe("skipped"); + expect(skipped.needs.visit).toBe("within-a-month"); + }); + + it("sets one unit without clearing the other", () => { + const both = applyOnboardingAnswer( + blank(), + { + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }, + AT, + ); + const one = applyOnboardingAnswer( + both, + { step: "units", units: { unitPreference: "imperial" } }, + AT, + ); + expect(one.needs.units).toEqual({ + glucoseUnit: "mmol/L", + unitPreference: "imperial", + }); + }); +}); + +describe("applyOnboardingAnswer and the two list answers", () => { + it("stores the list de-duplicated, so the column holds what the reader returns", () => { + const next = applyOnboardingAnswer( + blank(), + { + step: "areas", + areas: ["sleep", "sleep", "labs"], + }, + AT, + ); + expect(next.needs.areas).toEqual(["sleep", "labs"]); + + const sources = applyOnboardingAnswer( + blank(), + { step: "sources", sources: ["oura", "oura", "manual"] }, + AT, + ); + expect(sources.needs.sources).toEqual(["oura", "manual"]); + }); +}); + +describe("isOnboardingSettled", () => { + const settled: OnboardingStateDto = { + steps: ONBOARDING_STEP_IDS.map((id) => ({ id, status: "done" as const })), + // Q1 answered: the mark of a record that actually entered the flow. + needs: { ...emptyOnboardingNeeds(), recordTarget: "me" }, + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: { + task: "log-reading", + target: null, + completedAt: "2026-09-10T08:01:00.000Z", + }, + }; + + it("settles a record that never entered the flow", () => { + expect(isOnboardingSettled(null)).toBe(true); + }); + + it("settles a finished flow", () => { + expect(isOnboardingSettled(settled)).toBe(true); + }); + + it("does not settle while a step is pending", () => { + expect( + isOnboardingSettled({ + ...settled, + steps: settled.steps.map((s) => + s.id === "sources" ? { ...s, status: "pending" as const } : s, + ), + }), + ).toBe(false); + }); + + it("does not settle without the flow's own completion stamp", () => { + expect(isOnboardingSettled({ ...settled, completedAt: null })).toBe(false); + }); + + it("does not settle while the offered task has produced nothing", () => { + expect( + isOnboardingSettled({ + ...settled, + firstResult: { task: "log-reading", target: null, completedAt: null }, + }), + ).toBe(false); + }); + + it("settles when the offered task was deliberately passed", () => { + // The task was offered — `firstResult` names it, with no completion — and + // the person passed. Asking only "did it produce anything" answers no + // forever, which pins the checklist open with no way out but dismissing + // rows one by one. + expect( + isOnboardingSettled({ + ...settled, + steps: settled.steps.map((s) => + s.id === "first-result" ? { ...s, status: "skipped" as const } : s, + ), + firstResult: { task: "log-reading", target: null, completedAt: null }, + }), + ).toBe(true); + }); + + it("settles a record that never entered the flow, however it is published", () => { + // The payload carries the field for EVERY record. Without this arm, an + // account that predates the flow reads as permanently unfinished. + expect( + isOnboardingSettled({ + steps: defaultOnboardingSteps(), + needs: emptyOnboardingNeeds(), + completedAt: null, + firstResult: null, + }), + ).toBe(true); + }); +}); + +describe("hasEnteredOnboardingFlow", () => { + const blankState: OnboardingStateDto = { + steps: defaultOnboardingSteps(), + needs: emptyOnboardingNeeds(), + completedAt: null, + firstResult: null, + }; + + it("is false for a record the flow never touched", () => { + expect(hasEnteredOnboardingFlow(null)).toBe(false); + expect(hasEnteredOnboardingFlow(blankState)).toBe(false); + }); + + it("is true once the one required question is answered", () => { + expect( + hasEnteredOnboardingFlow({ + ...blankState, + needs: { ...emptyOnboardingNeeds(), recordTarget: "someone-else" }, + }), + ).toBe(true); + }); + + it("is true for a flow that was completed", () => { + expect( + hasEnteredOnboardingFlow({ + ...blankState, + completedAt: "2026-09-10T08:00:00.000Z", + }), + ).toBe(true); + }); + + it("is not fooled by the units step resolving from the account's columns", () => { + // `resolveOnboardingSteps` marks `units` done for an account that already + // holds both preferences. That record has still never seen a question. + expect( + hasEnteredOnboardingFlow({ + ...blankState, + steps: resolveOnboardingSteps(defaultOnboardingSteps(), { + glucoseUnit: "mg/dL", + unitPreference: "metric", + }), + }), + ).toBe(false); + }); +}); + +describe("the unit preferences the account already holds", () => { + it("reads the two raw columns into the wire vocabulary", () => { + expect( + readHeldUnitPreferences({ + glucoseUnit: "mmol/L", + unitPreference: "imperial", + }), + ).toEqual({ glucoseUnit: "mmol/L", unitPreference: "imperial" }); + }); + + it("answers null for a column that was never chosen", () => { + const held = readHeldUnitPreferences({ + glucoseUnit: null, + unitPreference: null, + }); + expect(held).toEqual({ glucoseUnit: null, unitPreference: null }); + expect(accountHoldsBothUnits(held)).toBe(false); + }); + + it("does not mistake a value it does not know for a chosen one", () => { + expect( + readHeldUnitPreferences({ + glucoseUnit: "stones", + unitPreference: "metrick", + }), + ).toEqual({ glucoseUnit: null, unitPreference: null }); + }); + + it("needs both preferences before the question answers itself", () => { + expect( + accountHoldsBothUnits({ glucoseUnit: "mg/dL", unitPreference: null }), + ).toBe(false); + expect( + accountHoldsBothUnits({ glucoseUnit: null, unitPreference: "metric" }), + ).toBe(false); + expect( + accountHoldsBothUnits({ + glucoseUnit: "mg/dL", + unitPreference: "metric", + }), + ).toBe(true); + }); +}); + +describe("resolveOnboardingSteps", () => { + const held = { + glucoseUnit: "mg/dL", + unitPreference: "metric", + } as const; + + it("marks units done when the account already holds both preferences", () => { + const resolved = resolveOnboardingSteps(defaultOnboardingSteps(), held); + expect(resolved.find((s) => s.id === "units")?.status).toBe("done"); + }); + + it("leaves units pending while either preference is unset", () => { + for (const partial of [ + { glucoseUnit: "mg/dL", unitPreference: null }, + { glucoseUnit: null, unitPreference: "metric" }, + { glucoseUnit: null, unitPreference: null }, + ] as const) { + const resolved = resolveOnboardingSteps( + defaultOnboardingSteps(), + partial, + ); + expect(resolved.find((s) => s.id === "units")?.status).toBe("pending"); + } + }); + + it("never overwrites an answer that was actually given", () => { + const skipped = defaultOnboardingSteps().map((s) => + s.id === "units" ? { ...s, status: "skipped" as const } : s, + ); + expect( + resolveOnboardingSteps(skipped, held).find((s) => s.id === "units") + ?.status, + ).toBe("skipped"); + }); + + it("touches no other step", () => { + const resolved = resolveOnboardingSteps(defaultOnboardingSteps(), held); + expect( + resolved.filter((s) => s.id !== "units").map((s) => s.status), + ).toEqual( + defaultOnboardingSteps() + .filter((s) => s.id !== "units") + .map((s) => s.status), + ); + }); +}); + +describe("everyOnboardingQuestionSettled", () => { + function withStatuses( + status: "pending" | "done" | "skipped", + overrides: Partial> = {}, + ) { + return defaultOnboardingSteps().map((step) => ({ + ...step, + status: overrides[step.id] ?? status, + })); + } + + it("names the six questions and stops before the confirm screen", () => { + expect([...ONBOARDING_QUESTION_STEP_IDS]).toEqual([ + "who", + "areas", + "medication", + "sources", + "visit", + "units", + ]); + }); + + it("refuses a flow that only answered the first question", () => { + expect( + everyOnboardingQuestionSettled(withStatuses("pending", { who: "done" })), + ).toBe(false); + }); + + it("refuses a flow with any one question still waiting", () => { + for (const id of ONBOARDING_QUESTION_STEP_IDS) { + expect( + everyOnboardingQuestionSettled( + withStatuses("done", { [id]: "pending" }), + ), + `\`${id}\` still pending should not read as a finished questionnaire`, + ).toBe(false); + } + }); + + it("accepts a deliberate pass as an answer", () => { + expect( + everyOnboardingQuestionSettled(withStatuses("skipped", { who: "done" })), + ).toBe(true); + }); + + it("ignores what happens after the confirm screen", () => { + expect( + everyOnboardingQuestionSettled( + withStatuses("done", { + confirm: "pending", + "first-result": "pending", + done: "pending", + }), + ), + ).toBe(true); + }); +}); diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index a4a828b8c..b67450e7d 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -6,6 +6,13 @@ * status + dismissed-ids set) and renders against the result. */ +import { + hasEnteredOnboardingFlow, + isOnboardingSettled, + type OnboardingNeeds, + type OnboardingStateDto, +} from "./needs"; + export const CHECKLIST_ITEM_IDS = [ "profile", "measurement", @@ -64,6 +71,55 @@ export interface ChecklistInputs { insightsConfigured: boolean; /** Dismissed item ids (per-item localStorage state). */ dismissedIds: ReadonlySet; + /** + * v1.39 (C1) — the needs-based setup state from `GET /api/auth/me`, or null + * for a record that never entered the flow. + * + * Required rather than optional: the ordering below is the whole reason the + * answers are published, and a caller that stops passing them should fail to + * compile rather than quietly fall back to the fixed order. + */ + onboarding: OnboardingStateDto | null; +} + +/** + * v1.39 (C1) — the order the answers imply. + * + * The rows themselves are fixed; what the questionnaire decides is which of + * them a person sees first. Someone who said they take medication daily should + * not have to scroll past "connect a data source" to find it, and someone who + * named a wearable should meet that row before the manual-entry one. + * + * `profile` stays first whatever the answers say — it is the identity the + * clinical surfaces derive from, not a domain choice — and any row the answers + * do not speak to keeps its position relative to the others. The result is a + * reordering, never a removal: nothing here drops a row. + */ +export function checklistOrderFromNeeds( + needs: OnboardingNeeds, +): ChecklistItemId[] { + const promoted: ChecklistItemId[] = []; + const promote = (id: ChecklistItemId) => { + if (!promoted.includes(id)) promoted.push(id); + }; + + if (needs.medication === "yes" || needs.medication === "sometimes") { + promote("medication"); + } + // "I type them in" and "a file I already have" are not connections, so they + // say nothing about the data-source row. + if (needs.sources.some((s) => s !== "manual" && s !== "file")) { + promote("dataSource"); + } + if (needs.areas.length > 0) promote("measurement"); + + return [ + "profile", + ...promoted, + ...CHECKLIST_ITEM_IDS.filter( + (id) => id !== "profile" && !promoted.includes(id), + ), + ]; } /** @@ -112,7 +168,15 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { dismissed: inputs.dismissedIds.has("insights"), }, ]; - return items; + if (!inputs.onboarding) return items; + // Ordered from the answers only once the flow has been confirmed: before + // that the answers are still being given, and re-ordering the dashboard + // under someone mid-question would be movement they did not ask for. + if (inputs.onboarding.completedAt === null) return items; + const order = checklistOrderFromNeeds(inputs.onboarding.needs); + return order + .map((id) => items.find((item) => item.id === id)) + .filter((item): item is ChecklistItem => item !== undefined); } /** @@ -145,25 +209,45 @@ export function checklistProgress(items: ChecklistItem[]): ChecklistProgress { /** * Should the dashboard render the checklist at all? * - * Visible while the user is still in the setup phase: - * - `onboardingCompletedAt` is null OR - * - they have fewer than 5 measurements + * Never when the whole list is dismissed, and never when there is no + * non-dismissed row left to show. * - * AND the user has not dismissed the entire checklist - * AND there is at least one undone, non-dismissed item. + * For a record that ran the setup flow the design spec retires the old + * five-reading rule: the list goes when its rows are done or the person hides + * it. Five readings were never evidence that anything was set up. A record + * that never entered the flow keeps the pre-v1.39 rule unchanged — first-run + * wizard unfinished, or fewer than five readings. + * + * A list whose rows are all done still stays while the flow itself is + * unfinished, because the one task the flow offered is not one of the six + * rows: "every row done" and "the setup finished" are different statements. */ export function shouldShowChecklist(args: { onboardingCompletedAt: string | null; measurementCount: number; dismissedAll: boolean; items: ChecklistItem[]; + /** + * v1.39 (C1) — the needs-based setup state for this record, or null before + * the account payload resolves. A record that never entered the flow reads + * as empty answers with nine pending steps and is treated as having no + * unfinished setup, which is what keeps every account that predates the flow + * on the rule it already had. + */ + onboarding?: OnboardingStateDto | null; }): boolean { if (args.dismissedAll) return false; - const stillInSetup = - args.onboardingCompletedAt == null || args.measurementCount < 5; - if (!stillInSetup) return false; + const onboarding = args.onboarding ?? null; const visible = visibleChecklist(args.items); if (visible.length === 0) return false; + + const stillInSetup = + hasEnteredOnboardingFlow(onboarding) || + args.onboardingCompletedAt == null || + args.measurementCount < 5; + if (!stillInSetup) return false; + + if (!isOnboardingSettled(onboarding)) return true; return visible.some((item) => !item.done); } diff --git a/src/lib/onboarding/needs-apply.ts b/src/lib/onboarding/needs-apply.ts new file mode 100644 index 000000000..293448d5c --- /dev/null +++ b/src/lib/onboarding/needs-apply.ts @@ -0,0 +1,139 @@ +/** + * v1.39 (C1) — applying one answered step to the stored onboarding state. + * + * Pure, so the idempotence claim is checkable without a database: applying the + * same body twice returns a value equal to applying it once, which is what + * `PATCH /api/onboarding/answers` promises. The route reads the row, calls + * this, and writes the result back; nothing about "what an answer means" lives + * in the route. + * + * Kept beside the vocabulary rather than inside it so `needs.ts` keeps + * depending on nothing but the module registry — this file is the one place + * that knows both the stored shape and the request shape. + */ +import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs"; + +import type { + OnboardingFirstResult, + OnboardingNeeds, + OnboardingStepId, + OnboardingStepState, + OnboardingStepStatus, +} from "./needs"; + +export interface OnboardingRecordState { + needs: OnboardingNeeds; + steps: OnboardingStepState[]; + firstResult: OnboardingFirstResult | null; +} + +/** + * The two list answers are stored as given, and a client that sends the same + * chip twice would otherwise leave the column holding something the reader + * never returns — `parseOnboardingNeeds` de-duplicates on the way out. + */ +function unique(values: readonly T[]): T[] { + return [...new Set(values)]; +} + +function withStatus( + steps: readonly OnboardingStepState[], + id: OnboardingStepId, + status: OnboardingStepStatus, +): OnboardingStepState[] { + return steps.map((step) => (step.id === id ? { ...step, status } : step)); +} + +/** + * Apply one answer. + * + * A skip records the pass and leaves the answers exactly as they were: the + * spec's principle is that nothing is lost by a choice, and a person who + * answered Q2, went back and then skipped it should not have their areas + * erased by the skip. `now` is injected so the first-result stamp is the + * server's instant and the test can pin it. + */ +export function applyOnboardingAnswer( + state: OnboardingRecordState, + input: OnboardingAnswerInput, + now: Date, +): OnboardingRecordState { + if ("status" in input && input.status === "skipped") { + return { ...state, steps: withStatus(state.steps, input.step, "skipped") }; + } + + const steps = withStatus(state.steps, input.step, "done"); + + switch (input.step) { + case "who": + return { + ...state, + steps, + needs: { ...state.needs, recordTarget: input.recordTarget }, + }; + case "areas": + return { + ...state, + steps, + needs: { ...state.needs, areas: unique(input.areas) }, + }; + case "medication": + return { + ...state, + steps, + needs: { ...state.needs, medication: input.medication }, + }; + case "sources": + return { + ...state, + steps, + needs: { ...state.needs, sources: unique(input.sources) }, + }; + case "visit": + return { ...state, steps, needs: { ...state.needs, visit: input.visit } }; + case "units": + return { + ...state, + steps, + needs: { + ...state.needs, + units: { + // An omitted field leaves the recorded answer alone; an explicit + // `null` clears it. The two cases are different on purpose — the + // screen may set one unit without the other. + glucoseUnit: + input.units.glucoseUnit === undefined + ? state.needs.units.glucoseUnit + : input.units.glucoseUnit, + unitPreference: + input.units.unitPreference === undefined + ? state.needs.units.unitPreference + : input.units.unitPreference, + }, + }, + }; + case "first-result": { + const already = + state.firstResult?.task === input.firstResult.task + ? state.firstResult.completedAt + : null; + return { + ...state, + steps, + firstResult: { + task: input.firstResult.task, + target: input.firstResult.target ?? null, + // Stamped once. A repeat of the same completed task keeps the + // instant it already had, which is what makes the write idempotent + // rather than merely repeatable. + completedAt: input.firstResult.completed + ? (already ?? now.toISOString()) + : already, + }, + }; + } + case "confirm": + case "done": + return { ...state, steps }; + } +} diff --git a/src/lib/onboarding/needs-store.ts b/src/lib/onboarding/needs-store.ts new file mode 100644 index 000000000..64cb3560e --- /dev/null +++ b/src/lib/onboarding/needs-store.ts @@ -0,0 +1,103 @@ +/** + * v1.39 (C1) — reading the needs-onboarding row, for the payload and for the + * three writes. + * + * One place that turns an `OnboardingRecord` row (or its absence) into the + * published DTO, so `GET /api/auth/me` and the write routes cannot disagree + * about what "nothing answered yet" looks like. A record with no row reads as + * empty answers and nine pending steps rather than as a missing object — a + * client then never branches on absence, and the account payload stays + * additive for every account that predates the flow. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + parseOnboardingFirstResult, + parseOnboardingNeeds, + parseOnboardingSteps, + resolveOnboardingSteps, + type HeldUnitPreferences, + type OnboardingStateDto, +} from "./needs"; +import type { OnboardingRecordState } from "./needs-apply"; + +/** The columns every reader below needs. */ +export const ONBOARDING_RECORD_SELECT = { + needsJson: true, + stepsJson: true, + firstResultJson: true, + modulesDerivedAt: true, + completedAt: true, +} as const; + +export interface OnboardingRecordRow { + needsJson: unknown; + stepsJson: unknown; + firstResultJson: unknown; + modulesDerivedAt: Date | null; + completedAt: Date | null; +} + +/** The answers / steps / first result, parsed fail-soft. */ +export function readOnboardingRecordState( + row: OnboardingRecordRow | null, +): OnboardingRecordState { + if (!row) { + return { + needs: emptyOnboardingNeeds(), + steps: defaultOnboardingSteps(), + firstResult: null, + }; + } + return { + needs: parseOnboardingNeeds(row.needsJson), + steps: parseOnboardingSteps(row.stepsJson), + firstResult: parseOnboardingFirstResult(row.firstResultJson), + }; +} + +/** + * The shape published on the account payload. + * + * `held` is what the RECORD's account already carries in its two unit columns, + * and it is a required argument rather than an optional one: the Q6 "skipped + * when the account already holds them" rule has to hold for every reader of + * this DTO, and a caller that has not thought about it should fail to compile. + */ +export function toOnboardingStateDto( + row: OnboardingRecordRow | null, + held: HeldUnitPreferences, +): OnboardingStateDto { + const state = readOnboardingRecordState(row); + return { + steps: resolveOnboardingSteps(state.steps, held), + needs: state.needs, + completedAt: row?.completedAt?.toISOString() ?? null, + firstResult: state.firstResult, + }; +} + +/** + * Load one record's stored onboarding row, or `null` when it never entered the + * flow. + * + * The row rather than the DTO, because the DTO needs the record's unit columns + * as well and the caller is the one holding them — the account payload already + * reads that user row for the cycle gate, and a second read of the same row on + * the hottest endpoint in the app would buy nothing. + * + * Takes the delegate rather than the whole client, matching the section + * builders next door, so the route's global client and a worker's local one + * share the same read. + */ +export async function loadOnboardingRecordRow( + prisma: Pick, + recordId: string, +): Promise { + return prisma.onboardingRecord.findUnique({ + where: { userId: recordId }, + select: ONBOARDING_RECORD_SELECT, + }); +} diff --git a/src/lib/onboarding/needs.ts b/src/lib/onboarding/needs.ts new file mode 100644 index 000000000..59430ad96 --- /dev/null +++ b/src/lib/onboarding/needs.ts @@ -0,0 +1,441 @@ +/** + * v1.39 (C1) — the needs-based onboarding's server state, as data. + * + * The design spec (`.planning/2026-09-09-onboarding-design-spec.md`) asks five + * questions, saves each answer as it is given, and derives the record's module + * map from the set of answers once. This file is the vocabulary and the + * fail-soft readers for the three JSON columns behind that: the answers, the + * ordered steps, and the one task the flow ended on. + * + * Pure and Prisma-free on purpose — the Zod schemas in + * `src/lib/validations/onboarding-needs.ts` build on it, the OpenAPI generator + * pulls it in without dragging the server graph along, and the browser reads + * the same step ids the server writes. + * + * ── Coexistence with the wizard of today ─────────────────────────────────── + * + * Nothing here retires anything. `User.onboardingStep`, `User.onboardingGoals` + * and the six goal slugs in `./goals.ts` keep their meaning and keep driving + * the four-step wizard and its one-time dashboard seed until C2 replaces that + * surface. The two states are disjoint by construction: the goal slugs decide + * TILE ORDER, these answers decide WHICH MODULES ARE ON, and neither reads the + * other. `User.onboardingCompletedAt` also stays what it was — the first-run + * redirect's gate — which is why the needs flow carries a completion stamp of + * its own rather than borrowing that column: "set up again" has to be able to + * re-ask the questions without pushing the person back through the first-run + * redirect. + */ +import type { + OnboardingAreaKey, + OnboardingModuleNeeds, +} from "@/lib/modules/registry"; +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; + +/** + * The ordered steps, with the stable ids the spec names. Order is the order + * they are asked in; the ids are the wire contract a client (web now, native + * later) matches on, and a client that meets an id it does not know is + * expected to skip it rather than refuse the list. + */ +export const ONBOARDING_STEP_IDS = [ + "who", + "areas", + "medication", + "sources", + "visit", + "units", + "confirm", + "first-result", + "done", +] as const; + +export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; + +export const ONBOARDING_STEP_STATUSES = ["pending", "done", "skipped"] as const; + +export type OnboardingStepStatus = (typeof ONBOARDING_STEP_STATUSES)[number]; + +/** + * The steps a person may pass without answering. `who` is the one required + * question in the spec; `confirm` and `done` are not questions at all, so + * "skipped" would not mean anything for them. + */ +export const ONBOARDING_SKIPPABLE_STEP_IDS = [ + "areas", + "medication", + "sources", + "visit", + "units", + "first-result", +] as const; + +/** + * Q1 — whose record this is. The vocabularies below are pinned against the + * registry's derivation input with `satisfies`, so a value added here that the + * mapping cannot read fails to compile rather than falling through the + * derivation silently. + */ +export const ONBOARDING_RECORD_TARGETS = [ + "me", + "someone-else", + "both", +] as const satisfies readonly OnboardingModuleNeeds["recordTarget"][]; + +export type OnboardingRecordTarget = (typeof ONBOARDING_RECORD_TARGETS)[number]; + +/** Q3 — medication on a schedule. */ +export const ONBOARDING_MEDICATION_ANSWERS = [ + "yes", + "no", + "sometimes", +] as const satisfies readonly OnboardingModuleNeeds["medication"][]; + +export type OnboardingMedicationAnswer = + (typeof ONBOARDING_MEDICATION_ANSWERS)[number]; + +/** Q4 — where the readings come from today. */ +export const ONBOARDING_SOURCE_KEYS = [ + "manual", + "apple-health", + "withings", + "oura", + "whoop", + "polar", + "fitbit", + "strava", + "nightscout", + "file", +] as const; + +export type OnboardingSourceKey = (typeof ONBOARDING_SOURCE_KEYS)[number]; + +/** Q5 — a doctor's visit coming up. */ +export const ONBOARDING_VISIT_ANSWERS = [ + "within-a-month", + "later", + "no", +] as const satisfies readonly OnboardingModuleNeeds["visit"][]; + +export type OnboardingVisitAnswer = (typeof ONBOARDING_VISIT_ANSWERS)[number]; + +/** + * The one task the flow ends on. `target` names what the task is about — a + * source key for a connection, an area key for a reading — and is null for the + * medication task, which has nothing to disambiguate. + */ +export const ONBOARDING_FIRST_RESULT_TASKS = [ + "connect-source", + "add-medication", + "log-reading", +] as const; + +export type OnboardingFirstResultTask = + (typeof ONBOARDING_FIRST_RESULT_TASKS)[number]; + +/** + * Q6 — the unit answers, recorded as given. + * + * These are NOT the source of truth for how a value is displayed: + * `User.glucoseUnit` and `User.unitPreference` are, and the answers step + * writes them. What is kept here is what the flow was TOLD, so a restart can + * show the person the answer they already gave without re-deriving it from a + * display column somebody may have changed in Settings since. + */ +export interface OnboardingUnitAnswers { + glucoseUnit: "mg/dL" | "mmol/L" | null; + unitPreference: "metric" | "imperial" | null; +} + +/** The answers as given. Every field is null / empty until it is answered. */ +export interface OnboardingNeeds { + recordTarget: OnboardingRecordTarget | null; + areas: OnboardingAreaKey[]; + medication: OnboardingMedicationAnswer | null; + sources: OnboardingSourceKey[]; + visit: OnboardingVisitAnswer | null; + units: OnboardingUnitAnswers; +} + +export interface OnboardingStepState { + id: OnboardingStepId; + status: OnboardingStepStatus; +} + +export interface OnboardingFirstResult { + task: OnboardingFirstResultTask; + target: string | null; + /** ISO instant when the offered task produced its result, or null. */ + completedAt: string | null; +} + +/** + * What `GET /api/auth/me` publishes under `onboarding`, and what a client + * reads. Additive: an account that never entered the flow gets the empty + * answers and nine `pending` steps rather than a missing field, so a consumer + * never has to branch on absence. + */ +export interface OnboardingStateDto { + steps: OnboardingStepState[]; + needs: OnboardingNeeds; + completedAt: string | null; + firstResult: OnboardingFirstResult | null; +} + +export function emptyOnboardingNeeds(): OnboardingNeeds { + return { + recordTarget: null, + areas: [], + medication: null, + sources: [], + visit: null, + units: { glucoseUnit: null, unitPreference: null }, + }; +} + +/** Nine steps, all pending, in the spec's order. */ +export function defaultOnboardingSteps(): OnboardingStepState[] { + return ONBOARDING_STEP_IDS.map((id) => ({ id, status: "pending" as const })); +} + +function member( + values: readonly T[], + value: unknown, +): T | null { + return typeof value === "string" && + (values as readonly string[]).includes(value) + ? (value as T) + : null; +} + +function memberList( + values: readonly T[], + value: unknown, +): T[] { + if (!Array.isArray(value)) return []; + const out: T[] = []; + for (const entry of value) { + const parsed = member(values, entry); + if (parsed !== null && !out.includes(parsed)) out.push(parsed); + } + return out; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * Read the stored answers, dropping anything that is not a known value. + * + * Fail-soft rather than throwing: a column written by an older release, or by + * a value this release retired, must not 500 the account payload that rides + * every app boot. What cannot be read reads as unanswered, which is the state + * the flow already knows how to handle. + */ +export function parseOnboardingNeeds(value: unknown): OnboardingNeeds { + const raw = asRecord(value); + const units = asRecord(raw.units); + return { + recordTarget: member(ONBOARDING_RECORD_TARGETS, raw.recordTarget), + areas: memberList(ONBOARDING_AREA_KEYS, raw.areas), + medication: member(ONBOARDING_MEDICATION_ANSWERS, raw.medication), + sources: memberList(ONBOARDING_SOURCE_KEYS, raw.sources), + visit: member(ONBOARDING_VISIT_ANSWERS, raw.visit), + units: { + glucoseUnit: member(["mg/dL", "mmol/L"] as const, units.glucoseUnit), + unitPreference: member( + ["metric", "imperial"] as const, + units.unitPreference, + ), + }, + }; +} + +/** + * Read the stored steps as the full ordered nine. + * + * The stored blob is treated as a status lookup, never as the order: the order + * is this release's `ONBOARDING_STEP_IDS`, so a step added to the flow appears + * as `pending` for an account mid-way through rather than vanishing, and a + * step removed from the flow stops being published without a migration. + */ +export function parseOnboardingSteps(value: unknown): OnboardingStepState[] { + const stored = new Map(); + if (Array.isArray(value)) { + for (const entry of value) { + const row = asRecord(entry); + const id = member(ONBOARDING_STEP_IDS, row.id); + const status = member(ONBOARDING_STEP_STATUSES, row.status); + if (id && status) stored.set(id, status); + } + } + return ONBOARDING_STEP_IDS.map((id) => ({ + id, + status: stored.get(id) ?? "pending", + })); +} + +export function parseOnboardingFirstResult( + value: unknown, +): OnboardingFirstResult | null { + const raw = asRecord(value); + const task = member(ONBOARDING_FIRST_RESULT_TASKS, raw.task); + if (!task) return null; + return { + task, + target: typeof raw.target === "string" ? raw.target.slice(0, 64) : null, + completedAt: + typeof raw.completedAt === "string" && raw.completedAt.length > 0 + ? raw.completedAt + : null, + }; +} + +/** + * The steps that are QUESTIONS — everything the flow puts to the person before + * the confirm screen. + * + * Sliced out of the ordered list rather than written out again, so a question + * added to the flow joins this set on the day it lands and the completion gate + * below cannot fall behind the screens. + */ +export const ONBOARDING_QUESTION_STEP_IDS: readonly OnboardingStepId[] = + ONBOARDING_STEP_IDS.slice(0, ONBOARDING_STEP_IDS.indexOf("confirm")); + +/** + * Has the questionnaire been finished? + * + * Every question answered or deliberately passed — not "Q1 is set". The + * difference matters because the answers become a module map: deriving from a + * flow somebody abandoned after the first screen reads every unanswered + * question as its conservative default and switches off the surfaces that + * default implies, and the derivation latch then makes that unrepeatable. + * + * `who` cannot be passed (the schema gives it no skip arm), so "not pending" + * means "answered" for it and "answered or passed" for the rest. + */ +export function everyOnboardingQuestionSettled( + steps: readonly OnboardingStepState[], +): boolean { + return ONBOARDING_QUESTION_STEP_IDS.every( + (id) => steps.find((step) => step.id === id)?.status !== "pending", + ); +} + +/** True once every step has been answered or deliberately passed. */ +export function everyOnboardingStepSettled( + steps: readonly OnboardingStepState[], +): boolean { + return steps.every((step) => step.status !== "pending"); +} + +/** + * Did this record ever enter the setup flow? + * + * Q1 is the one question with no skip arm, so an answer to it is the mark of + * having started, and the flow's own completion stamp covers a row whose + * answers a later release stopped recognising. + * + * Deliberately NOT "any step is not pending": `units` resolves to `done` from + * the account's own unit columns, so a record that never saw a question can + * carry a settled step. The published state is additive — every record has an + * `onboarding` field, and a record that predates the flow reads as empty + * answers with nine pending steps — so this is what tells the two apart. + */ +export function hasEnteredOnboardingFlow( + state: OnboardingStateDto | null | undefined, +): boolean { + if (!state) return false; + return state.completedAt !== null || state.needs.recordTarget !== null; +} + +/** + * Has the person finished setting up? + * + * Three conditions, and each of them is a way the flow can be left half-done: + * a step still waiting for an answer, a set of answers never confirmed, and a + * first-result task that was offered and never produced. The last one is the + * point of the whole flow — it ends on one completed task — so an offer with + * no result is not a finished setup. + * + * Two things settle it outright. `null`, and a record that never entered the + * flow: there is no unfinished setup to keep anything open for, and since the + * payload publishes the field for every record, without that arm every account + * that predates the flow would read as permanently unfinished. + * + * And a first-result step the person deliberately passed. The task is offered + * (`firstResult` records which one, with no completion) and then skipped; the + * offer is still on the row, so asking only "did the offered task produce + * anything" answers "no" forever. A deliberate pass is an answer, not an + * unfinished task. + */ +export function isOnboardingSettled( + state: OnboardingStateDto | null | undefined, +): boolean { + if (!state) return true; + if (!hasEnteredOnboardingFlow(state)) return true; + if (!everyOnboardingStepSettled(state.steps)) return false; + if (state.completedAt === null) return false; + const offered = state.steps.find((step) => step.id === "first-result"); + if (offered?.status === "skipped") return true; + return state.firstResult === null || state.firstResult.completedAt !== null; +} + +/** + * The unit columns an account already holds, read as the vocabulary the wire + * publishes. + * + * `User.unitPreference` is nullable and `null` means "default metric", which + * the account payload coerces away (`unitPreference === "imperial" ? + * "imperial" : "metric"`). That coercion is fine to compare a CHOSEN value + * against and useless for telling a chosen metric from an unset column, so the + * server reads the raw column here and answers `null` for "never chosen". + */ +export interface HeldUnitPreferences { + glucoseUnit: "mg/dL" | "mmol/L" | null; + unitPreference: "metric" | "imperial" | null; +} + +/** Read the two raw columns into the wire vocabulary; anything else is unset. */ +export function readHeldUnitPreferences(row: { + glucoseUnit: string | null; + unitPreference: string | null; +}): HeldUnitPreferences { + return { + glucoseUnit: member(["mg/dL", "mmol/L"] as const, row.glucoseUnit), + unitPreference: member(["metric", "imperial"] as const, row.unitPreference), + }; +} + +/** True once the account carries both unit preferences as deliberate values. */ +export function accountHoldsBothUnits(held: HeldUnitPreferences): boolean { + return held.glucoseUnit !== null && held.unitPreference !== null; +} + +/** + * The steps as they should be READ, given what the account already holds. + * + * The spec's Q6 is "skipped when the account already holds them", and the + * evidence for that is the two unit columns, not the questionnaire's own + * memory of having asked. So a record whose account carries both preferences + * publishes `units` as `done` even though nobody answered it here — which is + * what stops the flow re-asking a value the account holds, and what stops the + * completion gate below waiting for an answer to a question the flow will + * never show. + * + * Read-side only: the stored ledger keeps saying `pending`, because the person + * really has not answered it, and an answer given later still lands. + */ +export function resolveOnboardingSteps( + steps: readonly OnboardingStepState[], + held: HeldUnitPreferences, +): OnboardingStepState[] { + if (!accountHoldsBothUnits(held)) return [...steps]; + return steps.map((step) => + step.id === "units" && step.status === "pending" + ? { ...step, status: "done" as const } + : step, + ); +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts new file mode 100644 index 000000000..042a10e8e --- /dev/null +++ b/src/lib/openapi/error-codes.ts @@ -0,0 +1,355 @@ +/** + * Every `meta.errorCode` the API emits, grouped by the surface that emits it. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * — the envelope's own description tells a client to branch on it rather than + * on the prose — and until this file existed a client could not enumerate it. + * When this file was written, sixteen of its two hundred and forty-two codes + * appeared somewhere in `docs/api/openapi.yaml`; the rest existed only in + * server source, so the only way to learn one was to trigger the error in + * testing and the only way to learn a new one had appeared was to trigger + * that too. + * + * The list is generated from the code and then committed as a literal, so it is + * reviewable in a diff and stable across a release. `error-code-catalogue.test.ts` + * re-derives it from the tree on every run and fails in both directions: a code + * emitted and not listed, and a code listed and no longer emitted. + * + * Naming: four conventions coexist here — dot.case (the majority and the one + * to use), bare snake_case, a camelCase segment, and a hyphen. Nothing is + * renamed: a code is a wire value and a shipped client branches on it. New + * codes are dot.case. + * + * Not in this list, and deliberately: + * + * - `assistant.disabled.` is built from a template at runtime + * (`src/lib/feature-flags/index.ts`), so the surface segment is open. + * `assistant.disabled.coach` is the one the iOS contract names. + * - The integration-probe classes — `credentials_rejected`, `rate_limited`, + * `upstream_error`, `timeout`, `connection_failed`, and the per-provider + * additions beside them — are chosen by each `/test` route's own + * categoriser and are already enumerated in those operations' + * descriptions, where the per-provider differences can be stated. + * - `IntegrationStatus.errorCode` is a different field with the same name: it + * records an upstream HTTP status in the sync ledger and never reaches a + * response envelope. + */ +export const ERROR_CODE_CATALOGUE: Readonly> = + { + ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], + allergy: ["allergy.invalid"], + analytics: ["analytics.invalid_query"], + anamnesis: [ + "anamnesis.fact.conflict", + "anamnesis.fact.currentExists", + "anamnesis.fact.invalidValue", + ], + auth: [ + "auth.admin.required", + "auth.mfa.code_invalid", + "auth.missing", + "auth.refresh.invalid", + "auth.refresh.reuse", + "auth.refresh.revoked", + "auth.scope.insufficient", + "auth.stepup.required", + "auth.token.expired", + "auth.token.invalid", + ], + backup: ["backup.payload.undecryptable", "backup.section.missing"], + coach: [ + "coach.conversation.invalidTitle", + "coach.conversation.notFound", + "coach.fenced.attachmentConflict", + "coach.fenced.attachmentLimit", + "coach.fenced.attachmentNotFound", + "coach.fenced.attachmentNotIndexed", + "coach.fenced.attachmentRequired", + "coach.fenced.attachmentUnavailable", + "coach.fenced.invalid", + "coach.request.invalid", + ], + "coach-prefs": [ + "coach-prefs.body.invalid_json", + "coach-prefs.body.invalid_shape", + ], + consent: ["consent.ai.required"], + cycle: [ + "cycle.bulk.invalid", + "cycle.bulk.too_large", + "cycle.calendar.invalid", + "cycle.calendar.range", + "cycle.cycles.invalid", + "cycle.day-log.conflict", + "cycle.day-log.invalid", + "cycle.disabled", + "cycle.period.invalid", + "cycle.symptom.custom.invalid", + "cycle.symptom.custom.limit", + ], + "cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"], + "disable-coach": ["disable-coach.body.invalid_json"], + documents: [ + "documents.chat.conversationNotFound", + "documents.chat.invalid", + "documents.inbound.alreadyConfirmed", + "documents.inbound.alreadyPartlyConfirmed", + "documents.inbound.budgetExceeded", + "documents.inbound.decryptFailed", + "documents.inbound.encounterNotFound", + "documents.inbound.episodeNotFound", + "documents.inbound.extractFailed", + "documents.inbound.factNotFound", + "documents.inbound.factNotPending", + "documents.inbound.factTypeMismatch", + "documents.inbound.fileTooLarge", + "documents.inbound.fileType", + "documents.inbound.invalidMetadata", + "documents.inbound.invalidQuery", + "documents.inbound.invalidUpdate", + "documents.inbound.localOcrDisabled", + "documents.inbound.notFound", + "documents.inbound.notIndexed", + "documents.inbound.pdfNeedsAnthropic", + "documents.inbound.providerUnsupported", + "documents.inbound.quotaExceeded", + "documents.inbound.rateLimited", + "documents.inbound.restoreDuplicate", + "documents.inbound.restoreGone", + "documents.inbound.uploadBusy", + "documents.inbound.uploadTimeout", + ], + encounter: [ + "encounter.invalid", + "encounter.link.invalid", + "encounter.practitioner-not-found", + "encounter.reminder-conflict", + "encounter.reminder-not-found", + ], + environment: [ + "environment.invalid", + "environment.no_home", + "environment.range_too_large", + "environment.travel.not_found", + ], + export: ["export.selection.empty", "export.selection.unknown_leaf"], + familyHistory: ["familyHistory.invalid"], + feedback: ["feedback.body.invalid"], + health_score_config: [ + "health_score_config.invalid", + "health_score_config.too_narrow", + ], + illness: [ + "illness.day-log.invalid", + "illness.disabled", + "illness.episode.chronic-no-resolve", + "illness.episode.invalid", + "illness.episode.invalid-window", + "illness.episode.parent-not-found", + "illness.insights.invalid", + ], + import: ["import.write_failed"], + ingest: ["ingest.rate_limited"], + insights: [ + "insights.generate.budgetExceeded", + "insights.generate.outboundScreened", + "insights.generate.profileScopeChanged", + "insights.generate.scopeChanged", + ], + labs: [ + "labs.biomarker.create.invalid", + "labs.biomarker.duplicate", + "labs.biomarker.notFound", + "labs.biomarker.update.invalid", + "labs.biomarker.update.referenceRangeInvalid", + "labs.create.invalid", + "labs.ocr.budgetExceeded", + "labs.ocr.extractFailed", + "labs.ocr.fileTooLarge", + "labs.ocr.fileType", + "labs.ocr.localOcrDisabled", + "labs.ocr.pdfNeedsAnthropic", + "labs.ocr.providerUnsupported", + "labs.ocr.rateLimited", + "labs.restore.invalid", + "labs.result.notFound", + "labs.update.invalid", + "labs.update.linkedFieldsImmutable", + "labs.update.numericExpected", + "labs.update.qualitativeExpected", + "labs.update.referenceRangeInvalid", + "labs.update.sourceReferenceRangeInvalid", + ], + managed_profile: [ + "managed_profile.guardian.duplicate", + "managed_profile.guardian.managed_invitee", + "managed_profile.guardian.required", + "managed_profile.not_found", + ], + measurement: [ + "measurement.batch.source_not_permitted", + "measurement.batch.too_large", + "measurement.bulk-delete.invalid", + "measurement.create.invalid", + "measurement.create.source_not_permitted", + "measurement.delete.too_large", + "measurement.duplicate_timestamp", + "measurement.restore.invalid", + "measurement.update.server_owned_source", + ], + medication: ["medication.intake.import.invalid_format"], + medications: [ + "medications.intake.bulk.apple_health_not_mirrored", + "medications.intake.bulk.invalid", + "medications.intake.bulk.too_large", + "medications.intake.force_slot.invalid", + "medications.intake.force_slot.occupied", + "medications.intake.injection_site.disallowed", + "medications.intake.taken_at.before_start", + "medications.mirror.limit_exceeded", + ], + mentalHealth: ["mentalHealth.rateLimited"], + module: ["module.disabled"], + modules: ["modules.body.invalid_json", "modules.invalid"], + mood: [ + "mood.bulk-delete.invalid", + "mood.bulk.invalid", + "mood.bulk.too_large", + "mood.create.external_id_not_delegable", + "mood.create.invalid", + "mood.duplicate_timestamp", + "mood.linkedContext.invalid", + "mood.list.invalid", + "mood.not_found", + "mood.ratedFactor.out_of_range", + "mood.restore.invalid", + "mood.update.invalid", + ], + "notification-prefs": ["notification-prefs.body.invalid_json"], + nutrient: [ + "nutrient.batch.invalid", + "nutrient.batch.too_large", + "nutrient.daily.invalid", + "nutrient.read.invalid", + "nutrient.water.invalid", + "nutrient.water.invalid_day", + ], + onboarding: [ + "onboarding.answers.invalid", + "onboarding.answers.rateLimited", + "onboarding.complete.invalid", + "onboarding.disclaimer.invalid", + "onboarding.restart.invalid", + "onboarding.restart.rateLimited", + "onboarding.step.completed", + "onboarding.step.concurrent", + "onboarding.step.invalid", + "onboarding.step.outOfOrder", + "onboarding.step.rateLimited", + "onboarding.tour.invalid", + "onboarding.user.notFound", + ], + personal_records: ["personal_records.invalid_query"], + practitioner: ["practitioner.invalid"], + profile: [ + "profile.update.emailInUse", + "profile.update.invalidBody", + "profile.update.nothingSaved", + ], + record_write: ["record_write.rate_limited"], + "report-selection": [ + "report-selection.body.invalid_json", + "report-selection.body.invalid_shape", + "report-selection.leaves.unknown", + ], + route: ["route.retired"], + "share-link": [ + "share-link.document.decryptFailed", + "share-link.document.notFound", + "share-link.document.rateLimited", + "share-link.documents.invalid", + "share-link.report.notFound", + "share-link.report.rateLimited", + "share-link.selection.forbidden_leaf", + "share-link.selection.unknown_leaf", + ], + sharing: [ + "sharing.accept.expired", + "sharing.accept.not_found", + "sharing.accept.not_pending", + "sharing.accept.revoked", + "sharing.access.denied", + "sharing.invite.duplicate", + "sharing.invite.invalid", + "sharing.invite.invalid_scope", + "sharing.invite.manage_browser_only", + "sharing.invite.refused", + "sharing.invite.self", + "sharing.not_permitted", + "sharing.renounce.already_ended", + "sharing.renounce.not_found", + "sharing.revoke.already_ended", + "sharing.revoke.not_found", + "sharing.session.changed", + "sharing.switch.invalid", + "sharing.switch.wrong_transport", + ], + "source-priority": [ + "source-priority.body.invalid_json", + "source-priority.body.invalid_shape", + ], + tokens: ["tokens.measurements.ceiling_reached"], + vaccination: [ + "vaccination.booster-invalid", + "vaccination.booster-no-antigen", + "vaccination.booster-out-of-scope", + "vaccination.encounter-not-found", + "vaccination.identity-required", + "vaccination.invalid", + "vaccination.link.invalid", + "vaccination.not-found", + "vaccination.practitioner-not-found", + ], + workout: [ + "workout.batch.invalid", + "workout.batch.payload_too_large", + "workout.batch.too_large", + ], + "(unprefixed)": [ + "about_me_conflict", + "ai_response_truncated", + "coach_prefs_conflict", + "dashboard_layout_conflict", + "empty_file", + "health_score_config_conflict", + "insights_layout_conflict", + "invalid_base_updated_at", + "json_carries_no_intake_time", + "json_not_an_array", + "medication_layout_conflict", + "missing_required_columns", + "modules_conflict", + "mood_tag_layout_conflict", + "not_configured", + "notification_prefs_conflict", + "oidc_only", + "private_origin_not_approved", + "rate_limited_self", + "too_many_rows", + "unreadable_json", + "upstream_invalid_json", + "vapid_not_configured", + ], + } as const; + +/** + * The catalogue as one sentence for the published `meta.errorCode` description. + * + * Rendered rather than hand-written so the document cannot drift from the list + * the guard checks: there is one source, and it is the object above. + */ +export function renderErrorCodeCatalogue(): string { + return Object.entries(ERROR_CODE_CATALOGUE) + .map(([surface, codes]) => `${surface} — ${codes.join(", ")}`) + .join("; "); +} diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 25a30a8b5..a41d6e81e 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -34,6 +34,7 @@ import { inviteManagedProfileGuardianSchema, updateManagedProfileSchema, } from "@/lib/validations/managed-profiles"; +import { onboardingStateResource } from "./onboarding"; import { moduleAccessMap } from "./profile"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; @@ -462,6 +463,15 @@ const accountPayload = z notificationPrefs: z.looseObject({ medication: z.looseObject({ clientManaged: z.boolean() }), }), + // v1.39 (C1) — enumerated for the same reason `notificationPrefs` is: a + // client DECIDES on it. The getting-started checklist orders its rows from + // `needs` and stays on screen while the flow is unfinished, and a future + // native client renders the steps it knows. Always present, and resolved + // for the RECORD the payload describes — the same scoping as `modules`, + // because the checklist reads it beside that record's counts. Under a + // SCOPED grant the answers come back empty, for the reason the module map + // is masked. The three write routes stay actor-only. + onboarding: onboardingStateResource, }) .meta({ id: "AccountPayload", diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts index 3f1629054..63a1f7a40 100644 --- a/src/lib/openapi/routes/allergies.ts +++ b/src/lib/openapi/routes/allergies.ts @@ -30,6 +30,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -125,6 +126,7 @@ export const allergyPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 230e0e47e..537bf4c07 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -25,6 +25,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -125,6 +126,7 @@ export const biomarkerPaths: NonNullable = { }, ...conflict, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index 77e9ac8fc..d091a0dd7 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -27,6 +27,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -314,6 +315,7 @@ export const customMetricPaths: NonNullable = { }, ...notFound, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index e32173f30..f0d03b608 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -33,7 +33,9 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, + malformedJsonResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -515,6 +517,7 @@ export const cyclePaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Cycle"], summary: "Create a custom cycle symptom (v1.15.1)", description: @@ -526,6 +529,7 @@ export const cyclePaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "201": { description: "The created custom symptom.", content: { @@ -707,6 +711,7 @@ export const cyclePaths: NonNullable = { }, ...cycleDisabledOnADelegableRoute, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, @@ -869,7 +874,7 @@ export const cyclePaths: NonNullable = { tags: ["Cycle"], summary: "Cycle-phase correlation insights (v1.15.0)", description: - "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.", + "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are unchanged.", responses: { "200": { description: "Phase-correlation rows + headline.", @@ -991,6 +996,7 @@ export const cyclePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index f1e3f4a64..b5df36a31 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -38,6 +38,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -273,6 +274,7 @@ export const encounterPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index 6cc215f33..dfd157c28 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -20,7 +20,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { backupPayloadSchema } from "@/lib/validations/backup"; -import { errorEnvelope, stdResponses } from "./shared"; +import { + errorEnvelope, + SHARING_NOT_PERMITTED_DESCRIPTION, + stdResponses, +} from "./shared"; const exportQuery = z .object({ @@ -305,6 +309,12 @@ export const exportPaths: NonNullable = { }, }, }, + "403": { + description: + "The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 7cddeae2a..81dd942af 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -22,6 +22,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -161,6 +162,7 @@ export const labsPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index f138557cb..ffd097081 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -21,6 +21,7 @@ import { measurementSourceEnum, measurementTypeEnum, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -885,6 +886,7 @@ export const measurementPaths: NonNullable = { // After the spread: `stdResponses` carries a generic 422 that would // otherwise overwrite this one. ...stdResponses, + ...recordWriteRateLimitResponse, "422": { description: "Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index bdf31f54c..ace53ad90 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -35,6 +35,7 @@ import { idempotentWrite, invalidBaseTokenResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "../shared"; @@ -289,7 +290,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "401": stdResponses["401"], - "429": stdResponses["429"], + ...recordWriteRateLimitResponse, }, }, }, @@ -321,6 +322,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Record a titration step or a legacy stock correction", description: @@ -331,6 +333,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: glp1PostBodySchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: @@ -1151,6 +1154,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, get: { diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 6e9080da6..6687f11c6 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -135,7 +135,8 @@ export const mentalHealthPaths: NonNullable = { tags: ["Mental health"], summary: "Record a completed screener", description: - "Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.", + "Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.\n\n" + + "The projection row is the one write this operation makes outside its declared section: the operation is fenced on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a screener whose own total is missing from the record would be an inconsistency inside the section the grant does open. The consent copy for the mood-and-mind section names it.", parameters: [idempotencyKeyParameter], requestBody: { content: { diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 92cb9afe4..186331aa3 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -56,6 +56,7 @@ import { invalidBaseTokenResponse, moduleDisabledResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, updatedAtTokenField, } from "./shared"; @@ -1636,6 +1637,7 @@ export const moodPaths: NonNullable = { ...duplicateTimestampResponse, ...recordRefusal(), ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts index 2d7b3b66a..f023d8be4 100644 --- a/src/lib/openapi/routes/onboarding.ts +++ b/src/lib/openapi/routes/onboarding.ts @@ -23,13 +23,131 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { + ONBOARDING_FIRST_RESULT_TASKS, + ONBOARDING_MEDICATION_ANSWERS, + ONBOARDING_RECORD_TARGETS, + ONBOARDING_SOURCE_KEYS, + ONBOARDING_STEP_IDS, + ONBOARDING_STEP_STATUSES, + ONBOARDING_VISIT_ANSWERS, +} from "@/lib/onboarding/needs"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; import { onboardingCompleteSchema, onboardingStepSchema, } from "@/lib/validations/onboarding"; +import { + onboardingAnswerSchema, + onboardingRestartSchema, +} from "@/lib/validations/onboarding-needs"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +/* ── v1.39 (C1): the needs-based flow ─────────────────────────────────────── */ + +const onboardingNeedsResource = z + .object({ + recordTarget: z + .enum(ONBOARDING_RECORD_TARGETS) + .nullable() + .describe("Q1 — whose record this is. Null until answered."), + areas: z + .array(z.enum(ONBOARDING_AREA_KEYS)) + .describe( + "Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`; the map is the server's, and a client must not re-derive it.", + ), + medication: z + .enum(ONBOARDING_MEDICATION_ANSWERS) + .nullable() + .describe("Q3 — medication on a schedule."), + sources: z + .array(z.enum(ONBOARDING_SOURCE_KEYS)) + .describe("Q4 — where the readings come from today."), + visit: z + .enum(ONBOARDING_VISIT_ANSWERS) + .nullable() + .describe("Q5 — a doctor's visit coming up."), + units: z + .object({ + glucoseUnit: z.enum(["mg/dL", "mmol/L"]).nullable(), + unitPreference: z.enum(["metric", "imperial"]).nullable(), + }) + .describe( + "Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on the account payload are, and the answers step writes them. This records what the flow was told.", + ), + }) + .meta({ + id: "OnboardingNeeds", + description: + "The setup answers as given. Every field is null or empty until its question is answered.", + }); + +const onboardingStepResource = z + .object({ + id: z.enum(ONBOARDING_STEP_IDS), + status: z.enum(ONBOARDING_STEP_STATUSES), + }) + .meta({ + id: "OnboardingStepState", + description: + "One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather than refusing the list, so the flow may grow a screen without a client release.\n\n`units` carries one resolution the others do not: a record whose account already holds BOTH unit preferences reads as `done` whether or not the question was ever put, which is what stops the flow re-asking a value the account holds.", + }); + +const onboardingFirstResultResource = z + .object({ + task: z.enum(ONBOARDING_FIRST_RESULT_TASKS), + target: z + .string() + .nullable() + .describe( + "What the task is about — a source key for a connection, an area key for a reading. Null for the medication task.", + ), + completedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the offered task produced its result, or null."), + }) + .meta({ + id: "OnboardingFirstResult", + description: + "The one task the flow offered at the end, and whether it produced its result.", + }); + +export const onboardingStateResource = z + .object({ + steps: z.array(onboardingStepResource), + needs: onboardingNeedsResource, + completedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one alone.", + ), + firstResult: onboardingFirstResultResource.nullable(), + }) + .meta({ + id: "OnboardingState", + description: + "The needs-based setup flow for one record. Always present on the account payload — a record that never entered the flow reads as empty answers with nine `pending` steps, so a client branches on the step statuses rather than on the field's existence. On the account payload it describes the ACTIVE record, so a client under an account switch reads the same record its counts and module map describe; under a scoped grant the answers come back empty.", + }); + +const onboardingStateEnvelope = dataEnvelope( + z.object({ onboarding: onboardingStateResource }), + "OnboardingStateEnvelope", +); + +const onboardingAnswerRequest = onboardingAnswerSchema.meta({ + id: "OnboardingAnswerRequest", + description: + "One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a skip may never carry an answer.", +}); + +const onboardingRestartRequest = onboardingRestartSchema.meta({ + id: "OnboardingRestartRequest", + description: "No input beyond the session. Send `{}`.", +}); + const tourProgressResource = tourProgressSchema.meta({ id: "TourProgress", description: @@ -121,6 +239,86 @@ const onboardingCompleteRequest = onboardingCompleteSchema.meta({ }); export const onboardingPaths: NonNullable = { + "/api/onboarding/answers": { + patch: { + tags: ["Onboarding"], + summary: "Save one answer of the needs-based setup flow", + description: + "Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the same step. The body names the step and carries either its answer or `status: \"skipped\"`; the two can never ride together.\n\nIdempotent. The stored state is computed by a pure function of the row and the body, so sending the same step twice writes the same value and the second write changes nothing a reader can see — including the first-result completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract: steps may be answered, re-answered and passed in any order, which is what makes a back button work.\n\nThe `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit` and `unitPreference`, which every display surface already reads, and records the answer beside them.\n\nCookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never write another record's setup. Rate-limited to 120 writes per 10 minutes per account.", + requestBody: { + required: true, + content: { "application/json": { schema: onboardingAnswerRequest } }, + }, + responses: { + "200": { + description: "The full setup state after the write.", + content: { + "application/json": { schema: onboardingStateEnvelope }, + }, + }, + "413": { + description: "Body exceeds 16 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/onboarding/restart": { + post: { + tags: ["Onboarding"], + summary: "Ask the setup questions again", + description: + "\"Set up again\", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp, and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again.\n\nIt writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module somebody turned on by hand, and the protection against the second derivation lives in the merge rather than here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run redirect.\n\nCookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to 10 restarts per 10 minutes per account.", + requestBody: { + // The route accepts an absent body and treats it as `{}`; the schema + // is strict, so a body that carries anything is a 422. + required: false, + content: { "application/json": { schema: onboardingRestartRequest } }, + }, + responses: { + "200": { + description: "The reset setup state.", + content: { + "application/json": { schema: onboardingStateEnvelope }, + }, + }, + "413": { + description: "Body exceeds 4 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/onboarding/step": { post: { tags: ["Onboarding"], @@ -178,9 +376,9 @@ export const onboardingPaths: NonNullable = { "/api/onboarding/complete": { post: { tags: ["Onboarding"], - summary: "Stamp onboarding as complete (legacy single-shot path)", + summary: "Stamp onboarding as complete, and derive the module map", description: - "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", + "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. A half-answered flow derives nothing and is not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY wizard's completion out of the derivation, since that wizard never puts these questions.\n\nThe response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement it always did.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", requestBody: { required: true, content: { "application/json": { schema: onboardingCompleteRequest } }, @@ -188,11 +386,14 @@ export const onboardingPaths: NonNullable = { responses: { "200": { description: - "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not echo what was saved.", + "Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries the state after the derivation.", content: { "application/json": { schema: dataEnvelope( - z.object({ completed: z.literal(true) }), + z.object({ + completed: z.literal(true), + onboarding: onboardingStateResource.optional(), + }), "OnboardingCompleteEnvelope", ), }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 36c2de803..2ce21fc07 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -38,16 +38,17 @@ import { healthProfileFactKindSchema, } from "@/lib/validations/health-profile-facts"; import { - coachPrefsSchema, - dataEnvelope, - errorEnvelope, - stdResponses, baseUpdatedAtField, - updatedAtTokenField, + coachPrefsSchema, conflictResponse409, - invalidBaseTokenResponse, + dataEnvelope, deviceRevokeResponse, + errorEnvelope, + invalidBaseTokenResponse, + malformedJsonResponse, recordRefusal, + stdResponses, + updatedAtTokenField, } from "./shared"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime @@ -1063,6 +1064,7 @@ export const profilePaths: NonNullable = { }, ...conflictResponse409("Coach preferences", "coach_prefs_conflict"), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1108,6 +1110,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, @@ -1263,6 +1266,7 @@ export const profilePaths: NonNullable = { }, ...conflictResponse409("Module preferences", "modules_conflict"), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1310,6 +1314,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, @@ -1505,6 +1510,7 @@ export const profilePaths: NonNullable = { "notification_prefs_conflict", ), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1534,7 +1540,7 @@ export const profilePaths: NonNullable = { tags: ["Auth"], summary: "Replace the owner's saved doctor-report selection", description: - "Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.", + "Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.", requestBody: { required: true, content: { "application/json": { schema: savedReportProfile } }, @@ -1552,6 +1558,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 746714f56..f1e30a86a 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -19,6 +19,7 @@ import { dataEnvelope, errorEnvelope, recordRefusal, + SHARING_NOT_PERMITTED_DESCRIPTION, stdResponses, } from "./shared"; @@ -615,7 +616,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -811,7 +813,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43b625a9e..a97b71a82 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -6,6 +6,8 @@ * runtime request parsing, so the wire contract stays single-source. */ import { z } from "zod/v4"; + +import { renderErrorCodeCatalogue } from "../error-codes"; import { createMeasurementSchema as createMeasurementSchemaBase, listMeasurementsSchema as listMeasurementsSchemaBase, @@ -82,7 +84,8 @@ export const errorEnvelope = z .string() .optional() .describe( - "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded.", + "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.` is built from a template so the last segment is open (`assistant.disabled.coach` is the one the native client names); the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " + + renderErrorCodeCatalogue(), ), }) .optional(), @@ -225,6 +228,29 @@ export const invalidBaseTokenResponse = { }, }; +/** + * The 400 an unparseable JSON body earns. + * + * `safeJson` has answered 400 for a body that will not parse since it was + * written, and roughly two hundred and twenty routes go through it — but seven + * `/api/auth/me/*` writes hand-rolled the parse and answered 422, so a client's + * "my serializer produced garbage" branch had to accept two statuses on a + * subset of routes it could not predict. They answer 400 now, and this is the + * response that says so. The dotted token each route already carried moved to + * `meta.errorCode`, where a machine code belongs. + * + * Spread only on the operations that hand-rolled the parse. It is deliberately + * NOT folded into `stdResponses`: that set is spread onto reads as well, and a + * GET that accepts no body has no malformed body to refuse. + */ +export const malformedJsonResponse = { + "400": { + description: + "The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + // ── Standard 401 / 422 / 429 responses ─────────────────────────────── export const stdResponses = { @@ -266,6 +292,32 @@ export const stdResponses = { }, }; +/** + * The 429 the shared single-record write bucket answers with. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written and the per-record siblings were not capped at all, which is exactly + * backwards: the batch endpoint is the one a well-behaved client uses. The + * eleven single-record creates now share one generous per-account bucket, and + * this is the response that names it — a client that meets a ceiling should be + * able to read which one it met without a bug report. + * + * Spread AFTER `...stdResponses` so it replaces the generic 429 on those + * operations. The headers block is the one the standard 429 already declares. + * + * `record-write-rate-limit-contract.test.ts` holds the numbers in this sentence + * to the constants in `src/lib/rate-limit.ts`, so the paragraph cannot drift + * away from the bucket it describes. + */ +export const recordWriteRateLimitResponse = { + "429": { + description: + "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", + content: { "application/json": { schema: errorEnvelope } }, + headers: stdResponses["429"].headers, + }, +}; + // ── Idempotent writes ──────────────────────────────────────────────── /** @@ -379,6 +431,25 @@ export function idempotentWrite(): IdempotentConflictResponse { export const SHARING_ACCESS_DENIED_DESCRIPTION = "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it."; +/** + * The OTHER sharing refusal, for a route that names no record at all. + * + * A route resolving through `requireAuth` serves the caller and only the + * caller, and refuses outright while the browser is inside somebody else's + * record rather than quietly answering with the caller's own rows. That + * posture matters most on the routes where being wrong is unrecoverable — the + * record wipe, the account deletion, the encrypted archive — and it was + * documented nowhere on them. A client that switches records has to know which + * of its calls stop working, and finding out by wiping the wrong record is not + * a contract. + * + * One sentence, spliced onto whatever else the operation's 403 already says, + * for the same reason the delegable refusal is one string: a paraphrase per + * path invites a client to tell them apart. + */ +export const SHARING_NOT_PERMITTED_DESCRIPTION = + "Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it."; + /** * The 403 above, optionally sharing the status with the reasons a route already * refuses for. diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 193a7904a..3a4708dae 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -38,6 +38,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -263,6 +264,7 @@ export const vaccinationPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, @@ -362,14 +364,17 @@ export const vaccinationPaths: NonNullable = { tags: ["Records"], summary: "Plan a booster reminder from a dose", description: - "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.", + "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" + + "The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.", requestParams: idPath, requestBody: { required: true, content: { "application/json": { schema: vaccinationBoosterRequest } }, }, responses: { - ...recordRefusal(), + ...recordRefusal( + "Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.", + ), "200": { description: "Existing booster reminder re-anchored.", content: { diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d76113579..d6cc7b624 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -117,6 +117,49 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * Shared per-account ceiling for the single-record writes. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written; the per-record siblings a naive or older client loops over were not + * capped at all, which is backwards — the batch endpoint is the one a + * well-behaved client uses. This is the floor under the other direction: + * `POST /api/measurements`, `/api/mood-entries`, both medication-intake writes, + * `/api/labs`, a custom-metric entry, a cycle day-log, a vaccination, an + * allergy, a biomarker and an encounter. + * + * One shared bucket, not one per route, for the reason + * `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates + * should still meet a cap. Three hundred a minute is five writes a second + * sustained for a full minute — no hand-driven session and no sync burst + * reaches it. Ten of the eleven are wrapped in `withIdempotency`, so an honest + * retry storm is deduplicated before it is counted; `POST + * /api/medications/intake` is not, because it updates a named `intakeId` + * rather than creating a row, so a replay writes the status that is already + * there. It exists to cut off the pathological loop, not to shape traffic. + * + * A refused request is counted too: the check runs before the body is read, + * so a client looping on a 422 spends its allowance without writing a row. + * + * Keyed on the ACTING account rather than the record, the frozen precedent from + * the delegable medication routes: a delegate burns their own allowance rather + * than locking the record's owner out of their own log, and cannot collect a + * fresh one by switching records. + */ +export const RECORD_WRITE_BUCKET_PREFIX = "record-write"; +export const RECORD_WRITE_LIMIT = 300; +export const RECORD_WRITE_WINDOW_MS = 60 * 1000; + +export async function checkRecordWriteRateLimit( + accountId: string, +): Promise { + return checkRateLimit( + `${RECORD_WRITE_BUCKET_PREFIX}:${accountId}`, + RECORD_WRITE_LIMIT, + RECORD_WRITE_WINDOW_MS, + ); +} + /** * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). * The bucket is keyed by the `:` binding the Bearer diff --git a/src/lib/record-settings/modules.ts b/src/lib/record-settings/modules.ts new file mode 100644 index 000000000..13428f2ad --- /dev/null +++ b/src/lib/record-settings/modules.ts @@ -0,0 +1,85 @@ +/** + * v1.39 (C1) — the one write path for a record's module state. + * + * Two surfaces decide a record's modules and they must not disagree about how + * the decision is stored. `PATCH /api/record-settings/modules` (v1.38.15) is a + * guardian switching a managed record's modules by hand; the setup flow's + * confirm screen derives a map from the answers. Both write the same two + * places — the record's `modulePreferencesJson` blob and, for the delegated + * cycle key, the record's own `CycleProfile` column — and both have to + * invalidate the same cache, because a module toggle adds or removes a Health + * Score pillar and the cached composite was computed from a composition that + * no longer holds. That is what lives here, keyed on the RECORD, which is the + * carrier issue #939 point 2 needs. + * + * What deliberately does NOT live here is who may call it. The two callers + * authenticate differently and that is not an oversight: the record-settings + * route is a guardian acting on a managed record (`requireGuardianAuth`), and + * the setup routes are actor-only (`requireAuth()`, which refuses outright + * under an acting-account switch) until a guardian's route into a managed + * record's setup arrives with the rest of that record's configuration. A + * delegable declaration is a reviewed entry in the sharing surface guard, not + * something a shared write helper may hand out. + * + * `PATCH /api/auth/me/modules` is deliberately not routed through here: it is + * the ACTOR's own record, and its write is guarded on an optimistic-concurrency + * base token the two record-keyed callers do not carry. Folding it in would + * mean either dropping that guard or making it a parameter nobody but one + * caller sets. + * + * Nor does the merge: "what the answers imply" and "what a guardian just + * ticked" are different questions with different rules about what may be + * overwritten, so each caller computes its own complete map and this writes + * it. + */ +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { prisma, toJson } from "@/lib/db"; + +export interface RecordModuleWrite { + /** The record whose modules these are — never the actor, under a switch. */ + recordId: string; + /** The complete map to persist, or undefined to leave the blob alone. */ + modulePreferences?: Record; + /** + * An explicit answer for the delegated cycle key, or undefined to leave the + * column alone. The blob never owns this key; the profile column does. + */ + cycleTrackingEnabled?: boolean; +} + +/** + * Persist a record's module state and invalidate what it invalidates. + * + * Returns the cycle profile's stored opt-in afterwards (null when the record + * has no profile row), so a caller that publishes the resolved cycle state can + * resolve it against the record's sex without a second read. + */ +export async function writeRecordModulePreferences( + input: RecordModuleWrite, +): Promise<{ cycleTrackingEnabled: boolean | null }> { + if (input.modulePreferences !== undefined) { + await prisma.user.update({ + where: { id: input.recordId }, + data: { modulePreferencesJson: toJson(input.modulePreferences) }, + }); + } + if (input.cycleTrackingEnabled !== undefined) { + await getOrCreateCycleProfile(input.recordId); + await prisma.cycleProfile.update({ + where: { userId: input.recordId }, + data: { cycleTrackingEnabled: input.cycleTrackingEnabled }, + }); + } + + const profile = await prisma.cycleProfile.findUnique({ + where: { userId: input.recordId }, + select: { cycleTrackingEnabled: true }, + }); + + // A module toggle adds or removes a Health Score pillar, so the cached + // composite was computed from a composition that no longer holds. + invalidateUserHealthScore(input.recordId); + + return { cycleTrackingEnabled: profile?.cycleTrackingEnabled ?? null }; +} diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 672484ac7..41fb46cad 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -96,19 +96,39 @@ export interface AccountAccessEntry { */ sections: ShareDomain[] | null; /** - * May this caller ADD to that record. Resolved server-side. It does not by - * itself mean edit or delete — a WRITE grant adds and does nothing more, and - * that stayed true when a third level arrived. A MANAGE grant answers true - * here as well, and what it additionally admits is said by `level` rather - * than smuggled into this boolean. + * The grant's LEVEL, resolved server-side: is it at or above WRITE. + * + * It is not "may this caller add something", and reading it that way is how + * section-blind controls happen. There is no scope term in it: a WRITE grant + * scoped to the document vault answers `true` here with + * `writableDomains: []`, because the vault takes no delegated write at any + * level. Ask {@link AccountAccessEntry.writableDomains} for the section the + * control belongs to; this boolean answers only whether the grant is a + * reading one. + * + * It does not by itself mean edit or delete — a WRITE grant adds and does + * nothing more, and that stayed true when a third level arrived. A MANAGE + * grant answers true here as well, and what it additionally admits is said + * by `level` rather than smuggled into this boolean. */ canWrite: boolean; /** - * v1.38.12 — the sections this grant can add to, resolved server-side from - * the grant's level and scope against the delegated write routes that - * exist (`domain-write-support.ts`). In the consent screen's order. Empty - * for a READ grant, and empty for any section whose routes accept no - * delegated write at all — the vault, today. + * v1.38.12 — the sections with AT LEAST ONE delegated write route this + * grant satisfies, resolved server-side from its level and scope against + * the routes that exist (`domain-write-support.ts`). In the consent + * screen's order. Empty for a READ grant, and empty for any section whose + * routes accept no delegated write at all — the vault, today. + * + * Existential, and the word matters: within a section the answer varies by + * verb, and this list cannot say which. `profile` is writable because a + * visit, a practitioner and a dose answer at WRITE, while an allergy and a + * family-history entry are MANAGE creates; `measurements` is writable + * because a reading is, while a water entry and a checkup reminder are + * MANAGE. So a control does not read "my section is writable, therefore my + * create is": it asks the hook for its own verb class — + * `canWriteDomain` for a create the WRITE level covers, `canManageDomain` + * for one it does not. A per-verb table would be a second place the route + * declarations live, and the declarations are the thing that decides. * * A list rather than a boolean because the answer differs per section, and * the client's per-control question is "this section", never "any". diff --git a/src/lib/sharing/acting-domains.ts b/src/lib/sharing/acting-domains.ts new file mode 100644 index 000000000..039062bc2 --- /dev/null +++ b/src/lib/sharing/acting-domains.ts @@ -0,0 +1,33 @@ +/** + * Which sections of the record the caller is actually inside. + * + * Lives here rather than beside one feature because a route that reads or + * writes across its own declared section is a shape the product keeps growing, + * and its callers are already spread over four features. No list of them here: + * a comment that enumerates its own callers is a comment that goes stale on + * the next one. Two verbatim copies of this existed in the visits and the + * vaccination service, with a note on the second saying a second spelling + * would be a second thing to keep in step. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} diff --git a/src/lib/sharing/domain-write-support.ts b/src/lib/sharing/domain-write-support.ts index ad182b434..f5a2725e8 100644 --- a/src/lib/sharing/domain-write-support.ts +++ b/src/lib/sharing/domain-write-support.ts @@ -74,6 +74,15 @@ const LEVEL_RANK: Record = { * `sections === null` is the entire record. A MANAGE grant always carries it * (`grants.ts` refuses a scope on a MANAGE invitation), a WRITE grant may. * + * The answer is EXISTENTIAL per section, never per verb: "is there a route in + * this section at this need the grant satisfies". Three of the four sections + * that answer `write: true` also carry MANAGE-only creates — an allergy and a + * family-history entry under `profile`, a water entry and a checkup reminder + * under `measurements`, a snooze and a re-recorded dose under `medications` — + * and no list keyed on the section can distinguish them. A control asks the + * hook for its own verb class instead; the route declarations stay the one + * place the split is decided. + * * For `need === "write"` the answer is "is there a non-read route in this * section the grant satisfies": a WRITE grant reaches the section's `"write"` * routes, a MANAGE grant reaches those and its `"manage"` routes as well. diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index 784297cf4..5ea941070 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -68,7 +68,16 @@ export const SHARE_DOMAINS = [ "profile", /** Illness episodes and the day logs under them. */ "illness", - /** Mood entries and mental-health screener history. */ + /** + * Mood entries and mental-health screener history. + * + * One write in this section lands outside it, and the consent copy names it: + * completing a screener also files the instrument's total as a `COMPUTED` + * measurement, so the trend reads through the chart and rollup tier without + * the encrypted item content going anywhere. It is a projection of the + * administration rather than a second reading, and withholding it would file + * a screener whose own total is missing from the record. + */ "mind", /** Cycle tracking. */ "cycle", diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index 8d929ff5f..8d2212bd2 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -11,7 +11,6 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { listTargets, replaceTargets } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; import { @@ -37,29 +36,6 @@ export const SERIES_SELECT = { seriesDoses: true, } as const satisfies Prisma.VaccinationRecordSelect; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. Same shape as the visits service, deliberately - * — the two features sit on the same seam and a second spelling of this would - * be a second thing to keep in step. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * The whole live history for one account, in the shape the derivation wants. * diff --git a/src/lib/validations/__tests__/onboarding-needs.test.ts b/src/lib/validations/__tests__/onboarding-needs.test.ts new file mode 100644 index 000000000..76d8a9b74 --- /dev/null +++ b/src/lib/validations/__tests__/onboarding-needs.test.ts @@ -0,0 +1,105 @@ +/** + * v1.39 (C1) — what `PATCH /api/onboarding/answers` will and will not accept. + * + * The union has an answer arm per step and a skip arm for six of the nine, and + * the split is a decision rather than a shape: `who` is the one required + * question, and `confirm` / `done` are acknowledgements. A skip arm added to + * one of those three would let a client record a pass through a screen that + * does not offer one, so the arms are checked against + * `ONBOARDING_SKIPPABLE_STEP_IDS` rather than against a list written twice. + * + * The other property here is that a skip can never carry an answer. That is + * what makes "skipped, and my areas are …" unrepresentable rather than merely + * discouraged, and it is why the route can trust the parsed body without + * re-checking the combination. + */ +import { describe, expect, it } from "vitest"; + +import { + ONBOARDING_SKIPPABLE_STEP_IDS, + ONBOARDING_STEP_IDS, +} from "@/lib/onboarding/needs"; +import { onboardingAnswerSchema } from "../onboarding-needs"; + +const SKIPPABLE: readonly string[] = ONBOARDING_SKIPPABLE_STEP_IDS; + +describe("onboardingAnswerSchema", () => { + it("accepts a skip for exactly the steps that may be passed", () => { + for (const step of ONBOARDING_STEP_IDS) { + const parsed = onboardingAnswerSchema.safeParse({ + step, + status: "skipped", + }); + expect( + parsed.success, + `\`${step}\` ${SKIPPABLE.includes(step) ? "should" : "should not"} be skippable`, + ).toBe(SKIPPABLE.includes(step)); + } + }); + + it("refuses a skip that carries an answer", () => { + expect( + onboardingAnswerSchema.safeParse({ + step: "areas", + status: "skipped", + areas: ["sleep"], + }).success, + ).toBe(false); + }); + + it("refuses an answer for the wrong step", () => { + expect( + onboardingAnswerSchema.safeParse({ step: "areas", medication: "yes" }) + .success, + ).toBe(false); + }); + + it("refuses a value outside the closed set", () => { + expect( + onboardingAnswerSchema.safeParse({ step: "visit", visit: "someday" }) + .success, + ).toBe(false); + expect( + onboardingAnswerSchema.safeParse({ step: "areas", areas: ["astrology"] }) + .success, + ).toBe(false); + }); + + it("refuses an unknown key alongside a valid answer", () => { + expect( + onboardingAnswerSchema.safeParse({ + step: "who", + recordTarget: "me", + userId: "someone-else", + }).success, + ).toBe(false); + }); + + it("accepts each answered step with its own payload", () => { + const bodies: unknown[] = [ + { step: "who", recordTarget: "both" }, + { step: "areas", areas: ["glucose", "cycle"] }, + { step: "medication", medication: "sometimes" }, + { step: "sources", sources: ["nightscout", "manual"] }, + { step: "visit", visit: "later" }, + { step: "units", units: { glucoseUnit: "mmol/L" } }, + { step: "confirm" }, + { + step: "first-result", + firstResult: { + task: "log-reading", + target: "glucose", + completed: true, + }, + }, + { step: "done" }, + ]; + expect(bodies.length).toBe(ONBOARDING_STEP_IDS.length); + for (const body of bodies) { + expect( + onboardingAnswerSchema.safeParse(body).success, + JSON.stringify(body), + ).toBe(true); + } + }); +}); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 9be51be04..d163e6e36 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -886,6 +886,33 @@ const healthScoreRecordBackupSchema = z }) .passthrough(); +/** + * v1.39 (C1) — the needs-based setup state. One row per record. + * + * The three answer objects are validated loosely on purpose: the closed + * vocabularies live in `src/lib/onboarding/needs.ts`, and the restore reads + * every one of them back through the same fail-soft parsers the account + * payload uses. Duplicating the enums here would give the file a second, + * quietly divergent opinion about what an answer is, and the failure mode of + * being stricter here is refusing a whole account's restore over one retired + * chip. + */ +const onboardingRecordBackupSchema = z + .object({ + id: z.string().min(1).optional(), + needs: z.record(z.string(), z.unknown()).default({}), + steps: z.array(z.record(z.string(), z.unknown())).default([]), + firstResult: z.record(z.string(), z.unknown()).nullable().default(null), + // The once-only latch on the module derivation. Carried rather than + // defaulted: a null here would hand the next confirm permission to + // re-apply the questionnaire over every module decision made since. + modulesDerivedAt: isoDateTime.nullable().default(null), + completedAt: isoDateTime.nullable().default(null), + createdAt: isoDateTime.optional(), + updatedAt: isoDateTime.optional(), + }) + .passthrough(); + const appSettingsBackupSchema = z .object({ id: z.string().min(1), @@ -1668,6 +1695,10 @@ export const backupPayloadSchema = z // the sections above: a file written before the table existed carries no // key, and an account whose score never resolved writes []. healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]), + // The needs-based setup answers. Defaulted to null for the same reason as + // the sections above: a file written before the table existed carries no + // key, and a record that never entered the flow has no row. + onboardingRecord: onboardingRecordBackupSchema.nullable().default(null), // Visits, the address book behind them, and the three link tables. // Defaulted for the same reason as the sections above: a file written // before the tables existed carries no key, and an account that has never @@ -1796,6 +1827,8 @@ export interface BackupSummary { intradayProfiles: number; /** Local days whose health score was written down as it was shown. */ healthScoreRecords: number; + /** 1 when the record's setup answers ride the file, 0 otherwise. */ + onboardingRecords: number; /** v1.37.19 (A6-8) — the visit address book. */ practitioners: number; /** v1.37.19 (A6-8) — doctor visits, planned and past. */ @@ -1884,6 +1917,7 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary { correlationPatterns: payload.correlationPatterns.length, intradayProfiles: payload.intradayProfiles.length, healthScoreRecords: payload.healthScoreRecords.length, + onboardingRecords: payload.onboardingRecord ? 1 : 0, // v1.37.19 (A6-8) — the sections restored since 08-01 were written and // restored but absent from this report, so the admin's "what did I just // restore" answer silently under-counted a file that carried visits or diff --git a/src/lib/validations/onboarding-needs.ts b/src/lib/validations/onboarding-needs.ts new file mode 100644 index 000000000..043920fac --- /dev/null +++ b/src/lib/validations/onboarding-needs.ts @@ -0,0 +1,119 @@ +/** + * v1.39 (C1) — request schemas for the three needs-onboarding writes. + * + * They live outside the route files for the reason `./onboarding.ts` gives: a + * route module may only export handlers plus the Next.js route config, so the + * OpenAPI registry cannot import a schema declared inside one. Keeping the + * shapes here makes the published contract and the runtime parser the same + * object. + * + * Prisma-free by construction — everything below reads its vocabulary from + * `@/lib/onboarding/needs` and `@/lib/modules/registry`, both pure data. + */ +import { z } from "zod/v4"; + +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; +import { + ONBOARDING_FIRST_RESULT_TASKS, + ONBOARDING_MEDICATION_ANSWERS, + ONBOARDING_RECORD_TARGETS, + ONBOARDING_SOURCE_KEYS, + ONBOARDING_VISIT_ANSWERS, +} from "@/lib/onboarding/needs"; + +/** + * One answered step. `status` is optional and only ever `"done"` on this arm — + * a skip is the separate arm below, so a body can never claim to skip a step + * and carry an answer for it at the same time. + */ +function answered( + id: Id, + shape: Shape, +) { + return z + .object({ + step: z.literal(id), + status: z.literal("done").optional(), + ...shape, + }) + .strict(); +} + +/** One deliberately passed step. No answer may ride along. */ +function skipped(id: Id) { + return z + .object({ step: z.literal(id), status: z.literal("skipped") }) + .strict(); +} + +/** + * Q6 — the unit answers. Both fields are optional and nullable: the screen + * only appears when glucose or weight is among the areas, and it may set one + * without the other. The route writes the record's real display columns from + * these and records the answer beside them. + */ +export const onboardingUnitsSchema = z + .object({ + glucoseUnit: z.enum(["mg/dL", "mmol/L"]).nullable().optional(), + unitPreference: z.enum(["metric", "imperial"]).nullable().optional(), + }) + .strict(); + +/** + * The task the flow ended on. `completed` is what turns the offer into a + * result — the server stamps the instant rather than taking one from the + * client, so a replay cannot backdate it. + */ +export const onboardingFirstResultSchema = z + .object({ + task: z.enum(ONBOARDING_FIRST_RESULT_TASKS), + target: z.string().trim().min(1).max(64).nullable().optional(), + completed: z.boolean().optional(), + }) + .strict(); + +/** + * `PATCH /api/onboarding/answers` — one step at a time. + * + * A flat union rather than a discriminated one: each step id appears twice + * (answered and skipped) and `z.discriminatedUnion` needs one option per + * literal. The three steps that are not questions — `who` is required, and + * `confirm` / `done` are acknowledgements — have no skip arm, so a client + * cannot record a pass through a screen that does not offer one. + */ +export const onboardingAnswerSchema = z.union([ + answered("who", { recordTarget: z.enum(ONBOARDING_RECORD_TARGETS) }), + answered("areas", { + areas: z + .array(z.enum(ONBOARDING_AREA_KEYS)) + .max(ONBOARDING_AREA_KEYS.length), + }), + answered("medication", { + medication: z.enum(ONBOARDING_MEDICATION_ANSWERS), + }), + answered("sources", { + sources: z + .array(z.enum(ONBOARDING_SOURCE_KEYS)) + .max(ONBOARDING_SOURCE_KEYS.length), + }), + answered("visit", { visit: z.enum(ONBOARDING_VISIT_ANSWERS) }), + answered("units", { units: onboardingUnitsSchema }), + answered("confirm", {}), + answered("first-result", { firstResult: onboardingFirstResultSchema }), + answered("done", {}), + skipped("areas"), + skipped("medication"), + skipped("sources"), + skipped("visit"), + skipped("units"), + skipped("first-result"), +]); + +export type OnboardingAnswerInput = z.infer; + +/** + * `POST /api/onboarding/restart` — no input beyond the session. Strict and + * empty rather than unvalidated, so a client that starts sending a field gets + * a 422 that names it instead of having it silently ignored. + */ +export const onboardingRestartSchema = z.object({}).strict(); diff --git a/src/proxy.ts b/src/proxy.ts index a43c6cb32..97a86c93a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -158,6 +158,14 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = // Chart display-pref toggles above the charts (dashboard + insights): { path: "/api/dashboard/chart-overlay-prefs", method: "PUT" }, { path: "/api/dashboard/widgets", method: "PUT" }, + // v1.39 (C1) — the needs-based setup flow, which the demo has to be able + // to complete with preset answers (design spec §Principles 5). All three + // are user-scoped preference writes: closed enum answers, the step ledger, + // and the module map derived from them. No health data is created by any + // of them, and the demo account's own record is the only thing they touch. + { path: "/api/onboarding/answers", method: "PATCH" }, + { path: "/api/onboarding/complete", method: "POST" }, + { path: "/api/onboarding/restart", method: "POST" }, ]; // Legacy route redirects (German → English) diff --git a/src/types/backups.ts b/src/types/backups.ts index 1931628ea..635b65c27 100644 --- a/src/types/backups.ts +++ b/src/types/backups.ts @@ -10,6 +10,7 @@ * keeps owning the HTTP contract, the component keeps owning the UI. */ import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; +import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness"; export interface BackupRow { id: string; @@ -24,6 +25,41 @@ export interface BackupRow { createdAt: string; } +/** One account's newest object in the operator's off-host bucket. */ +export interface OffhostAccountRow { + userId: string; + username: string; + /** + * ISO instant a run last walked this account, or null when none has. Null + * is what the whole cohort reads until the first run after the upgrade that + * adds the ledger, and the card says so rather than claiming `never`. + */ + lastAttemptAt: string | null; + /** ISO instant the newest object landed, or null when there is none. */ + lastSuccessAt: string | null; + /** Size of that object in bytes, or null when there is none. */ + sizeBytes: number | null; + /** Whole hours since it landed, or null when there is none. */ + ageHours: number | null; + freshness: OffhostBackupFreshness; +} + +/** + * The off-host picture, per account. + * + * Computed from the ledger the nightly worker writes, never from the bucket: + * the page must be able to answer "does this account have a recent copy + * off-host" on a host whose worker holds no listing grant on the bucket. + */ +export interface OffhostBackupOverview { + /** Whether this host has the variables the nightly job needs. */ + configured: boolean; + /** Hours between two scheduled runs — the number behind the verdicts. */ + periodHours: number; + /** One row per account. Empty when off-host backup is not configured. */ + rows: OffhostAccountRow[]; +} + export interface BackupsList { rows: BackupRow[]; /** @@ -32,6 +68,12 @@ export interface BackupsList { * made six weeks ago and one made on Sunday look identical in a table. */ schedule: BackupScheduleStatus; + /** + * The off-host leg. A weekly row in this table says the copy that lives in + * this database is current; it says nothing about whether anything reached + * the operator's bucket, and those two fail independently. + */ + offhost: OffhostBackupOverview; /** * Soft retention hint — the worker is configured for weekly backups * (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 7ecb17c1a..8376beb3e 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -239,6 +239,8 @@ const COUNT_BACK: Record< p.medicationEfficacyTarget.count({ where: { medication: { userId } } }), MedicationScheduleRevision: (p, userId) => p.medicationScheduleRevision.count({ where: { medication: { userId } } }), + OnboardingRecord: (p, userId) => + p.onboardingRecord.count({ where: { userId } }), }; /** @@ -743,6 +745,35 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { lastComputedAt: AT("2026-07-01T00:00:00.000Z"), }, }); + await prisma.onboardingRecord.create({ + data: { + userId: OWNER_ID, + needsJson: { + recordTarget: "me", + areas: ["blood-pressure", "sleep"], + medication: "yes", + sources: ["withings"], + visit: "within-a-month", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }, + stepsJson: [ + { id: "who", status: "done" }, + { id: "areas", status: "done" }, + { id: "sources", status: "skipped" }, + ], + firstResultJson: { + task: "connect-source", + target: "withings", + completedAt: "2026-07-01T08:30:00.000Z", + }, + // The once-only latch on the module derivation. Seeded non-null so the + // assertion after the restore can show it came back rather than + // defaulting — a null here would hand the next confirm permission to + // re-apply the questionnaire over every module decision made since. + modulesDerivedAt: AT("2026-07-01T08:00:00.000Z"), + completedAt: AT("2026-07-01T08:00:00.000Z"), + }, + }); await prisma.healthScoreRecord.create({ data: { userId: OWNER_ID, @@ -1413,6 +1444,55 @@ describe("every model the plan claims two-ended survives a real restore", () => { pausedAt: "2026-07-15T08:00:00.000Z", resumedAt: null }, ]); + // The setup answers came back as answers, not as an empty questionnaire. + // + // Asserted column by column rather than by count: the row returning proves + // nothing about `modulesDerivedAt`, and that field is the once-only latch + // on the module derivation — a restore that let it default to null would + // hand the next confirm permission to re-apply the questionnaire over + // every module decision the account has taken since, silently. + const restoredOnboarding = await prisma.onboardingRecord.findUniqueOrThrow({ + where: { userId: OWNER_ID }, + }); + expect({ + needs: restoredOnboarding.needsJson, + steps: restoredOnboarding.stepsJson, + firstResult: restoredOnboarding.firstResultJson, + modulesDerivedAt: restoredOnboarding.modulesDerivedAt?.toISOString(), + completedAt: restoredOnboarding.completedAt?.toISOString(), + }).toEqual({ + needs: { + recordTarget: "me", + areas: ["blood-pressure", "sleep"], + medication: "yes", + sources: ["withings"], + visit: "within-a-month", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }, + // The stored blob is a status LOOKUP, and the reader answers with the + // full ordered nine — a step the fixture never mentioned comes back + // `pending` rather than missing, which is what lets the flow grow a + // screen without a migration. + steps: [ + { id: "who", status: "done" }, + { id: "areas", status: "done" }, + { id: "medication", status: "pending" }, + { id: "sources", status: "skipped" }, + { id: "visit", status: "pending" }, + { id: "units", status: "pending" }, + { id: "confirm", status: "pending" }, + { id: "first-result", status: "pending" }, + { id: "done", status: "pending" }, + ], + firstResult: { + task: "connect-source", + target: "withings", + completedAt: "2026-07-01T08:30:00.000Z", + }, + modulesDerivedAt: "2026-07-01T08:00:00.000Z", + completedAt: "2026-07-01T08:00:00.000Z", + }); + // The Coach came back able to speak, and the fence held. // // `documentScoped` is asserted per thread rather than in aggregate: a diff --git a/tests/integration/cycle-insights-scoped-delegate.test.ts b/tests/integration/cycle-insights-scoped-delegate.test.ts new file mode 100644 index 000000000..482afc6f5 --- /dev/null +++ b/tests/integration/cycle-insights-scoped-delegate.test.ts @@ -0,0 +1,329 @@ +/** + * What a cycle-only delegate learns from `GET /api/cycle/insights`. + * + * The route is declared `("read", "cycle")`, and everything about the phase + * labels belongs there. Its outcome columns do not: `lutealAvg`, + * `follicularAvg` and `delta` carry resting heart rate, HRV, sleep, steps, + * weight, temperature, glucose and the mood score in display units, and those + * are `measurements` and `mind` data reached through a cycle grant. + * + * So two properties, both about somebody holding `["cycle"]` and nothing else: + * + * 1. **No foreign figure crosses the seam.** No row whose `metricKey` names + * a measurement channel, no mood row, and no lagged discovery built out + * of either. + * 2. **The cycle-native answer still arrives.** The symptom-by-phase + * patterns are cycle data and stay, so the fix is a fence rather than a + * blanket refusal. + * + * The unscoped grant is asserted in the same fixture, because a change that + * hid the figures from everyone would satisfy the first property and break + * the feature. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "cycle-insights-owner"; +const DELEGATE_ID = "cycle-insights-delegate"; + +/** Cycle geometry the fixture logs, chosen so both phases clear the day floor. */ +const CYCLE_LENGTH = 28; +const PERIOD_LENGTH = 5; +const OBSERVED_CYCLES = 5; + +function isoDay(offsetDays: number): string { + const d = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000); + return d.toISOString().slice(0, 10); +} + +function atNoon(day: string): Date { + return new Date(`${day}T12:00:00Z`); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Sign in as the delegate and switch into the owner's record. */ +async function switchInto(scope: ShareDomain[] | null) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +/** + * Five observed cycles, then a weight and a mood value on every labelled day + * whose size depends on the phase. + * + * The separation is deliberately huge (80 kg vs 70 kg, mood 2 vs 4) so the + * Welch + Benjamini-Hochberg gate the route runs cannot be the reason a row is + * missing: with this fixture a missing row means the fence removed it. + */ +async function seedRecord() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `cycle-insights-${name}`, + email: `cycle-insights-${name}@example.test`, + gender: "FEMALE", + timezone: "UTC", + }, + }); + } + + // Prediction off, so the phase map the route builds is the one this fixture + // can reproduce from the logged cycles alone. + await prisma.cycleProfile.create({ + data: { + userId: OWNER_ID, + cycleTrackingEnabled: true, + predictionEnabled: false, + lutealPhaseLength: 14, + }, + }); + + const starts: string[] = []; + for (let i = OBSERVED_CYCLES; i >= 1; i--) { + starts.push(isoDay(-i * CYCLE_LENGTH)); + } + for (const [index, startDate] of starts.entries()) { + const next = starts[index + 1] ?? null; + await prisma.menstrualCycle.create({ + data: { + userId: OWNER_ID, + startDate, + endDate: next + ? isoDay(-(OBSERVED_CYCLES - index) * CYCLE_LENGTH - 1 + CYCLE_LENGTH) + : null, + periodEndDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + PERIOD_LENGTH - 1, + ), + ovulationDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + CYCLE_LENGTH - 15, + ), + tz: "UTC", + }, + }); + } + + const { buildPhaseDayMap } = await import("@/lib/cycle/engine-adapter"); + const cycles = await prisma.menstrualCycle.findMany({ + where: { userId: OWNER_ID }, + orderBy: { startDate: "asc" }, + }); + const today = isoDay(0); + const phaseByDay = buildPhaseDayMap( + cycles, + null, + 14, + isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH), + today, + today, + ); + + // One catalogue symptom, logged on enough phase-labelled days to clear the + // pattern floor. Without it `symptomPatterns` is empty in every leg and the + // "the cycle-native answer still arrives" property could not be shown even + // by a leg that asserted it. + // + // Upserted rather than created: the symptom catalogue is seeded by migration + // and survives the per-test truncation, so a plain create passes on the + // first leg and collides on the second. + const category = await prisma.cycleSymptomCategory.upsert({ + where: { key: "cycle-insights-cat" }, + update: {}, + create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" }, + }); + const symptom = await prisma.cycleSymptom.upsert({ + where: { key: "cycle-insights-cramps" }, + update: {}, + create: { + categoryId: category.id, + key: "cycle-insights-cramps", + labelKey: "cycle.symptom.cramps", + }, + }); + + let luteal = 0; + let follicular = 0; + for (const [day, phase] of phaseByDay) { + if (phase !== "LUTEAL" && phase !== "FOLLICULAR") continue; + const high = phase === "LUTEAL"; + if (high) luteal++; + else follicular++; + // A little jitter in both channels, so the Welch test has a variance to + // work with and the row is not dropped for a reason the fence did not + // cause. + const jitter = ((luteal + follicular) % 3) * 0.1; + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "WEIGHT", + value: (high ? 80 : 70) + jitter, + unit: "kg", + measuredAt: atNoon(day), + }, + }); + await prisma.moodEntry.create({ + data: { + userId: OWNER_ID, + mood: high ? "SCHLECHT" : "GUT", + score: (high ? 1 : 4) + ((luteal + follicular) % 2), + date: day, + moodLoggedAt: atNoon(day), + tz: "UTC", + }, + }); + // Cycle-native data, logged on the luteal days only so the pattern has a + // dominant phase to report. + if (high) { + await prisma.cycleDayLog.create({ + data: { + userId: OWNER_ID, + date: day, + tz: "UTC", + symptomLinks: { create: [{ symptomId: symptom.id }] }, + }, + }); + } + } + + expect( + luteal, + "the fixture labels enough luteal days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); + expect( + follicular, + "the fixture labels enough follicular days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); +} + +interface InsightsBody { + rows: Array<{ metricKey: string }>; + headline: { metricKey: string } | null; + lagged: { discovered: unknown[] }; + symptomPatterns: Array<{ symptomKey: string; topPhase: string }>; +} + +async function readInsights(): Promise { + const { GET } = await import("@/app/api/cycle/insights/route"); + // The handler declares no parameters; the wrapper still reads the request + // off its first argument, and the method it carries is what the record + // resolver escalates on. + const handler = GET as unknown as (request: Request) => Promise; + const res = await handler(new Request("http://localhost/api/cycle/insights")); + expect(res.status).toBe(200); + const parsed = (await res.json()) as { data: InsightsBody }; + return parsed.data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecord(); +}); + +describe("cycle insights fence the sections it reads across", () => { + it("gives the owner the measurement and the mood contrast", async () => { + await signIn(OWNER_ID); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys, "the weight contrast is the fixture's whole point").toContain( + "weight", + ); + expect(keys, "and the mood contrast beside it").toContain("mood"); + expect( + body.symptomPatterns.map((row) => row.symptomKey), + "and the cycle-native patterns the fence must not touch", + ).toContain("cycle-insights-cramps"); + }); + + it("gives an entire-record delegate the same figures", async () => { + await switchInto(null); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys).toContain("weight"); + expect(keys).toContain("mood"); + }); + + it("gives a cycle-only delegate no measurement and no mood figure", async () => { + await switchInto(["cycle"]); + const body = await readInsights(); + + expect( + body.rows, + "every crosstab row is a measurement or a mood column", + ).toHaveLength(0); + expect(body.headline).toBeNull(); + expect( + body.lagged.discovered, + "the lagged matrix is built from the same two sections", + ).toHaveLength(0); + // The other half of the property, and the one a blanket refusal would + // break: the symptom-by-phase patterns are cycle data and the grant opens + // cycle, so they arrive in full. + expect( + body.symptomPatterns.map((row) => row.symptomKey), + "the cycle-native answer still arrives", + ).toContain("cycle-insights-cramps"); + }); + + it("gives a cycle+mind delegate the mood figure and not the weight", async () => { + await switchInto(["cycle", "mind"]); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect( + keys, + "mind is held, so the mood column is within the grant", + ).toContain("mood"); + expect(keys, "measurements is not held").not.toContain("weight"); + }); +}); diff --git a/tests/integration/medication-write-idempotency.test.ts b/tests/integration/medication-write-idempotency.test.ts index 9f3277c4a..b83a9d138 100644 --- a/tests/integration/medication-write-idempotency.test.ts +++ b/tests/integration/medication-write-idempotency.test.ts @@ -1,5 +1,5 @@ /** - * The three medication create routes the native client replays out of its + * The four medication create routes the native client replays out of its * offline outbox, driven as routes against a real Postgres. * * Each case imports the shipped `POST` export and calls it twice with the same @@ -10,8 +10,8 @@ * keep passing after a route dropped it. * * The client sends the header on every replay, so a lost success response used - * to cost the user a duplicate medication, a duplicate symptom entry, or a - * duplicate pen in the supply count. `POST /api/medications` had a dedupe of + * to cost the user a duplicate medication, a duplicate symptom entry, a + * duplicate pen in the supply count, or a second titration step. `POST /api/medications` had a dedupe of * its own, but only for a MIRRORED create keyed on * `(externalSource, externalId)`; a manually entered medication carries * neither field and had nothing to collapse a retry onto — so the manual shape @@ -228,3 +228,41 @@ describe("POST /api/medications/[id]/inventory — replayed container", () => { expect(rows).toHaveLength(1); }); }); + +describe("POST /api/medications/[id]/glp1 — replayed titration step", () => { + it("replays the first response and writes exactly one dose change", async () => { + const medicationId = await seedMedication(); + const { POST } = await import("@/app/api/medications/[id]/glp1/route"); + const body = { + doseChange: { + effectiveFrom: "2026-09-01T00:00:00Z", + doseValue: 2.5, + doseUnit: "mg", + note: "stepped up after four weeks", + }, + }; + + const first = await (POST as Handler)( + post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + // The titration row carries no natural key at all, so without the wrapper + // the replay would leave the medication with two dose changes on the same + // day and the runway estimate reading off the wrong one. + const rows = await getPrismaClient().medicationDoseChange.findMany({ + where: { medicationId }, + }); + expect(rows).toHaveLength(1); + }); +}); diff --git a/tests/integration/offhost-backup-ledger.test.ts b/tests/integration/offhost-backup-ledger.test.ts new file mode 100644 index 000000000..f2047fc9a --- /dev/null +++ b/tests/integration/offhost-backup-ledger.test.ts @@ -0,0 +1,231 @@ +/** + * The off-host ledger, end to end against real Postgres. + * + * Both ends and the pipe: the nightly job is what writes the row, the admin + * console is what reads it, and a unit test that mocks Prisma proves neither + * the migration nor the shape they agree on. Here the real + * `runOffhostBackup()` uploads into an in-memory bucket double, the row lands + * in the real table, and the verdict the console shows is computed from what + * came back out. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Buffer } from "node:buffer"; +import type { Readable } from "node:stream"; + +import { + classifyOffhostBackup, + OFFHOST_BACKUP_GRACE_HOURS, + OFFHOST_BACKUP_PERIOD_HOURS, +} from "@/lib/jobs/offhost-backup-freshness"; +import { + offhostBackupConfigured, + runOffhostBackup, + type S3Like, +} from "@/lib/jobs/offhost-backup"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const HOUR = 3_600_000; + +/** The five variables the job insists on, none of which reach a network here. */ +const ENV: Record = { + BACKUP_S3_ENDPOINT: "http://bucket.invalid", + BACKUP_S3_BUCKET: "healthlog-test", + BACKUP_S3_ACCESS_KEY: "test-access-key", + BACKUP_S3_SECRET_KEY: "test-secret-key", + BACKUP_ENCRYPTION_KEY: + "1111111111111111111111111111111111111111111111111111111111111111", +}; + +function makeBucket(): S3Like & { store: Map } { + const store = new Map(); + return { + store, + putObject: async (key, body) => { + store.set(key, Buffer.from(body)); + }, + putStream: async (key: string, body: Readable) => { + const chunks: Buffer[] = []; + for await (const chunk of body) chunks.push(Buffer.from(chunk)); + store.set(key, Buffer.concat(chunks)); + }, + getObject: async (key) => store.get(key) ?? Buffer.alloc(0), + headObject: async (key) => store.has(key), + listObjects: async () => [], + deleteObject: async (key) => { + store.delete(key); + }, + }; +} + +async function seedUser(id: string): Promise { + await getPrismaClient().user.create({ + data: { + id, + username: id, + email: `${id}@example.test`, + timezone: "Europe/Berlin", + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + for (const [name, value] of Object.entries(ENV)) process.env[name] = value; +}); + +afterEach(() => { + for (const name of Object.keys(ENV)) delete process.env[name]; +}); + +describe("off-host backup ledger (real Postgres)", () => { + it("writes one row per uploaded account and reads back as fresh", async () => { + const prisma = getPrismaClient(); + await seedUser("account-one"); + await seedUser("account-two"); + + expect(offhostBackupConfigured()).toBe(true); + + const bucket = makeBucket(); + const report = await runOffhostBackup(prisma, bucket, new Date()); + expect(report.uploaded).toBe(2); + expect(report.failed).toBe(0); + + const rows = await prisma.offhostBackupState.findMany({ + orderBy: { userId: "asc" }, + }); + expect(rows.map((row) => row.userId)).toEqual([ + "account-one", + "account-two", + ]); + + for (const row of rows) { + // The size the ledger claims is the size of the object that landed, not + // an estimate: an operator sizing a bucket reads this column. + const object = bucket.store.get( + `${new Date().toISOString().slice(0, 10)}/user-${row.userId}.json.enc`, + ); + expect(object).toBeDefined(); + expect(Number(row.sizeBytes)).toBe(object?.byteLength); + + expect( + classifyOffhostBackup({ + lastAttemptAt: row.lastAttemptAt, + lastSuccessAt: row.lastSuccessAt, + now: new Date(), + }).freshness, + ).toBe("fresh"); + } + }); + + it("keeps one row per account across runs and ages into stale", async () => { + const prisma = getPrismaClient(); + await seedUser("account-one"); + + const bucket = makeBucket(); + await runOffhostBackup(prisma, bucket, new Date()); + await runOffhostBackup(prisma, bucket, new Date()); + + expect(await prisma.offhostBackupState.count()).toBe(1); + + // Age the row past two nightly runs — the state the console must not miss, + // because the run that skipped this account still reported a success for + // everybody else. + const aged = new Date( + Date.now() - + (OFFHOST_BACKUP_PERIOD_HOURS * 2 + OFFHOST_BACKUP_GRACE_HOURS + 1) * + HOUR, + ); + await prisma.offhostBackupState.update({ + where: { userId: "account-one" }, + data: { lastSuccessAt: aged }, + }); + + const row = await prisma.offhostBackupState.findUniqueOrThrow({ + where: { userId: "account-one" }, + }); + expect( + classifyOffhostBackup({ + lastAttemptAt: row.lastAttemptAt, + lastSuccessAt: row.lastSuccessAt, + now: new Date(), + }).freshness, + ).toBe("stale"); + }); + + it("separates an account no run has recorded from one a run found nothing for", async () => { + const prisma = getPrismaClient(); + await seedUser("account-one"); + + // Before any run: the ledger is empty, which is what every account on a + // host that has been uploading for months looks like the moment this + // table ships. It must not read as "nothing has ever reached the bucket". + expect( + classifyOffhostBackup({ + lastAttemptAt: null, + lastSuccessAt: null, + now: new Date(), + }).freshness, + ).toBe("unknown"); + + // A run that reaches the account and produces no object records the walk + // and leaves the success columns alone. + const bucket = makeBucket(); + bucket.putStream = async () => { + throw new Error("bucket refused the signature"); + }; + const report = await runOffhostBackup(prisma, bucket, new Date()); + expect(report.uploaded).toBe(0); + expect(report.failed).toBe(1); + + const row = await prisma.offhostBackupState.findUniqueOrThrow({ + where: { userId: "account-one" }, + }); + expect(row.lastSuccessAt).toBeNull(); + expect(row.sizeBytes).toBeNull(); + expect( + classifyOffhostBackup({ + lastAttemptAt: row.lastAttemptAt, + lastSuccessAt: row.lastSuccessAt, + now: new Date(), + }).freshness, + ).toBe("never"); + }); + + it("keeps the last good copy on the row when a later run fails", async () => { + const prisma = getPrismaClient(); + await seedUser("account-one"); + + const bucket = makeBucket(); + await runOffhostBackup(prisma, bucket, new Date()); + const good = await prisma.offhostBackupState.findUniqueOrThrow({ + where: { userId: "account-one" }, + }); + expect(good.lastSuccessAt).not.toBeNull(); + + bucket.putStream = async () => { + throw new Error("bucket refused the signature"); + }; + await runOffhostBackup(prisma, bucket, new Date()); + + const after = await prisma.offhostBackupState.findUniqueOrThrow({ + where: { userId: "account-one" }, + }); + // The failed run moved the walk instant and nothing else: yesterday's + // copy is still the copy this account has off-host. + expect(after.lastSuccessAt?.getTime()).toBe(good.lastSuccessAt?.getTime()); + expect(after.sizeBytes).toBe(good.sizeBytes); + expect(after.lastAttemptAt.getTime()).toBeGreaterThanOrEqual( + good.lastAttemptAt.getTime(), + ); + }); + + it("goes with the account it describes", async () => { + const prisma = getPrismaClient(); + await seedUser("account-one"); + await runOffhostBackup(prisma, makeBucket(), new Date()); + expect(await prisma.offhostBackupState.count()).toBe(1); + + await prisma.user.delete({ where: { id: "account-one" } }); + expect(await prisma.offhostBackupState.count()).toBe(0); + }); +}); diff --git a/tests/integration/onboarding-needs.test.ts b/tests/integration/onboarding-needs.test.ts new file mode 100644 index 000000000..68b1b1280 --- /dev/null +++ b/tests/integration/onboarding-needs.test.ts @@ -0,0 +1,647 @@ +/** + * The three needs-onboarding writes against real Postgres. + * + * The unit suite proves the pure parts — the area map, the merge that never + * retracts a hand-made decision, the apply function's idempotence. This file + * proves the four things a unit suite cannot, because each is about what is in + * the database afterwards: + * + * * **The answers persist and a replay changes nothing.** Idempotence is a + * claim on the wire, and a route can satisfy the pure function and still + * write a fresh row each time. + * * **The derivation happens once.** The second confirm has to leave the + * module map alone, including a module switched off in Settings between + * the two calls — that is the whole reason the latch exists. + * * **A restart resets the steps and not the modules.** The two are written + * to different tables by different routes, so only a real run can show + * that the second one stays where it was. + * * **A delegate cannot write another record's setup.** Proved with a live + * accepted grant and a switched session, not with a mocked resolver. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; +import { emptyOnboardingNeeds } from "@/lib/onboarding/needs"; +import type { + OnboardingStateDto, + OnboardingStepId, +} from "@/lib/onboarding/needs"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `onb-${suffix}`, + email: `onb-${suffix}@example.test`, + role: "USER", + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Attach the record-session assertion the shipped browser client attaches. */ +async function assertCurrentContext(sessionId: string) { + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + headerJar.set("x-healthlog-record-epoch", String(row.recordEpoch)); + headerJar.set("x-healthlog-record-scope", row.actingAsUserId ?? "self"); +} + +function jsonRequest(path: string, method: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function patchAnswer(body: unknown): Promise { + const { PATCH } = await import("@/app/api/onboarding/answers/route"); + return (PATCH as (r: Request) => Promise)( + jsonRequest("/api/onboarding/answers", "PATCH", body), + ); +} + +async function postComplete(body: unknown = {}): Promise { + const { POST } = await import("@/app/api/onboarding/complete/route"); + return (POST as (r: Request) => Promise)( + jsonRequest("/api/onboarding/complete", "POST", body), + ); +} + +async function postRestart(): Promise { + const { POST } = await import("@/app/api/onboarding/restart/route"); + return (POST as (r: Request) => Promise)( + jsonRequest("/api/onboarding/restart", "POST", {}), + ); +} + +/** The `onboarding` field the account payload publishes for the caller. */ +async function readMe(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me"), + ); + const body = (await res.json()) as { + data: { onboarding: OnboardingStateDto }; + }; + return body.data.onboarding; +} + +async function readState(res: Response): Promise { + const body = (await res.json()) as { + data: { onboarding: OnboardingStateDto }; + }; + return body.data.onboarding; +} + +function statusOf(state: OnboardingStateDto, id: OnboardingStepId) { + return state.steps.find((step) => step.id === id)?.status; +} + +/** + * The answers a "blood pressure and medication" setup gives — all six + * questions, because the confirm screen is only reached once every one of them + * has an answer or a deliberate pass. + */ +async function answerTheQuestions() { + await patchAnswer({ step: "who", recordTarget: "me" }); + await patchAnswer({ step: "areas", areas: ["blood-pressure", "labs"] }); + await patchAnswer({ step: "medication", medication: "yes" }); + await patchAnswer({ step: "sources", status: "skipped" }); + await patchAnswer({ step: "visit", visit: "no" }); + await patchAnswer({ step: "units", status: "skipped" }); +} + +async function modulePrefs(userId: string) { + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { modulePreferencesJson: true }, + }); + return (row.modulePreferencesJson ?? {}) as Record; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PATCH /api/onboarding/answers", () => { + it("saves one step at a time and answers with the full nine", async () => { + const user = await makeUser("answers"); + await signIn(user.id); + + const first = await patchAnswer({ step: "who", recordTarget: "both" }); + expect(first.status).toBe(200); + const state = await readState(first); + expect(state.needs.recordTarget).toBe("both"); + expect(state.steps).toHaveLength(9); + expect(statusOf(state, "who")).toBe("done"); + expect(statusOf(state, "areas")).toBe("pending"); + + const second = await readState( + await patchAnswer({ step: "areas", areas: ["sleep"] }), + ); + expect(second.needs.recordTarget).toBe("both"); + expect(second.needs.areas).toEqual(["sleep"]); + expect(statusOf(second, "areas")).toBe("done"); + }); + + it("is idempotent — a replay leaves one row and the same state", async () => { + const user = await makeUser("replay"); + await signIn(user.id); + + const once = await readState( + await patchAnswer({ step: "medication", medication: "sometimes" }), + ); + const twice = await readState( + await patchAnswer({ step: "medication", medication: "sometimes" }), + ); + expect(twice).toEqual(once); + expect( + await getPrismaClient().onboardingRecord.count({ + where: { userId: user.id }, + }), + ).toBe(1); + }); + + it("records a skip without erasing the answer already given", async () => { + const user = await makeUser("skip"); + await signIn(user.id); + + await patchAnswer({ step: "visit", visit: "within-a-month" }); + const skipped = await readState( + await patchAnswer({ step: "visit", status: "skipped" }), + ); + expect(statusOf(skipped, "visit")).toBe("skipped"); + expect(skipped.needs.visit).toBe("within-a-month"); + }); + + it("writes the record's real unit columns, and records the answer beside them", async () => { + const user = await makeUser("units"); + await signIn(user.id); + + const state = await readState( + await patchAnswer({ + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "imperial" }, + }), + ); + expect(state.needs.units).toEqual({ + glucoseUnit: "mmol/L", + unitPreference: "imperial", + }); + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { glucoseUnit: true, unitPreference: true }, + }); + expect(row).toEqual({ glucoseUnit: "mmol/L", unitPreference: "imperial" }); + + // Same trail as the two dedicated settings routes, so it does not matter + // which surface the person changed the units from. + const actions = ( + await getPrismaClient().auditLog.findMany({ + where: { userId: user.id }, + select: { action: true }, + }) + ).map((entry) => entry.action); + expect(actions).toContain("user.glucose-unit.update"); + expect(actions).toContain("user.unit-preference.update"); + }); + + it("does not re-ask the units the account already holds", async () => { + const user = await makeUser("units-held"); + await signIn(user.id); + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { glucoseUnit: "mg/dL", unitPreference: "metric" }, + }); + + const state = await readState( + await patchAnswer({ step: "who", recordTarget: "me" }), + ); + // Nobody answered Q6; the account's own columns did. + expect(statusOf(state, "units")).toBe("done"); + expect(state.needs.units).toEqual({ + glucoseUnit: null, + unitPreference: null, + }); + + // Half a preference is not an answer. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { unitPreference: null }, + }); + const half = await readState( + await patchAnswer({ step: "who", recordTarget: "me" }), + ); + expect(statusOf(half, "units")).toBe("pending"); + }); + + it("refuses a body that names a step it does not answer", async () => { + const user = await makeUser("invalid"); + await signIn(user.id); + + const res = await patchAnswer({ step: "areas", medication: "yes" }); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("onboarding.answers.invalid"); + expect( + await getPrismaClient().onboardingRecord.count({ + where: { userId: user.id }, + }), + ).toBe(0); + }); +}); + +describe("POST /api/onboarding/complete", () => { + it("derives the module map from the answers, once", async () => { + const user = await makeUser("derive"); + await signIn(user.id); + await answerTheQuestions(); + + const first = await postComplete(); + expect(first.status).toBe(200); + + const derived = await modulePrefs(user.id); + // What the answers named. + expect(derived.medications).toBe(true); + expect(derived.labs).toBe(true); + expect(derived.insights).toBe(true); + expect(derived.achievements).toBe(true); + // What they did not. + expect(derived.workouts).toBe(false); + expect(derived.mood).toBe(false); + expect(derived.doctorReport).toBe(false); + expect(derived.vaccinations).toBe(false); + // The delegated keys are never written into the blob. + expect(derived).not.toHaveProperty("cycle"); + expect(derived).not.toHaveProperty("coach"); + + const state = await readState(first); + expect(statusOf(state, "confirm")).toBe("done"); + expect(state.completedAt).not.toBeNull(); + + // The activity panel has to be able to answer "why did my modules change". + const audit = await getPrismaClient().auditLog.findMany({ + where: { userId: user.id, action: "user.modules.update" }, + }); + expect(audit).toHaveLength(1); + const details = JSON.parse(audit[0].details ?? "{}") as { + changed?: string[]; + source?: string; + }; + expect(details.changed).toContain("medications"); + expect(details.source).toBe("onboarding"); + + // Somebody changes their mind in Settings, then the confirm screen is + // replayed. The second call must leave that decision alone: the latch is + // what stops a questionnaire from re-applying itself over a later choice. + // The completion instant is latched with it — a replay does not move the + // moment the setup finished at. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { ...derived, labs: false } }, + }); + const second = await postComplete(); + expect(second.status).toBe(200); + expect((await modulePrefs(user.id)).labs).toBe(false); + expect((await readState(second)).completedAt).toBe(state.completedAt); + }); + + it("never switches off a module the person switched on by hand", async () => { + const user = await makeUser("handmade"); + await signIn(user.id); + // The remote MCP endpoint is off by default and nothing in the flow asks + // for it, so a derivation that ignored the stored map would retract it. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { mcp: true } }, + }); + await answerTheQuestions(); + await postComplete(); + + expect((await modulePrefs(user.id)).mcp).toBe(true); + }); + + it("never switches off a domain the record already holds rows in", async () => { + const user = await makeUser("established"); + await signIn(user.id); + // An account that has been running for years: mood entries and workouts, + // no stored module preference at all, because both were default-on and + // nobody ever touched a toggle. The questions below name neither. + await getPrismaClient().moodEntry.create({ + data: { + userId: user.id, + date: "2026-09-01", + mood: "GUT", + score: 4, + moodLoggedAt: new Date("2026-09-01T18:00:00.000Z"), + }, + }); + await getPrismaClient().workout.create({ + data: { + userId: user.id, + sportType: "RUNNING", + startedAt: new Date("2026-09-01T06:00:00.000Z"), + endedAt: new Date("2026-09-01T06:30:00.000Z"), + durationSec: 1800, + }, + }); + + await answerTheQuestions(); + await postComplete(); + + const prefs = await modulePrefs(user.id); + // Left alone rather than written false: absence is what "on" looks like + // in the stored map, and the surfaces stay. + expect(prefs).not.toHaveProperty("mood"); + expect(prefs).not.toHaveProperty("workouts"); + // A domain with nothing in it is still switched off by the answers. + expect(prefs.illness).toBe(false); + expect(prefs.vaccinations).toBe(false); + }); + + it("switches cycle tracking on through its own column when the area is chosen", async () => { + const user = await makeUser("cycle"); + await signIn(user.id); + await patchAnswer({ step: "who", recordTarget: "me" }); + await patchAnswer({ step: "areas", areas: ["cycle"] }); + await patchAnswer({ step: "medication", status: "skipped" }); + await patchAnswer({ step: "sources", status: "skipped" }); + await patchAnswer({ step: "visit", status: "skipped" }); + await patchAnswer({ step: "units", status: "skipped" }); + await postComplete(); + + const profile = await getPrismaClient().cycleProfile.findUniqueOrThrow({ + where: { userId: user.id }, + }); + expect(profile.cycleTrackingEnabled).toBe(true); + }); + + it("derives nothing from a questionnaire that was never finished", async () => { + const user = await makeUser("half"); + await signIn(user.id); + // Q1 and nothing else — the tab was closed, or the legacy four-step wizard + // posted here afterwards. Either way the remaining five questions have no + // answer, and reading them as their conservative defaults would switch off + // eleven surfaces and latch the result. + await patchAnswer({ step: "who", recordTarget: "me" }); + + const res = await postComplete(); + expect(res.status).toBe(200); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { modulePreferencesJson: true }, + }); + expect(row.modulePreferencesJson).toBeNull(); + + const record = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: user.id }, + }); + expect(record.modulesDerivedAt).toBeNull(); + expect(record.completedAt).toBeNull(); + + const state = await readState(res); + expect(statusOf(state, "confirm")).toBe("pending"); + + // Finishing the questions later still derives, so the gate defers the + // work rather than losing it. + await answerTheQuestions(); + await postComplete(); + expect((await modulePrefs(user.id)).medications).toBe(true); + }); + + it("leaves a record that never answered the questions exactly as it was", async () => { + const user = await makeUser("legacy"); + await signIn(user.id); + + const res = await postComplete({ heightCm: 180 }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { completed: boolean; onboarding?: unknown }; + }; + expect(body.data.completed).toBe(true); + expect(body.data.onboarding).toBeUndefined(); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { modulePreferencesJson: true, onboardingCompletedAt: true }, + }); + expect(row.modulePreferencesJson).toBeNull(); + expect(row.onboardingCompletedAt).not.toBeNull(); + }); +}); + +describe("POST /api/onboarding/restart", () => { + it("resets the steps and leaves the modules where they are", async () => { + const user = await makeUser("restart"); + await signIn(user.id); + await answerTheQuestions(); + await postComplete(); + const derived = await modulePrefs(user.id); + + const res = await postRestart(); + expect(res.status).toBe(200); + const state = await readState(res); + expect(state.steps.every((step) => step.status === "pending")).toBe(true); + expect(state.completedAt).toBeNull(); + // The answers survive as the prefill for the re-run. + expect(state.needs.medication).toBe("yes"); + expect(await modulePrefs(user.id)).toEqual(derived); + + // The latch is cleared, so the next confirm may derive again — and a + // module switched off by hand in between still survives it. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { ...derived, labs: true } }, + }); + await patchAnswer({ step: "who", recordTarget: "me" }); + await patchAnswer({ step: "areas", areas: [] }); + await patchAnswer({ step: "medication", medication: "no" }); + await patchAnswer({ step: "sources", status: "skipped" }); + await patchAnswer({ step: "visit", status: "skipped" }); + await patchAnswer({ step: "units", status: "skipped" }); + await postComplete(); + // The derivation really did run the second time round, so the survival + // below is the merge protecting a hand-made decision and not the gate + // refusing to look. + const reran = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: user.id }, + }); + expect(reran.modulesDerivedAt).not.toBeNull(); + expect((await modulePrefs(user.id)).labs).toBe(true); + }); + + it("leaves the first-run redirect's own stamp alone", async () => { + const user = await makeUser("redirect"); + await signIn(user.id); + await answerTheQuestions(); + await postComplete(); + await postRestart(); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { onboardingCompletedAt: true }, + }); + expect(row.onboardingCompletedAt).not.toBeNull(); + }); +}); + +describe("a delegate and somebody else's record", () => { + it("refuses every setup write while the session is acting on another record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + await assertCurrentContext(session.id); + + for (const call of [ + () => patchAnswer({ step: "who", recordTarget: "me" }), + () => postComplete(), + () => postRestart(), + ]) { + const res = await call(); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + } + + // Neither record acquired a setup row — not the owner's, and not the + // delegate's own, which is the quiet failure a fall-back would produce. + expect(await getPrismaClient().onboardingRecord.count()).toBe(0); + const ownerRow = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: owner.id }, + select: { modulePreferencesJson: true, onboardingCompletedAt: true }, + }); + expect(ownerRow.modulePreferencesJson).toBeNull(); + expect(ownerRow.onboardingCompletedAt).toBeNull(); + }); + + it("publishes the RECORD's setup state on the account payload, not the actor's", async () => { + const owner = await makeUser("scoped-owner"); + const delegate = await makeUser("scoped-delegate"); + + // The owner sets up; the delegate never does. + await signIn(owner.id); + await answerTheQuestions(); + await postComplete(); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + + cookieJar.clear(); + headerJar.clear(); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + await assertCurrentContext(session.id); + + // The checklist reads this beside the record's reading count and + // medication count, both of which answer for the record under a switch. + // Answering it for the actor would order one record's rows by another + // person's answers. + const state = await readMe(); + expect(state.needs.medication).toBe("yes"); + expect(state.needs.areas).toEqual(["blood-pressure", "labs"]); + expect(state.completedAt).not.toBeNull(); + expect(statusOf(state, "confirm")).toBe("done"); + }); + + it("empties the answers under a grant that opens only one section", async () => { + const owner = await makeUser("section-owner"); + const delegate = await makeUser("section-delegate"); + + await signIn(owner.id); + await answerTheQuestions(); + await postComplete(); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope: ["measurements"], + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + + cookieJar.clear(); + headerJar.clear(); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + await assertCurrentContext(session.id); + + // The answers name the domains the record tracks — the same thing the + // module map is masked for. The shape of the flow still comes through. + const state = await readMe(); + expect(state.needs).toEqual(emptyOnboardingNeeds()); + expect(state.completedAt).not.toBeNull(); + }); +}); diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts index e4f4341d2..f36d0438e 100644 --- a/tests/integration/record-settings-authorization.test.ts +++ b/tests/integration/record-settings-authorization.test.ts @@ -79,7 +79,17 @@ describe("record settings authorization", () => { expect(route).toContain("details: { changed }"); expect(route).not.toContain("details: { recordId:"); expect(route).toContain("invalidateUserMedications(access.recordId, {"); - expect(route).toContain("invalidateUserHealthScore(access.recordId)"); + // The module write is one record-keyed function shared with the setup + // flow's derivation; the health-score invalidation lives there, so the + // route must call it with the record and the helper must still invalidate. + expect(route).toContain("writeRecordModulePreferences({"); + expect(route).toContain("recordId: access.recordId,"); + expect(route).not.toContain("invalidateUserHealthScore("); + const moduleWrite = await readFile( + fromRoot("src/lib/record-settings/modules.ts"), + "utf8", + ); + expect(moduleWrite).toContain("invalidateUserHealthScore(input.recordId)"); expect(route).toContain("getAllEffectiveRanges(record, overrides)"); expect(route).toContain( "effective: getAllEffectiveRanges(record, overrides)", diff --git a/tests/integration/switched-session-destructive-refusal.test.ts b/tests/integration/switched-session-destructive-refusal.test.ts new file mode 100644 index 000000000..dfd48f675 --- /dev/null +++ b/tests/integration/switched-session-destructive-refusal.test.ts @@ -0,0 +1,183 @@ +/** + * The three destructive account routes, driven with a live record switch on. + * + * `DELETE /api/settings/data` wipes a record, `DELETE /api/settings/account` + * erases an account, and `POST /api/export/encrypted` puts a whole record into + * one file. None of them names a record: they resolve the CALLER and act on + * whatever that caller owns. So the question a switched session asks of them is + * not "may I do this to the record I am inside" — it is "what happens when the + * browser believes it is somewhere else and the route does not check". + * + * The answer must be a refusal, and the same one every undeclared route gives: + * 403, `sharing.not_permitted`, "cannot be used while acting on another + * account". Silently serving the delegate's own record is the failure this + * pins against, and it is the worst-shaped one in the product — a person who + * believes they are looking after somebody else's record presses "delete + * everything" and erases their own. + * + * Both halves are asserted: the status AND that nothing moved. A refusal that + * happens after the transaction would satisfy the first alone. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "switch-destructive-owner"; +const DELEGATE_ID = "switch-destructive-delegate"; + +/** The delegate's own reading. It must still be there afterwards. */ +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `switch-destructive-${name}`, + email: `switch-destructive-${name}@example.test`, + timezone: "UTC", + }, + }); + } + await prisma.measurement.create({ + data: { + userId: DELEGATE_ID, + type: "WEIGHT", + value: 72, + unit: "kg", + measuredAt: new Date(), + }, + }); +} + +/** + * Sign the delegate in and switch into the owner's record at MANAGE — the + * widest grant the product mints, so nothing below can be explained away by + * the grant simply being too narrow. + */ +async function switchIntoOwnersRecord() { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await getPrismaClient().session.create({ + data: { + userId: DELEGATE_ID, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, OWNER_ID); +} + +/** The one refusal all three must give. */ +async function expectSwitchRefusal(response: Response) { + expect(response.status).toBe(403); + const body = (await response.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + expect(body.error).toBe( + "This endpoint cannot be used while acting on another account", + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); + await switchIntoOwnersRecord(); +}); + +describe("a switched session reaches none of the record-wide destructive routes", () => { + it("refuses the record wipe and wipes neither record", async () => { + const { DELETE } = await import("@/app/api/settings/data/route"); + await expectSwitchRefusal( + await DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ), + ); + + const prisma = getPrismaClient(); + expect( + await prisma.measurement.count({ where: { userId: DELEGATE_ID } }), + "the delegate's own reading survived", + ).toBe(1); + expect( + await prisma.user.count({ + where: { id: { in: [OWNER_ID, DELEGATE_ID] } }, + }), + ).toBe(2); + }); + + it("refuses the account deletion and deletes neither account", async () => { + const { DELETE } = await import("@/app/api/settings/account/route"); + await expectSwitchRefusal( + await DELETE( + new Request("http://localhost/api/settings/account", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }) as never, + ), + ); + + expect( + await getPrismaClient().user.count({ + where: { id: { in: [OWNER_ID, DELEGATE_ID] } }, + }), + ).toBe(2); + }); + + it("refuses the encrypted export and hands out no archive", async () => { + const { POST } = await import("@/app/api/export/encrypted/route"); + const response = await POST( + new Request("http://localhost/api/export/encrypted", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ passphrase: "a-long-enough-passphrase-123" }), + }) as never, + ); + await expectSwitchRefusal(response); + // Not an archive under any content type: a refusal that still streamed the + // bytes would read as a 403 to a status check and as an export to a user. + expect(response.headers.get("content-type")).not.toContain( + "application/octet-stream", + ); + }); +}); diff --git a/tests/integration/vaccination-booster-scoped-delegate.test.ts b/tests/integration/vaccination-booster-scoped-delegate.test.ts new file mode 100644 index 000000000..3427956f0 --- /dev/null +++ b/tests/integration/vaccination-booster-scoped-delegate.test.ts @@ -0,0 +1,166 @@ +/** + * `POST /api/vaccinations/{id}/booster` crosses a section boundary, and the + * grant has to be on both sides of it. + * + * The operation is declared `("write", "profile")`, which is right for the act + * — filing the Impfpass and arming the booster it suggests are one thing to + * the person doing them. The ROW it mints is not: a `MeasurementReminder` + * belongs to the measurements section, every direct route over that model says + * so, and creating one there needs MANAGE. So a grant opening the health + * background alone would otherwise produce a reminder on the owner's checkup + * list that the delegate can neither read nor change through the section that + * owns it. + * + * Refused rather than silently skipped: the reminder is the whole request, and + * a 201 over a row that was never written would tell the person their booster + * is planned while nothing rings. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "booster-scope-owner"; +const DELEGATE_ID = "booster-scope-delegate"; + +/** A catalogue antigen that carries a booster interval. */ +const ANTIGEN = "tetanus"; + +let doseId: string; + +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `booster-scope-${name}`, + email: `booster-scope-${name}@example.test`, + timezone: "UTC", + }, + }); + } + const dose = await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: new Date("2026-03-01T00:00:00.000Z"), + antigenSlug: ANTIGEN, + }, + }); + doseId = dose.id; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function switchInto( + access: "WRITE" | "MANAGE", + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access, + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +async function planBooster(): Promise { + const { POST } = await import("@/app/api/vaccinations/[id]/booster/route"); + return POST( + new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }), + }), + { params: Promise.resolve({ id: doseId }) }, + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); +}); + +describe("planning a booster asks for the section the reminder lives in", () => { + it("mints for the owner", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(1); + }); + + it("mints for a delegate holding both sections", async () => { + await switchInto("WRITE", ["profile", "measurements"]); + expect((await planBooster()).status).toBe(201); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(1); + }); + + it("refuses a profile-only delegate and writes no reminder", async () => { + await switchInto("WRITE", ["profile"]); + const response = await planBooster(); + expect(response.status).toBe(403); + const body = (await response.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("vaccination.booster-out-of-scope"); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + "no reminder was left on the owner's checkup list", + ).toBe(0); + }); + + it("mints for a whole-record delegate", async () => { + await switchInto("MANAGE", null); + expect((await planBooster()).status).toBe(201); + }); +});