Commit Graph
7021 Commits
Author SHA1 Message Date
Marc-André Bombeck 021ea8f1ff feat(api): let a record answer the setup questions, and act on them
Three writes, all on the caller's own record.

`PATCH /api/onboarding/answers` saves one step the moment it is answered, so
leaving the flow and coming back resumes where it stopped. It is idempotent
because the whole computation is a pure function of the row and the body,
including the first-result instant, which is kept rather than re-taken. There is
no ordering contract at all: a back button has to work.

`POST /api/onboarding/complete` keeps everything it did and gains the confirm
screen's job. For a record that answered the questions it derives the module map
once and applies it, and the once-only latch is the point: a second confirm
would re-apply a questionnaire over decisions taken in Settings since. Cycle is
switched on through its own column and never off, because the gate already
derives an unanswered cycle from recorded sex and an untick is not a request to
retract that.

`POST /api/onboarding/restart` puts the steps back and clears the latch. It
writes no module state whatever: a re-run never turns off something switched on
by hand, and the safest way to hold that is for this route to have no opinion
about modules at all. It also leaves the first-run redirect's own stamp alone,
so asking the questions again does not push anybody back through the old wizard.

All three refuse outright while the session is acting on somebody else's record,
at every grant level, so a delegate can never write another record's setup. The
route into a managed profile's flow belongs with the rest of that record's
configuration and is not smuggled in here.

The demo allowlist gains the three: the flow has to be completable there with
preset answers, and none of them creates health data.
2026-09-10 11:03:36 +02:00
Marc-André Bombeck cc98c56172 feat(onboarding): store what a record needs, and map it to modules
The setup questionnaire the design spec asks for needs somewhere to put its
answers. `OnboardingRecord` is that place: one row per RECORD rather than per
person, so a guardian running a managed profile's setup writes the child's row
and not their own, and the three JSON columns hold the answers, the nine
ordered steps with their status, and the one task the flow ends on.

Nothing here is encrypted, and the schema comment says why: the columns hold
closed enum vocabularies, not free text, which is the same class of value as
the module map and the goal slugs sitting in plaintext beside them.

The needs to modules map lives next to the module keys rather than in the flow,
because a module renamed or retired has to break the map at compile time rather
than leave a chip that quietly changes nothing. The derivation is total — every
directly-owned key gets an explicit value — and the merge beside it is
one-directional: what the answers name is switched on, what they do not name is
switched off only where the record does not already carry a deliberate on. The
two delegated keys are absent by construction: `cycle` is reported separately
because its real column is on the cycle profile, and the Coach is not offered by
this flow at all.

Readers are fail-soft. They sit on the payload that rides every app boot, and a
value written by a release that named an area this one retired must not take the
shell down with it.

