Merge release v1.38.16

The API says what it refused and why, on every route, and the admin page says whether last night's off-host copy landed for each account.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-10 16:39:54 +02:00
commit 5061f423a8
255 files changed
+12656 -1139

No files matched your search

+132
View File
@@ -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:<accountId>` 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
+1273 -393
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -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
+21
View File
@@ -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
+42 -18
View File
@@ -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
+45
View File
@@ -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",
},
],
},
}),
);
}
+16 -6
View File
@@ -231,8 +231,8 @@ async function openSeedingSurface(page: Page): Promise<void> {
}
/**
* 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<void> {
* 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.
+25 -2
View File
@@ -6597,6 +6597,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"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)",
@@ -7012,7 +7019,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",
@@ -10484,7 +10507,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",
+25 -2
View File
@@ -6597,6 +6597,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"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)",
@@ -7012,7 +7019,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",
@@ -10484,7 +10507,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",
+25 -2
View File
@@ -6597,6 +6597,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"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)",
@@ -7012,7 +7019,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",
@@ -10484,7 +10507,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",
+25 -2
View File
@@ -6597,6 +6597,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"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)",
@@ -7012,7 +7019,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",
@@ -10484,7 +10507,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",
+25 -2
View File
@@ -6597,6 +6597,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"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)",
@@ -7012,7 +7019,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",
@@ -10484,7 +10507,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",
+25 -2
View File
@@ -6577,6 +6577,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"glitchtipEnvironment": "GlitchTip 환경",
"glitchtipEnvironmentPlaceholder": "production",
"glitchtipTargetOn": "오류 보고서는 {host}(으)로 전송됩니다.",
"glitchtipTargetOff": "오류 보고서는 이 호스트에 남습니다. 아무것도 전송되지 않습니다.",
"glitchtipTargetNoTarget": "오류 추적은 켜져 있지만 대상이 없어 아무것도 전송되지 않습니다.",
"glitchtipUnproven": "미확인",
"glitchtipDeliveryUnproven": "대상이 설정되어 있지만 아직 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 수락하는지 확인하세요.",
"glitchtipDeliveryStale": "지난 {hours}시간 동안 이 호스트를 떠난 보고서가 없습니다. 테스트를 눌러 대상이 여전히 수락하는지 확인하세요.",
"glitchtipDeliveryFailed": "마지막 보고서가 전달되지 않았습니다 ({reason}).",
"webPushVapidDescription": "환경 변수 없이 브라우저 푸시가 동작하도록 VAPID 키와 subject를 여기서 설정하세요.",
"webPushVapidPublicKey": "VAPID 공개 키",
"webPushVapidPublicKeyPlaceholder": "BElx... (Base64URL)",
@@ -6992,7 +6999,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": "위험 구역",
@@ -10484,7 +10507,7 @@
},
"mind": {
"label": "기분과 마음",
"detail": "기분 기록과 정신건강 설문 이력이에요."
"detail": "기분 기록과 정신건강 설문 이력이에요. 작성한 설문은 총점도 측정값으로 함께 저장돼요."
},
"cycle": {
"label": "생리 주기",
+25 -2
View File
@@ -6597,6 +6597,13 @@
"glitchtipDsnPlaceholder": "https://<public-key>@glitchtip.example.com/<project-id>",
"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)",
@@ -7012,7 +7019,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",
@@ -10484,7 +10507,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",
+1 -1
View File
@@ -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",
@@ -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;
@@ -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;
@@ -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;
+125
View File
@@ -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")
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.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/*`
@@ -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`
+271
View File
@@ -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<string, string[]> {
const emitted = new Map<string, string[]>();
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);
});
});
+67
View File
@@ -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");
}
@@ -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<string, { status: number; reason: string }> = {
"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);
});
});
@@ -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([]);
});
});
+72 -11
View File
@@ -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));
@@ -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);
@@ -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:<accountId>`");
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<string, { description?: string }> } }
>;
};
const unpublished = ON_THE_SHARED_BUCKET.filter(
([, path]) =>
doc.paths[path]?.post?.responses?.["429"]?.description !== published,
).map(([, path]) => path);
expect(unpublished).toEqual([]);
});
});
@@ -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");
@@ -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,
@@ -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 (!<result>.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<string, string> = {};
/** 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<string>();
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<string, string> = {
"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 `<file> <token>`. */
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([]);
});
});
+4
View File
@@ -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({
</SectionFrame>
);
case "backups":
// Two cards, two independent failure modes: the copies held in this
// database, then the copies that reached the operator's bucket.
return (
<SectionFrame>
<BackupsSection />
<OffhostBackupsSection />
</SectionFrame>
);
case "encryption":
@@ -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.
@@ -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<string, string>();
const restoredBiomarkerIds = new Set<string>();
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,
@@ -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: <T extends (...args: unknown[]) => 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<BackupsList> {
const res = await (GET as unknown as () => Promise<Response>)();
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);
});
});
+98 -2
View File
@@ -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<OffhostBackupFreshness, number> = {
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);
+37
View File
@@ -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,
@@ -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({
+12 -2
View File
@@ -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;
}
+13 -1
View File
@@ -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<Response> {
// 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,
+11 -2
View File
@@ -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;
+11 -2
View File
@@ -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;
+21 -4
View File
@@ -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
+1 -1
View File
@@ -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",
});
}
@@ -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<Response>)(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();
});
+4 -2
View File
@@ -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);
+7 -1
View File
@@ -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(
@@ -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
@@ -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({
@@ -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;
@@ -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<Response>)(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();
});
+4 -2
View File
@@ -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
@@ -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<Response>)(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();
});
@@ -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
+33 -5
View File
@@ -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
+102 -29
View File
@@ -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".
+22 -4
View File
@@ -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
+11 -2
View File
@@ -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;
@@ -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);
+11 -2
View File
@@ -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;
+11 -2
View File
@@ -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;
+11 -2
View File
@@ -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;
+12 -2
View File
@@ -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") {
+7 -1
View File
@@ -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;
@@ -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<typeof import("@/lib/rate-limit")>()),
// 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() },
+12 -1
View File
@@ -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,
@@ -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<typeof import("@/lib/rate-limit")>()),
// 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() },
@@ -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({
@@ -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:
+20 -4
View File
@@ -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,
+13 -4
View File
@@ -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;
+12 -1
View File
@@ -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<Response> {
// 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,
});
+51 -29
View File
@@ -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,
},
});
+19 -2
View File
@@ -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:<uuid>`), 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<Response> {
// 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,
);
});
}
@@ -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).
@@ -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();
@@ -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({
+10 -3
View File
@@ -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({
@@ -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();
@@ -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
+23 -7
View File
@@ -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,
+2 -4
View File
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+13 -2
View File
@@ -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<Response> {
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,
@@ -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.
@@ -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.
@@ -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) => {
+11 -2
View File
@@ -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({
+11 -2
View File
@@ -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
+5 -2
View File
@@ -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,
);
}
@@ -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;
+14 -4
View File
@@ -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,
@@ -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;
+17 -5
View File
@@ -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<Response> {
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 {
+7 -1
View File
@@ -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;
+11 -2
View File
@@ -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
@@ -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<typeof import("@/lib/rate-limit")>()),
// 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: {
+15 -4
View File
@@ -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
+12 -1
View File
@@ -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,
Loaded 100 of 255 files, more files were not shown because too many files have changed in this diff. Show more