Commit Graph
416 Commits
Author SHA1 Message Date
Marc-André Bombeck 79c4c3631f chore(deps): move the database pin to 16.15 and watch it
The bundled PostgreSQL was pinned to 16.14 while 16.15 had been out for
five weeks. The pin itself is deliberate, a database change should be
reviewable rather than whatever the registry serves on pull day, but
nothing watched it: Dependabot was configured with the docker ecosystem,
which reads the Dockerfile and never opens a compose file, so no update
PR was ever raised. CI and the integration suite meanwhile run against
the floating 16-alpine, so the shipped database was older than the
tested one.

Adds the docker-compose ecosystem so each PostgreSQL minor release
arrives as a reviewable PR, moves the pin to 16.15, and pins both halves
with a test that fails if the exact minor or the watch is dropped.
Refs #987
2026-09-16 12:19:09 +02:00
Marc-André Bombeck 530c8f40fe test: allowlist the consent journey's origin-only goto
The MCP consent journey navigates to /api/version only to give the page
an origin, then registers the OAuth client with the page's own fetch.
That evaluate reads no DOM, the same shape as the allowlisted vaccination
seed, and the consent page after it is gated on the Allow button.
2026-09-15 14:39:09 +02:00
Marc-André Bombeck 864566d882 fix(mcp): let the consent screen return to the connecting client
After Allow on the MCP OAuth consent screen, Chromium-based browsers
stayed on the page although the grant was issued. The page carried the
app-wide CSP form-action 'self', and Chromium applies form-action to the
redirect that follows the form post to the client's redirect_uri.

The authorize route now owns the CSP of its responses and the proxy
leaves that exact path alone. The consent page allows form-action for
its own origin plus the origin of the redirect URI the route has just
validated against the resolved client, and only https or http origins
qualify. Every other response from the route keeps form-action 'self'
with default-src 'none'. The redirect after the decision is now 303,
which tells every browser to follow a form POST with a GET. Refs #983
2026-09-15 14:06:27 +02:00
Marc-André Bombeck 3dece61cc8 Merge the review fix for record settings the grant cannot manage 2026-09-13 15:17:36 +02:00
Marc-André Bombeck 45ee70acb7 fix(settings): list record content only where the grant manages it
The anamnesis page writes through requireRecordAuth("manage",
"profile"), which refuses a grant whose sections do not reach profile.
The Settings list and the navigation entry checked only the level and
the record kind, so a MANAGE grant scoped away from profile would be
offered a page whose saves are refused.

Record-content destinations now name the section they write, and are
listed only when the active record's manageable sections include it.
A destination without a named section is never listed. The shell and
the navigation read the same predicate. MANAGE grants are whole-record
today, so managed profiles and adult MANAGE shares list what they did
before.

Refs #939
2026-09-13 14:49:56 +02:00
Marc-André Bombeck 873e9cb4fb fix(pwa): keep a subscribed worker registered behind a gateway
A same-origin auth gateway that answers an expired session with a 200
sign-in page returns HTML for both the navigation and the /api/version
probe, which read as another app. The worker then unregistered and
destroyed the Web Push subscription, which nothing re-creates, so
reminders stopped without notice.

The worker no longer unregisters while the registration holds a push
subscription, or when the lookup is unavailable. It deletes its caches
and passes requests to the network for the rest of its life instead.
The probe now sends same-origin credentials, so a valid gateway cookie
reaches HealthLog.

Refs #847
2026-09-13 14:45:31 +02:00
Marc-André Bombeck e34c5db45e fix(pwa): confirm a foreign origin before the worker retires
A reverse proxy with a response-header allowlist strips
X-HealthLog-Page from every real HealthLog page, and the worker then
unregistered on each load and dropped Web Push subscriptions.

A successful page without the marker now only sends requests to the
network while the worker asks /api/version with no-store and a manual
redirect. It deletes its caches and unregisters only when the answer
positively is not HealthLog: a 404, a non-JSON 2xx, or JSON without
the version shape. A failed probe, any other status or the HealthLog
shape resumes normal service. buildSha may be null on a self-built
image, so only its presence is checked.