Nothing is retired: the four-step wizard, its goal slugs and the one-time tile
seed keep working and keep their meaning until the new surface replaces them.
The two states are disjoint — the slugs decide tile order, these answers decide
which modules are on, and neither reads the other.
2026-09-10 11:03:24 +02:00
Marc-André Bombeck e7a1950f3a refactor(sharing): retire the coarse "may this person add" answer
`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.

Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.

So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.

The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
2026-09-10 11:02:32 +02:00
Marc-André Bombeck b6d3f9903f docs(api): publish the switch refusal on the wipe, the deletion and the encrypted export
The three routes that act on the whole of an account — `DELETE
/api/settings/data`, `DELETE /api/settings/account`, `POST
/api/export/encrypted` — name no record. They resolve the caller and act on
whatever the caller owns, so the question a switched session puts to them is
what happens when the browser believes it is somewhere else.

They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or
its erasure sibling, and both open with `requireAuth()`, which reads the acting
carrier and throws `sharing.not_permitted` for anything but `none`. What was
missing is that the contract never said so. `/api/settings/{data,account}`
published a 403 about narrow Bearer scope alone, and the encrypted export
published no 403 at all, so a client that switches records had no way to learn
which of its calls stop working short of trying one.

One sentence, spliced onto the three, byte-identical for the same reason the
delegable refusal is one string. `switched-session-destructive-refusal.test.ts`
drives each route with a live whole-record MANAGE switch and asserts both
halves: the status, the code and the wording, and that neither record moved —
a refusal raised after the transaction would satisfy the first alone. The
export leg additionally asserts no archive rode along with the 403.
2026-09-10 10:49:12 +02:00
Marc-André Bombeck 927496236a fix(capture): ask each capture surface's own section before offering it
The capture FAB rides the bottom bar on every authenticated page, and its
fallback arm was section-blind:

    return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);

`canAdd` is bound straight to the grant's LEVEL — `grantAllows(grant, "write")`,
no scope term at all. So a WRITE grant scoped to `["labs"]` published
`canAdd: true` with `writableDomains: ["labs"]`, and a delegate standing on
`/labs` was offered a weight form and a dose that the server refuses at
`grantCoversDomain`. The worst case is a WRITE grant scoped to `["documents"]`:
the vault takes no delegated write at any level, the writable list is empty, and
both kinds were still offered.

`writableDomains` is already the level × scope × route-table intersection, so
one question per kind, asked of that kind's own section, answers the scoped
case, the vault case and the mood entry's MANAGE-only create together. The
closed kind list and the separate manage arm are both dead once that lands and
are gone. The dashboard quick-entry sheets and the header menu carried the same
shape — unreachable today, because the dashboard opens only for a whole-record
grant — and move with it rather than waiting to become reachable.

The picker's legs in the affordances suite now build their capabilities from
the same table the server publishes, and four scoped cases join them: labs-only
offers nothing, measurements-only offers the reading, documents-only offers
nothing at all, and a mind-scoped manage offers the mood entry alone.
2026-09-10 10:45:01 +02:00
Marc-André Bombeck 3e176db5b7 fix(encounters): stop offering the visit edit sheet to a delegate who may not save it
`VisitsSection` asked the grant about its add button and nothing else. Every row
underneath stayed tappable into the edit sheet, whose Save is `PATCH
/api/encounters/{id}` and whose Delete is `DELETE` on the same path — both
`("manage", "profile")`. So a READ delegate opened a form that could not be
saved, and a WRITE delegate, who may file a new visit, opened one that could not
be changed. Reachable wherever the checkups page is, which is every
`profile`-scoped grant.

The row now takes its open callback only when `canManageDomain("profile")`
answers, and the card renders its content as content rather than as a button
when it does not. The add button keeps asking the write question, because that
is a different question with a different answer.

Four legs in the affordances suite over the same scoped fixtures the
immunization log uses: the owner opens the sheet, a READ and a WRITE delegate
see the row and cannot open it, a WRITE delegate still gets the add button, and
a MANAGE delegate opens it only while holding the section. Two of them fail
against the previous section.
2026-09-10 10:39:46 +02:00
Marc-André Bombeck e8a2cbe82e Merge the per-account off-host backup freshness and the honest error-report badge 2026-09-10 10:37:00 +02:00
Marc-André Bombeck 7c7e621fd9 fix(vaccinations): ask the grant before offering to add, edit or delete a dose
The immunization log carried no capability check at all: not one reference to
the record hook anywhere under `src/components/vaccinations`. The page is
presentable to every `profile`-scoped grant and to every whole-record one, so a
READ delegate was shown the add button, a clickable row that opens the edit
sheet, and the delete inside it — three controls the server refuses with a 403
each. A WRITE delegate was shown two of them.

`POST /api/vaccinations` answers at WRITE; the edit, the delete and the restore
behind it are `("manage", "profile")`. The two add buttons now ask
`canWriteDomain("profile")` and the row's edit affordance asks
`canManageDomain("profile")`, which is the split the address book next door
already gets right. The list already renders a non-interactive card when it is
handed no edit callback, and the sheet is only reachable through one of the two
gated entries, so nothing needs a second check that could drift from the first.

`delegated-write-affordances.test.tsx` gained a scoped-grant fixture — every
fixture in it was `sections: null`, which is why nothing noticed — and six legs
over the log: the owner, a READ, a WRITE and a MANAGE delegate holding
`profile`, a MANAGE delegate holding another section, and the empty state's own
add. Four of them fail against the previous view.
2026-09-10 10:36:52 +02:00
Marc-André Bombeck e645921852 fix(cycle): fence the phase crosstab's outcome columns to the sections they belong to
`GET /api/cycle/insights` is declared `("read", "cycle")`, which is right for
the phase labels and wrong for what it compares them against. The crosstab rows
carry `lutealAvg`, `follicularAvg` and `delta` in display units for resting
heart rate, HRV, sleep duration, steps, weight, body/wrist/skin temperature and
blood glucose, plus the mood score read straight off `MoodEntry` — measurement
and mind data, published in full to a grant scoped to the cycle alone, with the
lagged discovery's narrated interpretation on top.

The visits service already had the right shape for this: a predicate over the
grant's sections, asked before the foreign rows are read. Both sections are now
fenced at the query rather than in the response, so a caller outside them never
loads the values in the first place and the rows read exactly as they do for a
record with nothing logged. The wrist-temperature read that sharpens the
prediction stays: it never reaches the response.

The predicate had two verbatim copies, one in the visits service and one in the
vaccination service, with a comment on the second saying a second spelling would
be a second thing to keep in step. It is one module now, `sharing/acting-domains`,
and the third consumer imports it rather than a feature service.

`cycle-insights-scoped-delegate.test.ts` drives four grants against a fixture
whose separation is large enough that the FDR gate cannot be the reason a row is
missing: the owner and an entire-record delegate get both figures, a cycle-only
delegate gets neither and no lagged row, and a cycle+mind delegate gets the mood
column and not the weight. Both scoped legs fail against the previous handler.
2026-09-10 10:36:35 +02:00
Marc-André Bombeck a7faae8ded refactor(admin): read the off-host card from evidence, sort the worst row first
Four smaller things around the same card.

The configured flag came from the environment of whichever process serves the
request. A hand-rolled split that gives BACKUP_S3_* to the worker and not to
the web process would have shown an operator "nothing leaves this host" over a
ledger full of fresh rows. The ledger is queried either way now, and a row in
it outranks an environment read: a row is proof an upload happened.

Rows sorted by username put the one account that needs attention wherever the
alphabet leaves it, which on a hundred-account cohort is the crowd problem the
card exists to end, moved from a count into a list. Worst verdict first,
alphabetical within a verdict, decided server-side so the tests and the
accessibility fixture see the order the operator does.

The card's docblock claimed the worker holds PutObject and GetObject. The
runbook is right and the multipart path needs AbortMultipartUpload too, so the
prose no longer documents a grant that would break the uploader.

And the byte formatter moved to lib. The off-host card was importing it out of
a thousand-line client component, which costs nothing while both cards render
on one route and becomes a cost the moment either moves.
2026-09-10 10:32:33 +02:00
Marc-André Bombeck 9d18b8fd25 fix(admin): let the error-reporting badge follow reports, not a parseable URL
The badge read the enabled flag plus a DSN that parses. Neither says anything
about the target accepting a report: a wrong public key, a wrong project id and
a host that stopped answering all parse, and every one of them showed green
with a line naming the host. That is the false reassurance an operator finds
out about when they go looking for a crash that was never reported.

Nothing in the tree recorded an outcome, so there was nothing to read. The
sender now stamps one next to the DSN it used — instant on a success, a short
classification on a failure. Never the response body: that is text from a
remote host and has no business in this database or on this page.

The badge is green only while a report has left inside the last fortnight,
amber while a target is set and nothing has come back, and it names the reason
underneath. The clock lives with the ledger, so the verdict is resolved in the
route and the card paints what it is handed. A failure that a later success
superseded is not shown at all. Pressing Test records like any other send, so
the button proves the target on demand.
2026-09-10 10:28:57 +02:00
Marc-André Bombeck 25f0310596 feat(backups): tell an empty ledger apart from an empty bucket
On the release that adds the ledger the table is empty, so on a host that has
been uploading nightly for months every account read Never, under a sentence
saying no copy had ever reached the bucket. That sentence is untrue on exactly
the morning an operator opens the new card for the first time, and it is a
claim the host cannot make: what is empty is the record, not the bucket.

The run now records every account it walks, not only the ones an object came
of. A walk that produced nothing moves the attempt instant and leaves the
success columns alone, so the two cases separate: no row at all is no record
yet, a row without a success is never. Never now means what the word says —
a run looked at this account and has never put anything there.

The card names the last check on the never row, so the state is a fact with a
timestamp rather than an assertion. Runbook and all seven bundles carry the
same two sentences. The integration file proves both arms against real
Postgres, including that a failed run leaves the previous good copy on the
row.
2026-09-10 10:15:31 +02:00
Marc-André Bombeck cfc19a211c fix(backups): give the off-host verdicts slack for a Berlin night that is 25 hours long
The threshold was the schedule period with no budget on top of it. Two things
spend that budget on a host where nothing is wrong. The cron runs 02:30
Europe/Berlin, so on the DST fall-back Sunday the gap between two runs is 25
hours and every account on every host would cross one period on the same
morning. And the ledger instant is when that account's object landed, by
design, so on a cohort walked one account at a time an account reached an hour
later tonight than last night drifts past 24 with two clean runs behind it.

The schedule period and the verdict threshold are now separate numbers. The
period is still what the card quotes; the threshold is the period plus six
hours, applied to both steps, so due still means one run produced nothing and
stale still means two. The fall-back night is pinned in the unit file by its
real dates rather than by an arithmetic 25, and a genuinely missed night at 31
hours still reads due. The runbook says why the slack is there.
2026-09-10 10:09:59 +02:00
Marc-André Bombeck e64435f6a5 fix(backups): size the off-host ledger column for the object the uploader allows
The column was INTEGER. A single object may occupy 8 MiB across 10 000
multipart parts, which is the 80 GB the uploader explicitly permits, and
Postgres INTEGER stops at 2.1 GB. An account between the two uploads fine and
then cannot be recorded, so the biggest account on the host is the one the
console would have to report on and could not.

BIGINT in the table, BigInt in the model, Number() where the route builds the
wire row so the payload type stays a plain number. The migration is edited in
place rather than followed by a second one: the table has not shipped, and a
column widened before its first release is one column, not a history.

The comparison with the in-database backup does not carry over, for the
record: that size is octet_length over a bytea Postgres caps at 1 GB, while
this object never touches the database at all.
2026-09-10 10:07:28 +02:00
Marc-André Bombeck 86823db34e fix(backups): a landed object stays a success when the ledger write does not
The per-account ledger row shared the upload's try. A pool timeout or a
connection reset on that upsert landed in the same catch as a refused upload:
the account was counted as failed, its reason pushed into the run's failure
list, and the console would go on to read never or stale for an account whose
encrypted copy was sitting in the bucket the whole time. That is the same
inversion the ledger was added to remove, on the write side.

The upload's boundary now ends where the object is durable. uploadEncryptedBackup
resolves only after the multipart completes, so the count and the largest-object
figure are taken there, and the row is written afterwards under its own boundary.
A write that fails warns with the account it belongs to and adds a run-level
count, which is a different fact from a failed upload and reads as one.

Two arms that were correct by construction and unasserted now have tests: a
failed upload writes no success row, so the previous timestamp stands, and a
run whose ledger write rejects after the object landed still reports it
uploaded.
2026-09-10 10:04:06 +02:00
Marc-André Bombeck 34d647a884 test(backups): prove the off-host ledger end to end and the card's empty state
The unit tests either side of this mock Prisma, so neither proves the
migration applies or that the writer and the reader agree on a shape. The
integration file runs the real nightly job against real Postgres with an
in-memory bucket, then reads the row back and asks the console's own
classifier what it says: fresh after a run, one row across two runs, stale
once it ages past two nightly periods, gone when the account is.

The component test pins the case an operator most needs stated: a host with
no bucket configured says so, rather than painting an empty list that reads
as "no problems". The accessibility scan's admin-backups response gains one
row in each state, because a card that paints only its header proves nothing
about the rows anybody reads.

Two small corrections ride along, both from writing the tests. The freshness
badge composes its classes through `cn` like every other surface, and the
error-reporting line waits for the settings to arrive instead of asserting
"nothing is sent" while the answer is still loading.
2026-09-10 09:34:33 +02:00
Marc-André Bombeck 532483e782 fix(admin): say where error reports go, and stop calling a silent target configured
There is no default error-reporting DSN in this tree and there should not be:
a self-hoster's crash reports must not leave their host because nobody told
them not to. That part was already right, so nothing here adds a default.

What was wrong is what the console said about it. The GlitchTip card showed a
green "Configured" badge as soon as a DSN existed, whether or not the switch
that actually sends anything was on, and it never said where reports go. An
operator reading that badge has been told their errors are being collected
somewhere, which may be true of neither half.

The badge now tracks reports actually leaving, and one line under the switch
states the case: the host they go to, or that they stay here, or that tracking
is on with no target. The host only — the public key in the DSN answers a
different question. The deploy runbook says the same thing for a
maintainer-operated instance.
2026-09-10 09:28:12 +02:00
Marc-André Bombeck 1c10bbbde8 feat(admin): say per account how fresh the off-host copy is
The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.

The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.

The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
2026-09-10 09:27:58 +02:00
Marc-André Bombeck 036720bcfb docs(changelog): name the modules route's method and the profile refusal as they are
The 1.38.15 entry said PUT where the record-settings route answers PATCH, and promised a rejectedFields field on the managed-profile edit that only the two profile routes carry; the edit refuses with a 422 and the issue list.
2026-09-10 09:09:26 +02:00
Marc-André Bombeck 0c14cbd489 Merge the issue list on every shape refusal, the shared limiter on single-record writes and the error-code catalogue 2026-09-10 08:55:26 +02:00
Marc-André Bombeck 3ffdfe15a1 fix(api): give the shared write ceiling a code a client can branch on
The eleven single-record writes answered their shared 429 with prose and
nothing else, while the envelope's own description tells a client to
branch on `meta.errorCode`. On `POST /api/medications/[id]/intake` and
`POST /api/cycle/day-logs` a second, narrower bucket can answer 429 as
well, so the presence of the rate-limit headers was the only thing
telling the two refusals apart.

All eleven now carry `record_write.rate_limited`, the catalogue lists
it, the published 429 names it, and the contract test fails if a site
stops emitting it or the sentence stops publishing it.

The same description now states what a client could not predict from
"300 requests per 60 seconds": refused requests count too, because the
check runs before the body is read.
2026-09-10 08:39:09 +02:00
Marc-André Bombeck 68964b4bac docs(api): correct two comments that describe behaviour the code does not have
The single-record write ceiling justified itself with "every one of these
routes is already wrapped in `withIdempotency`". Ten of the eleven are.
`POST /api/medications/intake` is not, and it is the one of the eleven a
native client actually loops — it is replay-tolerant for its own reason,
being an update of a named `intakeId` rather than a create. The comment
now says which is which, and adds the clause it was missing: the check
runs before the body is read, so a refused request spends the allowance
too.

The custom-symptom wrapper said a replay "answered the 409 that
uniqueness raises". There is no such 409. The row's key is minted fresh
per request and the label lives in an AES-GCM column with a per-write
IV, which can carry no unique index, so an un-keyed replay wrote a
second row with the same label — a silent duplicate in the person's own
vocabulary, up to the fifty-row cap. The wrapper is worth more than the
old sentence claimed, not less.
2026-09-10 08:38:16 +02:00
Marc-André Bombeck b2c8fe8567 fix(api): answer the unknown-leaf refusal with a sentence and a code
`PUT /api/auth/me/report-selection` threw a bare 422 whose whole `error`
string was `report-selection.leaves.unknown` — a machine token in the
field a client shows a person, with nothing beside it to branch on and
no way to learn which leaves were refused. It now answers a sentence,
with the token in `meta.errorCode` and the refused ids in
`meta.unknownLeaves`, and the operation's description says so.

The site was invisible to the refusal guard because the refusal is
decided by `if (!minted.ok)` rather than by a parse result, so the guard
now reads the refused string as well as the deciding block: every 422
under `src/app/api` whose `error` is a dotted token is inventoried with
the reason it still is one. Eleven remain, each with its reason, and the
inventory may only shrink. Removing the fix above turns it red naming
that route.

The four sibling shape refusals keep their token in `error` on purpose,
and the comment now says why that is not the same decision as the seven
`invalid_json` sites: those moved their string because their status
moved to 400 anyway, while a shape refusal that keeps its 422 would be
changing the wire for nothing. Both halves publish `meta.errorCode`.

Two loose ends in the same guard: a block calling `returnAllZodIssues`
or `apiValidationError` without spelling `422` is now judged (their
default status is 422, so a future `refuse422(err)` helper cannot slip
past), and the site floor moves from 40 to 200 against the 248 the tree
carries — the old floor would have survived losing five matches in six.
2026-09-10 08:35:35 +02:00
Marc-André Bombeck f9561395c7 fix(api): read every module a route reaches when listing the error codes
The catalogue guard read the route tree plus a hand-kept list of library
modules. The dose-history import builds its file-level codes in a module
that list never named, so `empty_file`, `missing_required_columns`,
`unreadable_json`, `json_not_an_array` and `json_carries_no_intake_time`
shipped on `POST /api/medications/intake/dose-history-import` while the
envelope's own description promised every code was enumerated below, and
the guard was green because it had not read the file.

The sweep now walks the import graph from every route under `src/app`:
1423 modules, resolved through the same `@/…` alias the app uses, with
the generated client never opened. A code is read where it is written,
however many hops from the route that answers with it, and nothing is
hand-listed any more. Seeding from the whole app tree rather than
`src/app/api` also reached the clinician share-link document route,
whose three codes were missing for the same reason.

A tuple of codes is read as a family, like the object form already was,
which is what makes the import's union visible; the constant holding it
is renamed to the `*_ERROR_CODES` spelling the sweep reads, since a list
named for anything else is as likely to be nutrients as error codes. The
two sync-ledger writers are cut out of the text first — `errorCode` on
`IntegrationStatus` is a different field with the same name.

Proven by breaking it both ways: dropping `empty_file` from the
catalogue turns the guard red, and a code planted in
`src/lib/analytics/compliance.ts`, a module no list ever named, turns it
red naming that file.
2026-09-10 08:31:44 +02:00
Marc-André Bombeck 348d9e0739 Merge pull request #946 from MBombeck/release/v1.38.15
Release v1.38.15
v1.38.15
2026-09-10 08:21:29 +02:00
Marc-André Bombeck 56b6ec7010 test(e2e): read the tile's rate after the skip parity, one figure on both surfaces
The adherence journey pinned the tile at the figure its engine produced before a skip left the denominator; with both engines held to one number the tile reads what the card reads.
2026-09-10 08:06:11 +02:00
Marc-André Bombeck 868efc1c13 docs(api): publish the error codes the server actually emits
`meta.errorCode` is the mechanism this API chose for machine-readable
errors, and the envelope's own description tells a client to branch on
it rather than on the prose. 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 in testing, and had no way at
all to learn that a new one had appeared.

They are enumerated now, grouped by the surface that emits them, in the
description of the field they belong to. The list is generated from the
code and committed as a literal so it reads in a diff, and the sentence
in the document is rendered from it rather than written beside it.

Nothing is renamed. Four naming conventions coexist — dot.case, bare
snake_case, a camelCase segment and a hyphen — and a code is a wire
value a shipped client branches on, so they stay. New codes are
dot.case, and the guard is what stops a fifth convention arriving
unnoticed.

Three families stay outside the list and say why: the assistant refusal
builds its last segment from a template, the integration probes are
enumerated per operation where the per-provider differences can be
stated, and a route checking a credential of its own may answer 401
with no code.

The guard re-derives the set from the tree and fails both ways — a code
emitted and not listed, and a code listed and no longer emitted, which
is the worse of the two because a client can write a branch for it and
wait forever.
2026-09-10 07:47:10 +02:00
Marc-André Bombeck 8d418bc6f8 chore(release): v1.38.15 — the rest of #939, one adherence figure, and an API that says when to come back
Version anchors, the OpenAPI document and the changelog entry. Refs #939.
2026-09-10 07:45:55 +02:00
Marc-André Bombeck ab6c1cd917 fix(api): put a ceiling under the single-record writes
The batch endpoints have been capped at 60 calls a minute since they
were written. The per-record siblings a naive or older client loops
over — the measurement create, the mood entry, both medication-intake
writes, a lab result, a custom-metric entry, a cycle day-log, a
vaccination, an allergy, a biomarker and an encounter — had no ceiling
at all. That is backwards from the risk: the batch endpoint is the one
a well-behaved client uses, and the unlimited path is the one a broken
loop finds.

All eleven share one generous per-account bucket, `record-write`, at
300 requests a minute. Five writes a second sustained for a full minute
is beyond any hand-driven session and any sync burst, and every one of
these routes is already wrapped in `withIdempotency`, so an honest
retry storm is deduplicated before it is counted — the bucket exists to
cut off the pathological loop, not to shape traffic. It keys on the
ACTING account, the precedent the delegable medication routes set, so a
delegate burns their own allowance rather than locking the record's
owner out of their own log.

The eleven operations publish the bucket name and the cap instead of
the generic refusal, on the shared 429 that already declares
`Retry-After` and the rate-limit triple. A test pulls the key and the
window out of the query the limiter runs and holds the published
sentence to them, so the paragraph cannot drift away from the bucket it
describes.
2026-09-10 07:42:07 +02:00
Marc-André Bombeck 17671d9dc1 Merge the AA-contrast status rows on the admin page 2026-09-10 07:41:22 +02:00
Marc-André Bombeck 040b430e73 fix(api): accept an Idempotency-Key on the titration step and the custom symptom
Two more creates the client replays out of its offline outbox ran the
handler again on the replay. A titration step carries no natural key at
all, so a lost success response left the medication with two dose
changes on the same day and the runway estimate reading off the wrong
one. A custom cycle symptom is unique on its label, so the replay did
not duplicate — it answered the 409 that uniqueness raises, reporting a
conflict the client cannot act on for a row it has no record of
creating.

Both wrap in `withIdempotency`, the composition their siblings already
ship, and both operations publish the header and the in-flight 409 so a
client generated against the contract can tell a retry is safe.

The integration file that drives the medication creates twice against
real Postgres gains the titration case: one dose-change row after the
replay, and a replayed 201 carrying `X-Idempotent-Replay: true`.

`POST /api/mcp/tokens` is deliberately not on this list. Its response
carries a freshly minted `hlk_` credential, and the wrapper refuses to
persist a secret-shaped body — publishing the header there would
promise a replay the mechanism will never perform.
2026-09-10 07:40:05 +02:00
Marc-André Bombeck 3e44d68518 fix(api): answer an unparseable body with 400 everywhere
`safeJson` refuses a body that will not parse with 400, and so does
`apiHandler`'s own SyntaxError branch — about two hundred and twenty
routes inherit that. Seven `/api/auth/me/*` writes hand-rolled the
parse in a try/catch and threw 422 for the identical condition, so a
client's "my serializer produced garbage" branch had to accept two
statuses on a subset of routes it had no way to predict.

All seven answer 400 now, with the sentence `safeJson` already sends
and the dotted token each one carried moved to `meta.errorCode`. The
400 is published on those seven operations through a shared response;
it is deliberately not folded into the standard set, which is spread
onto reads as well and a GET has no body to refuse.

`/api/auth/me/health-score-config` keeps its 422: the operation's own
description names that status and its exact message, so moving it is a
contract decision rather than a cleanup. The guard carries it as an
allowlist entry with that reason rather than quietly skipping it.

Three route tests pinned the old status; they assert the new one and
say why. The guard reads the hand-rolled parses out of the tree and
asserts it still finds them, so it cannot pass on a matcher that
stopped matching.
2026-09-10 07:26:32 +02:00
Marc-André Bombeck 9ce0bd5027 fix(api): name the refused fields on every shape rejection
Fifty-nine 422s raised off a Zod `safeParse` answered with one opaque
sentence and nothing a client could map onto a field, while the other
166 sent `details.issues` — the multi-issue envelope that exists so a
caller with three bad fields fixes all three in one round-trip. Twelve
of the fifty-nine put a dotted machine token in `error` where the
sentence belongs, 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.

Every one of them now answers through `apiValidationError`, which keeps
the existing `error` string and the existing `meta` byte for byte and
adds the issue list beside them. The dotted tokens keep their place in
`error` for the clients that already match on them and are published in
`meta.errorCode` as well, which is the field a machine code belongs in
and the one a new client should read.

A guard reads the route tree per refusal site rather than per file —
per file is what let this grow, since a route that used the helper once
looked compliant to a `grep -l` sweep — and asserts it still finds the
sites it judges, so a matcher that stops matching fails instead of
reporting a clean zero.
2026-09-10 07:19:55 +02:00
Marc-André Bombeck 9e1f64e733 fix(admin): keep the worker-status row readable with no worker present
The admin snapshot paints its worker row in `--destructive` whenever the
process serving the page is not the worker: a split deployment running
`HEALTHLOG_PROCESS_TYPE=web`, or a worker that stopped answering. The token
is AA on a card (4.85:1 in the light theme) but the stat tile lays a
`bg-muted/50` wash over that card, and axe measured the tinted wording at
3.97:1 there, under the 4.5:1 AA floor. `e2e/a11y.spec.ts` went red in
exactly that state and green in every other one, which is why the console
could ship it.

The status grids now name a tone instead of spelling a class at every call
site. Success and warning keep their tint, both clear AA on the tile.
Destructive takes the shape the other status rows already use, the
medication detail row and the invite chips: the token paints a small
indicator and the wording stays in `text-foreground`. The word itself
("Stopped", "Error") states the condition, so nothing hangs on the colour
alone. The database, Umami and GlitchTip rows sit on the same surface with
the same tint and move with it.

The success-affordance guard's two pinned entries for the status grids went
stale in the process: the tint they carried now lives once in the tone
table, so the pins follow it there.

Checked against a standalone build on a throwaway database: the whole a11y
spec green in both themes and both viewports with `HEALTHLOG_PROCESS_TYPE`
at `web` (the failing state) and at `all`. jsdom cannot measure a contrast
ratio, so the new unit test pins the token choice for both arms instead.
2026-09-10 06:56:50 +02:00
Marc-André Bombeck c64708cc37 test(ci): pin the third end-to-end server the notification journey drives
The determinism guard counted two Playwright servers and would have refused a third by deletion; the notification journey's mail-configured, scheduler-free process is the third. Pin it by its two distinguishing variables and assert the shared servers carry no mail configuration.
2026-09-10 06:49:14 +02:00
Marc-André Bombeck 8b1ca7ee5a Merge the notification dispatch end-to-end journey, the account-scoped reminder sweep and the published notification preferences 2026-09-10 06:37:56 +02:00
Marc-André Bombeck 74dbb1a2e4 test(e2e): give the notification journey a server and a scope of its own
The journey's isolation went one way only. `test.describe.configure` orders
the tests inside the file; the suite is fullyParallel with two workers, so
everything else runs beside it — and its own trigger, the admin reminder
sweep, walked the whole instance and dispatched for every account that had
an overdue dose. With the mail env pointed at the local responder, that
would have started sending real messages for accounts this file has never
heard of, and the exact-equality assertion on the accepted recipients would
have been reading them. The sweep now names the account it then counts, and
the comments that defended the opposite direction say what the exposure
actually is.

Its own server, too, and for two reasons. The `SMTP_*` vars decide whether
the Email card renders at all, so on the shared server they put a card the
axe scan and the card-anatomy sweep had never measured onto
`/settings/integrations` for every spec that opens the route. And unset,
`HEALTHLOG_PROCESS_TYPE` starts the pg-boss reminder scheduler inside the
process serving requests, whose every-fifteen-minutes tick either mints the
missed-dose row that makes the sweep skip its own dispatch or dispatches
beside it. A third server carries both: mail configured, `web`, and only
this spec talks to it.

The shared server keeps `all` on measured grounds rather than taste. Worker
status is per-process and in-memory, so a `web` process reports the worker
stopped, `/admin` renders that row destructive-red, and axe fails it at
3.97:1 against the muted tile — a real contrast defect any operator running
the documented web/worker split already sees, and not this branch's to fix.
That limit is written where the server is defined.

Also here: the stub draws its port per run instead of pinning one, and says
so by name when the port is taken; teardown ends its sockets and survives a
setup that never got as far as starting it; the ledger reset stops deleting
rows whose subject is this account but whose recipient is somebody else,
and clears intake events explicitly rather than through a cascade; the
attempt poll refuses a window larger than the twenty rows the diagnostic
returns; and the header names the precondition the file cannot enforce and
the internal reasons it pins on purpose.

Verified against a production build and a throwaway Postgres: the journey
three times over at one worker, then the a11y and settings geometry specs
at two. Both assertions broken once to see them fail.
2026-09-10 06:35:40 +02:00
Marc-André Bombeck ee73e840e0 Merge the backup and restore end-to-end journey, the restore that leaves instance settings alone and the 422 for an unreadable copy 2026-09-10 06:09:23 +02:00
Marc-André Bombeck fd6fa18913 feat(api): publish the resolved notification preferences on the account payload
The medication detail's notification section decides between the
server-side reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged`, read off `GET /api/auth/me`.
That payload never carried the field — the preferences live on their own
endpoint, which is not on the app-boot path — so the flag was `undefined`
for every account: the chip never rendered, and a person whose phone owns
the reminders was shown a switch that no longer decides anything. The
write path and the dispatch gate were correct the whole time; only the
web reflection was missing its end.

The payload now carries the fully-resolved block from the same resolver
the dedicated endpoint uses, so the two cannot disagree, and an account
that never opted in reads `false` rather than absent. No extra query: the
session already loads the row the column sits on.

The pair guard over this payload gained the direction that could not see
it. It asked that every published field has a reader; a reader with no
field was invisible. It now also reads the response types clients declare
for this endpoint and holds them against what the route publishes. A
typed guard was tried first and does not work here — each consumer
declares its own local interface, so the compiler checks every file
against its own private claim and agrees with all of them.
2026-09-10 06:04:39 +02:00
Marc-André Bombeck f3321b8031 feat(api): let the admin reminder sweep name one account
`POST /api/admin/notifications/reminder-check` walked every active
medication on the instance and dispatched a reminder for each overdue
slot it found. That is the operator button's intended behaviour and it
stays the default, but it left any other caller — a browser journey, a
smoke check — writing into every account's delivery ledger at once with
no way to say otherwise.

The body now accepts an optional `userId` that narrows the sweep. It can
only narrow: the route is `requireAdmin()`, cookie-only by construction,
so a Bearer caller never reaches it however wide its scope, and the
selector feeds the Prisma `where` rather than the authorisation. A
bodyless POST — what the admin panel sends — is unchanged. An unknown key
is a 422 instead of a silently instance-wide run.

Published in the contract alongside the notification diagnostic, so the
scope is readable rather than folded into the console-only exemption.
2026-09-10 06:00:27 +02:00
Marc-André Bombeck 343d6397a0 test(e2e): say what the backup journey really touches, and pin it to promises
The file claimed it stayed inside its own account. "Backup now" enqueues the
pass for every account on the instance, so the header says that now, and says
what is confined instead: the restore's transaction, and the instance-wide
settings the dialog's opt-in leaves alone — which the journey asserts is clear
before it confirms, because a default that flipped would hand this file the
power to rewrite the document size cap for every spec sharing the server.

The run's own answer is asserted where it happens. Without it a missing worker
cost two minutes of polling and then blamed the backup pass for a 503 about
something else; the failure now names the endpoint in two seconds.

The rest are the assertions that pinned incidentals rather than promises: the
Argon2 cost comes from the constant the writer exports, so a legitimate cost
bump is not a red spec; the document is found by id rather than by position;
the poll compares the row against the row it saw before the click rather than
against the runner's clock; the non-admin bounce is pinned by pathname; the
empty-state CTA has its own test id, so nothing has to pick between two
buttons that share one; the deliberately corrupted copy is deleted when the
control is done with it instead of sitting in the console's list; and the
fixture orders by id after the timestamp so a tie cannot present as a change.
2026-09-10 05:48:18 +02:00
Marc-André Bombeck 8be51d9beb test(e2e): cover notification preferences through to the dispatch decision
One browser journey for the web arm of notification delivery: an account
configures its channels on the settings surface, a due medication reminder
runs through the product's own sweep, and the dispatch decision is read back
off that account's `push_attempts` ledger via the admin diagnostic.

What it pins:
  - the enabled channel delivers (`result: ok`) and the message really leaves
    the process, asserted against a local SMTP responder rather than against
    the ledger row alone;
  - a channel that is configured and switched OFF is never attempted, and its
    own test control is inert on the surface;
  - the APNs arm is attempted and skipped, and the ledger's reason
    distinguishes the transport refusing (`apns_not_configured`) from the
    client-managed opt-in refusing (`client_managed`) — with email still
    delivering under the opt-in, which is the published "APNs leg only"
    contract;
  - an ntfy relay URL inside a private range is refused with 422, leaves the
    stored config untouched, and writes no attempt.

Email is the channel exercised for real because it is the only one that can
be: every account-supplied delivery target crosses the SSRF floor
(`isPublicUrl` plus `safeFetch({ requirePublicHost: true })` with a
connect-time address pin), so ntfy, the generic webhook and Web Push refuse a
local stub by design, and Telegram and APNs address hard-coded third parties.
Email's transport is operator configuration, so `e2e/setup/smtp-stub.ts` can
stand in for it end to end. The config points the e2e server at that stub.

The journey carries one skipped test at full strength: the medication detail
surface decides between the reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged` read out of `/api/auth/me`, and
that payload does not carry the field. The flag therefore reads as false for
every account and the chip never renders, so a person whose phone owns the
reminders is still shown a server-side switch that no longer decides anything.
The write path and the dispatch decision are both correct; only the web
reflection is missing its end.

The account and its jar are seeded in global setup, and its channels, devices,
ledger and preferences are reset before every test so the counts are about the
run in front of them.
2026-09-10 05:41:17 +02:00
Marc-André Bombeck 6e4f3fb4c4 docs(ops): what a restore replaces, and the console path
Two gaps in `backup-restore.md`. It said the restore "replaces the account's
data tables" only in a paragraph about grants, so nothing stated the
consequence for the account's own record: the rows it gained after the snapshot
was taken are deleted with the rest of their class. And the console restore was
named once in passing — `## Restore` documents only the off-host script — so an
operator restoring from the browser had no procedure to read.

Both are written down now, including which button does what, that the restore
targets the snapshot's owner rather than the admin running it, that
instance-wide settings ride along only when the dialog's opt-in is ticked, and
what a copy this instance can no longer decrypt answers.
2026-09-10 05:26:56 +02:00
Marc-André Bombeck fd176f621d fix(backup): say what the backups console actually does
Two places claimed a stored payload cannot leave the page: the card's own
description ("payloads are not downloadable from this page") and the listing
route's comment ("admins can trigger a re-snapshot but not download another
user's payload from the UI"). Both have been false since the download button
shipped — every row carries one, and the route hands back the decrypted JSON.

The description now says what happens instead: sizes are the encrypted blob,
Download decrypts the snapshot for the admin who asks, the ciphertext stays in
the database. Seven catalogues, and the route comment points at the route that
owns the payload.
2026-09-10 05:26:31 +02:00
Marc-André Bombeck d3f087475c fix(backup): refuse a stored copy that will not open with 422
A `DataBackup` row that fails to decrypt answered 500 on the restore, the
download and the preview. It is not a server fault: either the key that wrote
the copy is no longer in `ENCRYPTION_KEYS` — a rotation that dropped the legacy
entry before the script reported zero rows, the case the rotation runbook warns
about — or the stored bytes are not the ones that were written. Every other
bad-input arm on those routes already answers 4xx, and the 500 also paged the
error reporter as if the process had broken.

All three now answer 422 with `meta.errorCode = backup.payload.undecryptable`
and one shared sentence, so the three routes cannot drift into three wordings
for one condition. The download's second 500 stays: a payload this instance
wrote and cannot parse really is a fault here.

The two routes that open a stored copy move from the unpublished list into the
contract, because the refusal is now a promise an operator can look up rather
than whatever the handler happened to answer. The restore publishes its
`Idempotency-Key` header with them.
2026-09-10 05:25:30 +02:00
Marc-André Bombeck 6b366b4f27 fix(backup): make the console restore leave instance settings alone
A disaster-recovery snapshot carries the singleton `app_settings` row —
registration, the MFA requirement, the default locale and timezone, module
availability, the notification and AI configuration, the document size cap and
quota — and the restore wrote it back as a side effect of restoring one
account. An operator putting a single record back on Wednesday reconfigured the
whole host from Monday's file, silently, with nothing on screen saying so.

Rebuilding a host from a snapshot is a real case, so the write stays: it is now
`restoreInstanceSettings: true` on the request and a checkbox beside the typed
gate, off unless the operator ticks it. Restoring an account and reconfiguring
the installation are two decisions, so they are two answers.

The 422 confirmation sentence and the dialog copy drop the instance-wide claim;
the opt-in carries it instead, in all seven catalogues. Integration coverage
runs both arms: without the flag the settings row keeps its own values, with it
the file's values land.
2026-09-10 05:16:59 +02:00
Marc-André Bombeck 6a2282fef8 Merge Retry-After on every refused request, stable list pagination and the corrected error contract 2026-09-10 05:10:05 +02:00
Marc-André Bombeck 6f718121c7 test(api): use the real caps in the limiter mocks, and drop a stale pointer
Three consent-surface tests mocked the shared AI-consent bucket at 60 where it
is 20, and the analytics-read and MCP mocks named 60 against real caps of 120.
Nothing asserts the number today, so nothing was wrong on the wire — but the
mocked verdict now carries `limit` all the way to `X-RateLimit-Limit`, and the
first test that reads that header off a mock would pin the fiction.

The response-shape file's header pointed at a document that is not in the
repo; it says what it means directly instead. It also now records the one
thing the gate cannot see: `meta` is a loose object on purpose, so undeclared
keys inside it survive the parse and the strip comparison does not reach them.
2026-09-10 05:07:15 +02:00
Marc-André Bombeck cf9e8b8790 docs(api): declare the 429 headers instead of only describing them
The shared 429 named `Retry-After` and the `X-RateLimit` triple in prose, so a
generated client got no typed accessor for the field the same sentence tells
it to back off on. They are a `headers` block now: `Retry-After` and the cap
and remainder as integers, the reset instant as a date-time. The text also
says what a 429 from a ceiling that is not one of these buckets carries —
nothing, because there is no bucket to describe.

Two route descriptions still listed the pre-change header set: the ingest 429
named only `X-RateLimit-Remaining` / `X-RateLimit-Reset`, and the document
suggest 429 named only the reset instant beside `meta.retryAt`. Both are short
of the wire in exactly the direction this work exists to close.
2026-09-10 05:07:08 +02:00
Marc-André Bombeck 42100d94a7 fix(api): finish the pagination tiebreaker, and state the tie premise correctly
The comment on the measurements list justified the tiebreaker with an Apple
Health import stamping long runs with one instant, and said the unique index
forbids two live measurements of the same type at that instant. The index is
`(userId, type, measuredAt, source, sleepStage)`, so the named example is the
one case it does block — while ties survive across `type` (the `stats:` daily
rollups), across `source` (two writers landing together) and across
`sleepStage`. Same conclusion, honest premise.

The per-day drill-down on the same route still sorted on `measuredAt` alone.
It slices in memory rather than with `skip`, so nothing drifts between pages,
but two identical requests were free to render a tied block in different
orders, and a list that reshuffles on refresh reads as data changing.

Three of the tiebreakered routes had nothing pinning the order: custom-metric
entries, the per-medication intake history (both sort arms, so the NULLS LAST
pin cannot be lost to the tiebreaker) and the mental-health assessment
history. Each now asserts the `orderBy` the way the illness day-log test does.
2026-09-10 05:07:02 +02:00