mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
255 files changed
+12656
-1139
No files matched your search
+132
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }> };
|
||||
|
||||
|
||||
@@ -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 }> };
|
||||
|
||||
|
||||
@@ -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 }> };
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user