Commit Graph
14 Commits
Author SHA1 Message Date
Marc-André Bombeck f7b3ce6ee0 fix(privacy): encrypt Coach conversation titles and custom-metric notes at rest
The title of a Coach conversation is the first 80 characters of the
opening message, and the note on a custom-metric reading is free text;
both were stored readable while every other note was encrypted.

Migration 0357 adds coach_conversations.title_encrypted and
custom_metric_entries.note_encrypted (the title loses its NOT NULL) plus
partial indexes on the backfill's discovery predicate. Every writer now
stores ciphertext only: conversation create, proactive nudge, reminder
surfacing, rename, entry create and edit, and both restore formats.
Readers prefer the ciphertext and fall back to the old column for rows
not yet migrated; a ciphertext that does not open throws instead of
falling back. The history title search decrypts in memory, since SQL can
no longer match the title.

free-text-encryption-backfill is a boot-time, idempotent pg-boss job
(staggered past the startup storm, daily discovery tick) that seals each
remaining readable value and nulls it in one transaction per row. A pass
that sees a row twice stops with an error instead of looping.

Both columns are registered for key rotation (registry, CLI script, and
the in-app rotation that walks the registry). A disaster-recovery backup
carries ciphertext only, sealing a not-yet-migrated row on the way out;
a portable export carries readable text. The old columns are marked for
removal in a later release.
2026-09-26 19:46:01 +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 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 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
Marc-André Bombeck 1d2744a109 fix(api): give every offset-paged list a unique tiebreaker
`/api/measurements` and `/api/labs` sort on one non-unique column and
page with `skip`/`take`. When the sort key ties, Postgres does not
promise the same order for the query that fetches page N and the one
that fetches page N+1, so a client paging its own history can receive a
row twice and never receive another — silently, on both sides.

Ties are not hypothetical here. The partial unique index forbids two
live measurements of the same type at one instant, but the tie that
occurs in production is the cross-type one: a daily `stats:` rollup
posts steps, distance, flights and active energy at the identical
timestamp, and an unfiltered history list pages straight through the
block. A lab panel drawn in one sitting shares `takenAt` across every
analyte in it.

Appending `{ id: <same direction> }` to the `orderBy` makes the order
total. Same fix on the five siblings that page the same way — medication
intake history, illness day-logs, custom-metric entries, reminder
history, mental-health assessments — because the defect is the shape,
not the two routes it was found on. No wire change; `/api/sync/changes`
already ordered this way.

The fixtures seed ids in an order unrelated to insertion order, so the
assertion on order inside the tied block can only hold if the secondary
key is doing the work: with the tiebreaker removed the new file fails on
all three cases.
2026-09-10 04:01:14 +02:00
Marc-André Bombeck d6bc858899 fix: refuse an unrecognised request field where the caller can act on it
A `z.object()` without `.strict()` strips an unknown key and parses
clean, so a caller that misspells a field name gets a 200 and loses the
value with nothing to tell it. The worst of them was the Coach
question-dismiss endpoint, where an omitted `question` means "dismiss
all": a typo in that key wiped every pending question and answered as
though one had been dismissed.

Twenty-four inline schemas across twenty-two endpoints are strict now —
the settings toggles, the restore and bulk-delete endpoints, message
feedback, the admin user update, the onboarding acknowledgements, the
provider chain, the chart overlay preferences, the medication extractor
and the two client-side reporters.

The strip stays where it carries weight. Sync ingest, device
registration and the layout PUTs are built out of fields that shipped as
additive and optional, on the promise that a server which does not know
one ignores it; an app running ahead of the install it talks to is
routine here rather than exceptional, and refusing would turn a dropped
field into a refused batch — a year of history lost to one row, a
dashboard that will not save, a phone that quietly stops registering for
push. A read filter discards nothing when a stray query parameter rides
along, the OAuth endpoints are required by RFC 6749 and RFC 7591 to
ignore parameters they do not recognise, and an import file comes from
another version or another tool by definition. Each is registered with
its reason in a new inventory test, so a schema cannot join them without
someone making the call. That test parses the tree rather than grepping
it: fourteen of these schemas are written `z\n  .object({…})` and a
`z.object(` sweep never saw them.

`sanitiseZodIssues` renders `unrecognized_keys` instead of passing it
through. Zod leaves `path` empty and puts the names in `issue.keys`, so
the sanitised issue named nothing at all and the caller was told only
that its body had failed. The names now ride a bounded `keys` field and
the message is rebuilt from them, capped at ten keys of 64 characters
and restricted to identifier characters, so a caller-supplied key cannot
echo back verbatim through the error path or into the audit ledger. The
names survive `stripValuesFromMessage` because a field name is
structure, not user-typed content.
2026-09-03 05:49:13 +02:00
Marc-André Bombeck 90bad17cc8 Show the answers behind a screening score
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.

Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
2026-08-14 09:46:48 +02:00
Marc-André Bombeck 7bf8dbd8af Give custom-metric values the same second chance as every other entry
Refs A3-11. Deleting a logged custom-metric value was the one hard delete
left on an entry surface — everywhere else a deletion tombstones and the
toast offers Undo. The DELETE verb now stamps deletedAt, every read
(list, latest-value, counts, revive check) filters the tombstone, and the
new entries/restore endpoint clears it scoped to owner and parent metric —
a foreign, live or mismatched id is a silent no-op, never an existence
leak. The history table's delete toast carries the Undo action.
2026-08-14 09:34:43 +02:00
Marc-André Bombeck da3cabb55f Give a grant a section set and a third level
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.