Refs #847
2026-09-13 14:16:52 +02:00
Marc-André Bombeck cd44a85537 fix(pwa): let the service worker leave an origin HealthLog left
The worker is registered with scope / and serves /_next/static/*
cache-first. A self-hoster who stopped HealthLog and started another
app on the same port kept getting HealthLog's cached chunks until they
unregistered the worker by hand.

The proxy now marks every page response with a presence-only
X-HealthLog-Page header. When a successful navigation arrives without
it, the worker stops handling requests for the rest of its life,
deletes its own caches and unregisters, and hands the page through
untouched. A network failure keeps the offline fallback, and a
redirect or error status is never read as another app, so a gateway
in front of a stopped HealthLog does not drop the Web Push
subscription.

Refs #847
2026-09-13 14:00:11 +02:00
Marc-André Bombeck 9bf78e32d6 Merge remote-tracking branch 'origin/main' into fix/close-account-enumeration 2026-09-11 16:01:25 +02:00
Marc-André Bombeck 625763982b Narrow a spent email budget to the field, and record both refusals
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
2026-09-11 14:36:58 +02:00
Marc-André Bombeck 4460047910 Merge the coach cap counting only the tokens the operator paid for
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
2026-09-11 14:18:21 +02:00
Marc-André Bombeck fe420f76d8 Cap the profile email-address conflict answer
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.

Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
2026-09-11 14:05:25 +02:00
Marc-André Bombeck 6a5fb338b1 Make every login refusal cost the same verification
Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.

`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
2026-09-11 13:59:42 +02:00
Marc-André Bombeck 6ef139c7b2 Remove the anonymous account-discovery endpoint
`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.

Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
2026-09-11 13:56:49 +02:00
Marc-André Bombeck 7436a0fcba Merge main — the test-infrastructure fixes from the shared suite
Brings in the truncate retry, the socket fix for the record-fence spec that
failed this branch's second e2e shard, and the corrected absence assertions.
2026-09-11 13:22:43 +02:00
Marc-André Bombeck 42b9aefdfd test(integration): retry a truncate that lost a deadlock
The suite wipes the database between files with one TRUNCATE, which needs
the strictest lock on every table at the same time. The server writes in
thirty-nine places without awaiting the result, so one of those can land
after the test that caused it has returned, and Postgres then picks the
truncate as the deadlock victim. It failed an unrelated file twice in two
days and passed on the rerun both times.

The truncate is retried up to four times, on that one error and no other,
with a short back-off. Three cases hold it there: a deadlock that clears,
an error that is not a deadlock and must not be retried at all, and a
deadlock that never clears and must still fail. All three go red without
the retry.
2026-09-11 12:38:31 +02:00
Marc-André Bombeck b565edc48d docs(ai): drop planning vocabulary from the ledger comments
CLAUDE.md forbids planning shorthand in committed artefacts, and this branch
introduced 74 lines of it across source, tests, a schema field and two
migrations. Someone reading the migration SQL in a year has no way to resolve a
wave letter or a round number, and a comment that points at something outside
the repository names no reason at all.

Three passes. A version followed by a parenthetical keeps the version and loses
the parenthetical, because the version says since when something holds. A
sentence that leaned on the vocabulary to carry its meaning is rewritten to
state the reason directly: the arrival reservation now says it was refused over
money the operator never spent, rather than naming the defect a wave existed to
remove. Three test titles carried the marker in the name itself and are renamed
to what they pin.

Comments only. No behaviour, no renames beyond those three titles.
2026-09-11 12:37:54 +02:00
Marc-André Bombeck 7b21840630 fix(ai): the arrival reaction reserves through the shared ledger gate
The arrival-reaction claim carried its own copy of the reservation upsert so
the write could ride the claim transaction. The copy drifted: it learned to
write operator_tokens and kept gating on the day's mixed total, at the halved
background ceiling. On the row the wave was written for, 1.2 million tokens
served by the user's own plan, the reaction was refused all day at half the old
ceiling, reproducing the defect the wave exists to remove on the one surface it
forgot to convert. reserveBudget now takes the transaction client, so both
writes still roll back together and there is no second copy left to drift.

The row also remembers who paid for its reservation. The supersede refund in
data-arrival has no chain to ask and could only reverse the total, so the
operator share of every superseded reservation stayed on the row forever,
walking the operator's ceiling upward against spend that never happened. A
supersede is routine, not an edge case: any second measurement of the same kind
on the same local date before the worker runs. The resume path re-derived the
owner from a chain the health ledger may have reordered since, which leaks the
same way or drains another request's balance.

A structural guard makes the invariant executable. Every INSERT column list,
SET clause and RETURNING list that names one counter must name the other, and
the set of files holding raw ledger SQL is pinned. Both defects above were a
grep away and both would have failed this test.
2026-09-11 11:11:12 +02:00
Marc-André Bombeck 41596d1cb8 docs: comments name what the code does, not how it was built
Thirty-one files carried internal build vocabulary and anchors pointing at
notes that are not in the repository: a reader of `checklist.ts` in a year
would find its rationale keyed to a research id they cannot open, inside a
comment that also names an internal build stage. Two test titles carried the
same anchors.

The version tags and the whole of the reasoning stay; only the vocabulary and
the dangling references go, with the few sentences that were built around an
anchor rewritten to stand on their own.
2026-09-11 11:03:04 +02:00
Marc-André Bombeck 97174f470d fix(demo): the shared demo account takes no free text and no new record
The demo allowlist had grown two entries it should not carry. The demo is one
published account every visitor signs into, so `PUT /api/coach/about-me` was
an anonymous write of arbitrary text into the Coach prompt, persisted for
every later visitor until the next reseed; it is gone, and the baseline step's
anamnesis card renders read-only under `DEMO_MODE` so no visitor meets the
refusal as a failed confirm. `POST /api/managed-profiles` bought nothing: the
route opens with a fresh-MFA requirement the demo account cannot satisfy, so
the guardian arm was limited to finishing without the profile either way.

The baseline profile write stays, and the block's preamble now describes what
is actually admitted and why the two refusals are refusals.
2026-09-11 10:57:42 +02:00
Marc-André Bombeck 5c194cc9b9 fix(settings): a fresh managed connection reads as connected again
The managed-record integration projection mapped the ledger's new `unknown`
state to `disconnected`, but that mapping runs after the connection map has
already established a real credential. A guardian who connected Withings on a
child's record saw "Not connected" until the first hourly sync landed, beside
a row whose own `connected` field said true — an invitation to revoke and redo
the grant they had just made.

`state` on this projection reports that a pipe exists, not that it is live;
liveness is the sync verdict the card beside the pill already reads. With no
ledger row and a credential present, the pipe exists.
2026-09-11 10:54:56 +02:00
Marc-André Bombeck 3642bbe4de Merge wave C — the flow cannot strand, mislead or silently switch off
The getting-started checklist's data queries follow the same setup rule as
its visibility, so a settled account stops being told to do what it has
done; a failed completion leaves the confirm screen usable; an empty
multi-select is no longer recorded as an answer; the confirm screen names
the modules a run would take out of the navigation; the settings links it
offers work before the first completion; and demo mode can fill the
baseline it is shown.
2026-09-11 10:24:48 +02:00
Marc-André Bombeck d2f47e3bb5 fix(auth): the refusal code joins the published catalogue
Two structural guards caught the new gate. The error-code catalogue is derived
from the route tree on every run and fails in both directions, so a code the
server emits and does not publish is a client that cannot branch on it; the
session-surface guard freezes the short list of files under `src/app` that
resolve a caller without `requireAuth`, and the register route now belongs on
it — `requireAuth` is the wrong gate there twice over, since it would throw at
the anonymous caller the route exists for and this route wants the ABSENCE of a
session.

The code is `auth.already_authenticated` rather than the bare
`already_authenticated`: the catalogue's own header rules that new codes are
dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The
OpenAPI document renders the catalogue into the ErrorEnvelope description, so
it is regenerated.
2026-09-11 09:51:32 +02:00
Marc-André Bombeck bda18f9e2d test(profile): the edge proxy is not a profile submitter
The demo allowlist names a path and a method as data, which reads to the
submitter matcher exactly like a request expression. proxy.ts submits
nothing and has no answer to read, so it is excluded by name.
2026-09-11 09:35:17 +02:00
Marc-André Bombeck 982c35287d fix(demo): the setup flow's baseline writes are allowlisted
A demo visitor who typed a height on the confirm screen got a 403 and a
generic toast, and the "someone I look after" arm could only take
"Finish without the profile": the profile PUT, the self-context PUT and
the managed-profile POST were missing from the demo allowlist, so only
the empty-form path completed.

All three are added, pinned per method as the rest of the list is. None
of them creates health data.
2026-09-11 09:26:10 +02:00
Marc-André Bombeck cfca84a294 fix(auth): the invite landing redirects at the edge, no shell, no race
The proxy now answers `/invite/<token>` with a real 307 onto
`/auth/register?invite=…` (or a bare `/auth/register` when the segment fails
the `hlv_` shape gate), carrying the baseline transport headers plus the
no-store / noindex / no-referrer hardening the token-in-path surface has had
since v1.17.0.

Before this the redirect came from the landing page, which Next delivers in the
RSC flight payload rather than as a server 307 — the shell mounted first and
raced it. At the edge there is no HTML and no hydration, so there is nothing to
race. The page stays in place as the fallback for any path that bypasses the
proxy matcher.
2026-09-11 09:09:46 +02:00
Marc-André Bombeck 55ff6df205 Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/error-codes.ts
2026-09-10 18:20:07 +02:00
Marc-André Bombeck 6adbee581e Apply the review of the setup flow: the child's answers reach the child
"Someone I look after" derived the child's answers onto the guardian's
own record. The completion now takes the managed record the profile
step just created, checks the same access rule the guardian's modules
route applies, and lands the same derivation and dashboard seed on that
record through the same record-keyed write; the guardian's own flow is
stamped complete and latched without touching its map. Three
integration cases pin it, and the child journey reads the child's map
through a real switch — it asserted the guardian's map before, which
pinned the wrong record.

Also from the review: the checklist's visit row read a meta the visits
list never publishes and now counts its body; the first-result tile
read the measurements list as an array and now shows the reading; the
demo allowlist admits the disclaimer and tour writes, with a structural
test over every onboarding route file; restart clears the old first
result, and the screen trusts the ledger before painting a result as
done; the headroom sweep's raw-key check matches the flow's own key
shapes; the demo migration script stops copying the dropped column; the
questions-to-pass rule and the shell's onboarding-path rule are pure and
pinned; the baseline form composes the shared action row; the schema
diff loses the unrelated formatting churn.
2026-09-10 18:19:34 +02:00
Marc-André Bombeck 8c7cdcfc2b Tighten the setup flow after a review of the whole diff
The done screen moves focus to its heading like every other screen; the
confirm screen's preference list holds only dt/dd pairs and the settings
link stands beside it; Back from Q1 is not offered once the flow has
been entered, because the front door would resume past the welcome and
bounce straight back; the medication task records no target on the
ledger, as the contract says, and keeps the id on the screen; and the
two success affordances go through the outcome module rather than a
hand-rolled tick. The error-code catalogue lists the answers and restart
codes and no longer the retired step ones, and the session-surface guard
learns the one new deriving helper the onboarding pages read through.
2026-09-10 16:09:18 +02:00
Marc-André Bombeck 02ca15ad6e Translate the setup flow into the other six locales
Every key the flow, the checklist rows, the Settings card and the
module-off action added to the English bundle now exists in German,
Spanish, French, Italian, Polish and Korean, with the same placeholders.
The forty-seven keys the five-step wizard left behind — its carousel,
goal picker, source grid and step counter — leave all seven bundles,
and the English-leak guard drops the one entry that named them.
2026-09-10 15:47:22 +02:00
Marc-André Bombeck a062610895 Retire the five-step wizard's route, goals and columns
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.

The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
2026-09-10 15:32:35 +02:00
Marc-André Bombeck 1c2e7c29de Add the setup flow's welcome screen and shell
The front door reads the same ledger every screen reads: a record that
never entered the flow sees the welcome, one mid-way is sent to the step
it owes, a finished one is offered the questions again. The shell prints
the counter and an ordered step list off the step machine, so it shows
exactly the screens this flow has, and every screen moves focus to its
heading on arrival.

"Skip for now" is a real exit — it stamps the account past the first-run
redirect and opens the checklist on the dashboard — and the one-time
medical disclaimer now gates it as well as "Set up", because a skip that
reached the dashboard without it would be a way round the acknowledgment.

The five-step wizard's welcome carousel, goal picker and source grid go
with it; the numbered step route follows in the next commit.
2026-09-10 15:10:50 +02:00
Marc-André Bombeck 12d6aa3078 Merge the scoped-grant fences, the delegate controls that ask their own section and the published switch refusals 2026-09-10 12:29:09 +02:00
Marc-André Bombeck 3017e24bb1 Merge the needs-based onboarding's server state and the derivation that never hides data 2026-09-10 12:23:34 +02:00
Marc-André Bombeck 681e69a723 fix(modules): drop the last three fail-open gates and discover the class instead of naming it
Six surfaces were named and fixed; nine carried the expression. `/labs`,
`/medications` and `/mood` still read `inSharedRecord || user?.modules?.X`, and
every precondition holds for them too: all three are presentable to their own
scoped grants, all three module keys map to a real section so the masked map
answers correctly for a delegate, and none of the three routes behind them
carries a module gate of its own — so a typed URL rendered a working page for a
record whose owner had switched the module off.

The guard is a discovered sweep now rather than a six-entry list. It walks
every component under `src/app` and `src/components` through the shared walker,
keeps the ones that read the module map, and asserts the SHAPE: no boolean `||`
joins the delegate flag to a module read. The search window 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 tenth surface fails on
arrival.

Writing it turned up why the first version could not have found the three: the
comment stripper every structural guard in this directory re-spells removes
block comments first, and cannot tell a real opener from a `/*` inside a line
comment. `mood/page-client.tsx` explains its gate with "Every
`/api/mood-entries/*` route also enforces the gate", and that `/*` opened a
fake block that swallowed the next 2.2 kB of real code — the gate three lines
below it included. A guard reading that file was searching a source with a hole
in it and reporting a clean sweep. The shared walker now carries a stripper that
walks each line left to right and lets whichever opener comes first win; the
published-coverage sweep beside it moved onto the same one.
2026-09-10 12:14:14 +02:00
Marc-André Bombeck 70e40e097d docs(sharing): correct the four statements the self-hosting guide gets wrong
Measured against the route declarations on this tree:

The health-background section names allergies, family history and the profile
facts, and omits visits, the practices behind them and the vaccination record —
all three of which `scope.ts` puts in that section, so a grant is narrower on
the page than it is in the product.

"Aggregate surfaces appear only on a whole-record grant" is true and
incomplete: a whole-record grant does not open them equally either. The daily
digest and the dashboard's snapshot answer at read, the health score, the
dashboard's summary and the insights need manage.

The write level lists seven creates against twelve WRITE declarations. A visit,
a practice, a vaccination dose and the booster reminder it suggests were
missing, and the guide said nothing about the creates that need manage inside a
section a write grant can otherwise add to, or about the vault taking no
delegated write at any level.

"Exports, the health-record archive" sat under what a delegate cannot reach,
while `POST /api/export/health-record` answers at manage on a whole-record
grant and its own comment says why. The row is about the full exports and the
encrypted archive; the doctor report is named as the exception it is.

The sharing-denial guard's docblock carried the same class of stale claim — 116
fenced modules, 26 never published — and that gap is closed on this tree. The
caveat becomes an assertion: a new leg recomputes the fenced modules from the
route sources and requires every one to be published, and it names no number,
because a hard-coded figure is what turned the paragraph into a false statement
about the contract. It fails when a published path is withheld from it.
2026-09-10 11:25:09 +02:00
Marc-André Bombeck 3f015effec test: prove the setup flow against a real database and a real grant
Four claims the unit suite cannot reach, because each is about what is in the
database afterwards: the answers persist and a replay writes nothing new, the
module derivation happens once and leaves a later Settings change alone, a
restart resets the steps and not the modules, and a delegate with a live
accepted grant and a switched session is refused on all three writes — with
neither record acquiring a row, which is the quiet failure a fall-back to the
caller's own record would produce.

The demo guard gains the three paths and, beside them, the case that matters
more: admitting one verb on a path must not open a sibling.
2026-09-10 11:17:00 +02:00
Marc-André Bombeck 4410a496cc docs(sharing): say that the writable-section list is existential, not per verb
`writableDomains` answers "is there at least one delegated write route in this
section this grant satisfies". Its docblock promised a per-section answer the
table cannot give: within a section the split runs by verb, and three of the
four sections marked writable carry MANAGE-only creates beside their WRITE ones
— an allergy and a family-history entry under `profile`, a water entry and a
checkup reminder under `measurements`, a snooze and a re-recorded dose under
`medications`.

No shipped control is wrong because of it: the allergy and family-history forms
already ask the manage question, and so does the water entry. But the name and
the sentence beside it invite a control to read "my section is writable,
therefore my create is", and that reading is wrong in three sections out of
four.

Documentation only, in the three places the claim is made — the published
field, the function that derives it, and the guard that holds it against the
routes, whose green run now says what it does not prove. A per-verb table would
be a second place the route declarations live, and the declarations are what
decides; the control keeps asking the hook for its own verb class.
2026-09-10 11:16:55 +02:00
Marc-André Bombeck 0ee26625be fix(modules): let the record's own module map close its pages for a delegate too
Six page gates read `inSharedRecord || user?.modules?.X`, with a comment saying
the delegate's own module map does not govern the owner's record. That was true
when it was written and stopped being true in v1.38.14: `GET /api/auth/me` now
publishes the RECORD's map, masked to what the grant opens, so the prefix that
protected against reading the wrong map now widens the gate instead. The
navigation drops the entry correctly while a typed URL still renders the page
for a record whose owner switched the module off, and `/api/vaccinations`
carries no module gate of its own, so it renders and works.

Deleting the prefix answers both cases from the one map, which is what it is
for. A structural guard holds the six expressions: it locates each gate, proves
it is a module gate, and then asserts it does not widen on the sharing
question. It finds nothing to assert on if the expression moves, and says so,
rather than passing on an empty match. All six legs fail against the previous
gates.
2026-09-10 11:04:49 +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 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 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 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 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 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 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 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