`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.

`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.

`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.

`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.

`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.

`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
2026-08-04 17:11:40 +02:00
Marc-André Bombeck 25e86b36b3 Close the delegated-access gaps the release audit left open
Seven findings from the adversarial pass before v1.36.1 was tagged. One
blocking defect shipped in that release; these did not, and they are all places
where somebody looking after another person's health record meets a wall with
no explanation, or where a guard cannot fail.

The document vault was the largest. `/documents` is marked as a shared
destination, so the shell offers it under a switch, and every read behind it
resolved the caller instead of the record: the vault painted a query-error card
and the illness view painted another beside it. That is the same failure the
release describes as fixed on the dashboard. Five GET arms move to the record
resolver now, and only the GET arms: the list, one document, its file, its
thumbnail and the vault's usage payload, which the filter bar is built from.
Uploading, retyping, deleting, linking a document to an illness and every AI
verb stay with the owner, and the two reads that spend decryption CPU key
their quota on the person calling rather than the record, the rule
`medications/compliance` already follows.

Logging a value on a metric you track yourself leaves the admitted set. It was
admitted, named on the consent screen in six languages, and unreachable: the
only form that posts one sits on a page a shared record does not open, and the
component that renders it says so in its own comment while returning null. It
is the third route the rule that withdrew allergies and family history fitted
exactly, and it was applied to two of the three. Reading those values stays.

Two guards could not fail, and both were proved by mutation before and after.
The prefetch guard checked rule three with two regexes: one was satisfied by
the doc comment every page carries, the other missed an aliased import, and
the pair stayed green with the dashboard genuinely prefetching off the
switch-blind session. It reads the AST now, resolves aliases through the
module's own import table, and pins the five prefetching pages by name so a
page that stops being detected is loud. The activity verb map decided
membership on a literal `requireRecordAuth("write")`, so putting the argument
on its own line, which is what the formatter does the moment the call grows,
dropped a route out of the scan and turned a genuinely-red run green. Both
counts are of matches now rather than of files walked.

The rest are single instances of rules applied everywhere else. "Take all due"
was withheld on a note saying it rides the bulk intake route; it does not, and
its own docblock says why not, so a helper with five morning tablets tapped
five times for nothing. Marking a dose skipped is offered wherever marking it
taken is. A form could be opened in the frame before the app knew whose record
was on screen and then stood there once the answer arrived, on the capture
sheet and on the dashboard's quick-entry sheets. Two more copies of the intake
toast were still building their own, one of them with an Undo pointing at a
route no delegate may call. The customise link on the mood page led to a page
that exists to say it is not part of what was shared. And the access-level
radios on the invitation screen are hidden by design with no focus ring on
their label, so keyboard focus moved invisibly across the one screen where
somebody grants write access to their record.

Three new structural guards, each watched fail first: the customise link sits
inside a capability branch on every surface a delegate can reach, every intake
surface confirms through the one shared decision, and the batch sweep is
offered at the level that admits it.
2026-08-04 14:55:32 +02:00
Marc-André Bombeck 095aba095e Let a delegate add to the clinical record of a shared account
An allergy, a family-history entry, an illness episode and a custom-metric
value now resolve the record rather than the caller.

Two of them carry a consequence worth naming. The family-history row
describes the record's relatives, so it is the one admitted write where
third-party health information is present by design; it is stored as the
record's own, exactly as the read arm already serves it. The illness module
gate runs against the record before the write, so a delegate cannot open an
episode inside a record whose owner switched the module off, whatever they
set on their own account.

The custom-metric entry reaches its metric through the ownership hop, which
now resolves against the record: a delegate can only feed a definition the
owner made, and their own metric of the same name is unreachable from in
there. Its validation breadcrumb moves to auditLog().
2026-08-03 12:10:59 +02:00
Marc-André Bombeck 8c070c953b feat(sharing): open the mood, custom-metric and record reads to a delegate
Six routes: the mood-entry list and detail, the custom-metric list, detail
and entries, and the personal-record list. The custom-metric entries read
establishes ownership on the metric before it queries by metric id, and that
hop now resolves against the record — a delegate cannot reach their own
metric from inside somebody else's account.

`GET /api/mood/tags` stays refused. It looks like plain record vocabulary and
one select in the middle of it pulls the owner's tag layout, which is a
presentation preference and belongs to the person rather than the record.

The mood list's validation breadcrumb moves onto `auditLog()` for the actor
stamp, the same change the measurement reads took.
2026-08-02 16:44:52 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André Bombeck b3e6ba03d7 feat(measurements): add custom metrics (define, log, chart)
Add a user-defined custom-metric store alongside the built-in measurements
on the Measurements surface. A user defines an arbitrary metric (name, unit,
optional target window, decimals, description), logs values, and charts them.

The store is a separate generic catalog + values pair, isolated from the
closed measurement-type system: not synced, not in FHIR, not in insights —
log and chart only. Names and units are plaintext, mirroring the biomarker
catalog. The inline list lives beneath the measurement list (no new nav
entry); tapping a metric opens a detail page with the trend chart, target
band, stat strip, and an editable value history.

Migration 0224 adds custom_metrics and custom_metric_entries.
2026-06-30 08:54:39 +02:00