Commit Graph
322 Commits
Author SHA1 Message Date
Marc-André Bombeck 24922b2787 Close the way round the delegated-intake refusal
v1.36.1 stopped a delegate changing a dose the owner had already recorded, and
it stopped them on one route. The sibling `POST /api/medications/[id]/intake`
reaches the same rows through the shared slot upsert, where the only existing
protection is against a pending sync echo washing out a recorded dose. An
explicit skip is not an echo: it fell through, flipped the outcome, and the
inventory the take had consumed was refunded on the way. So the refusal I added
was a door with the next one left open.

The rule compares OUTCOMES rather than refusing every write onto an actioned
slot, and the reason is the third case in the new contract. A double tap, an
offline replay and a retry after a partial batch all re-post the same decision,
and a caregiver hits all three; refusing those would make the feature
unreliable exactly when somebody is standing in a kitchen with a pill box. Same
decision applies, a different one is refused.

The check rides into `applyCanonicalSlotWrite` rather than sitting at the call
site, because that is where the existing row is already loaded: no extra query,
and nothing between the read and the update for a concurrent write to slip
through. A refused attempt files under the owner with the same action name the
sibling route uses, so it reaches the activity panel the same way.

Three integration cases, and each asserts the row rather than the status alone
— a 403 that had already written the flip would read as protection while being
the opposite. Watched fail: with the delegate flag forced off, the flip returns
201 while the replay and the owner's own correction stay green.
2026-08-04 15:32: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 69343c8820 Stop a delegate deferring somebody else's medication reminders
Found by an adversarial pass over the release candidate before it was tagged,
and it is the reason the tag waited.

`POST /api/medications/intake` is admitted for delegates because a caregiver
standing next to the patient has to be able to say the tablet went down. It is
an update, though, and the same body can carry two other things. Snoozing
writes `snoozedUntil` on the OWNER's medication row, and the reminder cron
skips that medication for as long as the stamp is in the future. The field
takes any future instant. So somebody with write access could stop another
person's medication reminders for years, and three things made it quiet: the
owner is deliberately not notified of a delegated snooze, the activity feed
renders the whole family as "marked a dose", and the only accurate trace was a
line in the admin audit log.

The second was flipping a dose the owner had already recorded — taken to
skipped and back, which rewrites their compliance history and refunds
inventory. The consent screen promises that changing what is already there
stays with the owner.

Both refuse for a delegate now and stay open to the owner, who is the only
person either was ever for. A refused attempt is filed under the owner and gets
its own sentence in the activity panel rather than borrowing the one for
marking a dose, because folding them together is what made the gap dangerous.

Two more from the same pass:

The consent notice named allergies among the things a delegate may add. That
left the admitted set before this release shipped, and it said so in six
languages on the one screen where somebody decides who may write into their
health record. It also omitted half of what IS admitted. The copy now matches
the frozen list, and the test asserts the withdrawal rather than only the
presence of the rest.

The banner told a WRITE delegate nothing at all — the mode line rendered only
in the read case, guarded by a comment stating that write grants did not exist
yet. This is the release that made them exist. It matters that the banner say
it, because the rule that a refused control is absent rather than disabled
rests on the banner naming the mode globally.

And a third instance of the substitution this release already fixed twice: the
BMI chart divided the owner's weights by the height of whoever was looking.
Inside a shared record it draws nothing now rather than a number belonging to
two different people.
2026-08-04 12:30:01 +02:00
Marc-André Bombeck 7eed26eb01 Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration 2026-08-03 21:18:13 +02:00
Marc-André Bombeck 75644bc3d2 Carry recorded side effects through the backup and back
MedicationSideEffect was classified as backed up and was not carried: the
export wrote nothing and the restore had nothing to read, so a restored
account kept the drug and lost the reason someone may have stopped taking it.
Delegated write access sharpens that — a side effect is one of the things a
second person can contribute, and it was the contribution a restore dropped.

The rows ride inside their medication, like the schedules beside them. A
top-level array would have to carry `medicationId` and survive a restore that
mints a fresh id for a portable file, which is the remap the intake events
already work around by drug name; a nested create has no id to remap, because
Prisma binds the child to whatever id the parent row actually got.

The note follows the measurement contract for the same dual-column
arrangement: a portable export carries the decrypted note and no ciphertext, a
disaster-recovery payload carries the ciphertext verbatim, and the restore
re-encrypts a legacy plaintext row rather than writing plaintext back into the
column.

Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim
where a written verdict cannot: the round trip seeds a side effect, exports
through the real builder, empties the account and reads the row back out of
the real restore route with its category, entry, severity and note intact.
Both halves were excised to check the test can fail — the restore branch turns
the count red, the payload turns the field values red.
2026-08-03 21:15:45 +02:00
Marc-André Bombeck a004d2358e Merge branch 'work/shared-front-door' into work/sharing-write-integration
# Conflicts:
#	src/__tests__/delegable-surface-guard.test.ts
2026-08-03 21:15:37 +02:00
Marc-André Bombeck 508cc8a60a Let a delegate read the dashboard they land on
Ten routes on `/` refused with `undeclared_mode`, which made the first page
of account sharing the only page in it that does not work. Each one now
declares a mode, per route rather than as a class.

Eight are record reads: the dashboard snapshot, the Today digest, the badge
grid, the Coach nudge signal and reminder ledger, the medication low-stock
thresholds, and the two presentation blobs. The old objection to admitting an
aggregate — a summary can carry a finding from a module the grant does not
cover — does not hold while a grant is whole-record and all-or-nothing. Every
admission site names that, so the day per-module scope lands the aggregates
are findable.

The layout routes split their verbs. Reading the owner's arrangement is right
and, for the dashboard, forced: the snapshot already carries the layout and
the client seeds the same cache cell from it, so an actor answer would put two
people's layouts in one key. Writing it is not a caregiver's job, so the PUT
and DELETE stay bare and refuse. Same on the medication list, where the stored
order names the owner's medication ids and means nothing anywhere else.

Two are actor surfaces: the UI language belongs to the person reading the
screen and must not be transplanted onto the record, and the feature-flag
matrix reads the settings singleton and no user row at all.

Also fixes a second instance of the delegate-data-under-owner-banner defect,
on the same page: the dashboard's client-side band fallback derived blood-
pressure targets and the weight range from `/api/auth/me`, which answers about
the caller, and painted them over the record's charts. Under a switch it now
derives nothing and waits for the snapshot's own bands.

The integration suite drives every admitted route with both accounts seeded,
proves the two answers differ, and then demands the owner's back.
2026-08-03 21:12:35 +02:00
Marc-André Bombeck 89c872a169 Pin the two writes this branch withdrew, instead of leaving their old proof
Taking `POST /api/allergies` and `POST /api/family-history` out of the
delegable set moved one end and left the other: the integration suite still
asserted that a delegate holding a WRITE grant gets a 201 from both, so ten
cases went red on a decision that was deliberate.

They are pinned the other way round now, with the reason in the file. A
delegate is refused at either grant level, nothing lands under either
account, and — the leg that matters most and that nothing else here covered —
the owner's own unswitched write still succeeds. Withdrawing a delegated
write by breaking the ordinary one would have been the worse bug.
2026-08-03 20:56:04 +02:00
Marc-André Bombeck 785a1eacfc Freeze the delegable write set and drive it against Postgres
The surface guard grows a write leg. Membership is decided by the argument
a module passes to the record resolver, not by the identifier, because a
delegable read and a delegable write name the same symbol and mean opposite
things. The call matcher resolves aliased and namespace-qualified calls
back to the original name, and proves it can against synthetic modules.

Two assertions carry the decision not to add a provenance column to eleven
tables. Every module in the write list has to call auditLog, which is the
only writer that stamps the acting account, and none of them may reach the
audit table through the client instead. That decision rested on the trail
existing; it now rests on a test.

The prose on the read list said the write arms keep refusing under a
switch. Eleven of them no longer do, so those sentences are rewritten
rather than left to lie.

The integration suite drives the shipped POST exports through the real
handler, resolver, grant table and Prisma. Five legs per verb: the write
lands under the owner and nowhere else, a read grant is refused with
nothing written, a caller who was never granted is refused the same way,
the request after a revocation is refused with no re-login, and a caller
who never switched is indistinguishable from before. The grant is minted
through the invitation flow rather than planted, and the audit assertion
reads both ends of the row.
2026-08-03 12:11:28 +02:00
Marc-André Bombeck 9e3cd7c430 Merge pull request #706 from MBombeck/work/dw-c
Tell the owner when somebody else marks a dose
2026-08-03 11:22:17 +02:00
Marc-André Bombeck d72451d59b Merge pull request #705 from MBombeck/work/dw-b
Offer read or read-and-add when you invite somebody
2026-08-03 11:21:26 +02:00
Marc-André Bombeck c98a478003 Merge remote-tracking branch 'origin/main' into work/dw-a 2026-08-03 10:54:01 +02:00
Marc-André Bombeck f751510675 Merge remote-tracking branch 'origin/main' into work/dw-c 2026-08-03 10:39:47 +02:00
Marc-André Bombeck aadf751355 Merge remote-tracking branch 'origin/main' into work/dw-b 2026-08-03 10:39:45 +02:00
Marc-André Bombeck 4f8fa25b51 Anchor the sync fixtures on a relative clock
The sync-changes fixtures seeded rows at a literal calendar date. The route
serves only what sits inside the tombstone retention horizon, which is sixty
days plus a fifteen-day margin, so the fixture was inside the window when it
was written and fell out of it exactly seventy-five days later. Three cursor
round-trips started failing at midnight with no commit behind them, and would
have failed on every run from then on.

The rows now anchor five days before the current time, so they cannot age out
of the window they are meant to be inside. The mood entry's own date string is
derived from the same anchor rather than written out, because the two drifting
apart is the same defect one level down.
2026-08-03 10:34:48 +02:00
Marc-André Bombeck c7960d726d Scope the idempotency replay cell to the record a request claims
A delegate retrying with one `Idempotency-Key` across two people's records met
itself in one cell. The cache tuple was `(caller, key, method, path)`, so the
second record's request replayed the first record's cached 201: the handler
never ran, no row was written for the second person, and the caller was told it
had been — with the other person's id in the body. Nothing errored and nothing
logged a conflict, and five of the write verbs about to become delegable are
wrapped here.

Fold the claimed record into the `key` component, centrally, once. Not into
`userId`: that column is a foreign key to `users` with a cascade behind it and
cannot hold a composite. It keeps naming the caller, which is what makes two
delegates on one record two cells for free. The separator is a byte the key
validator refuses, so a caller cannot hand-craft a key into a cell belonging to
a request they are not making.

A request with no acting account keys byte-for-byte as it did before, so a
client that never heard of sharing cannot be affected.

Accepted and pinned by test: the fold uses the CLAIM, before the grant check,
so within the 24h window a delegate whose access has ended can still replay a
key they had already completed. They receive bytes they already had, nothing
new is written, and a fresh key from them is refused because that request
reaches the handler.

Both frozen surfaces move in this diff. The claim reader gains a guard leg
freezing its consumers, since an unchecked claim is exactly the value a future
caller must not mistake for permission.
2026-08-03 10:18:27 +02:00
Marc-André Bombeck 533400ce60 Let an invitation carry the level it offers
A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.

The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.

There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.

The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
2026-08-03 10:12:08 +02:00
Marc-André Bombeck bf7fba4af0 Route the delegated-dose alert through one helper that refuses on self
The intake routes call notifyDelegatedIntake with both account ids and
the helper decides. Keeping the comparison here rather than at the call
sites means a route added later cannot notify somebody about a dose they
marked themselves, and a snooze is answered in one place as what it is:
moving a dose, not resolving one.

The medication name is read scoped to the owner, because that name goes
onto the owner's lock screen. Nothing addresses the delegate: the
dispatcher takes a single account id, and under record substitution that
id is the owner. The person who marked the dose learns what they did
from the response they are already awaiting.

The integration test asserts the ledger row before the sender mock, so a
dispatch nobody recorded cannot pass as a dispatch.
2026-08-03 09:52:02 +02:00
Marc-André Bombeck 5ff949ea70 Merge pull request #702 from MBombeck/work/backup-plan-reader
Prove a backed-up model travels, instead of proving a name exists
2026-08-02 20:22:24 +02:00
Marc-André Bombeck cb9867e379 Prove a backed-up model travels by making it travel
The plan says which models a backup carries and a source-text check says
each one has a reader and a restore branch written for it. Neither can
tell a restore branch that runs from one that only exists: wrap the
nutrient restore in `if (false && …)` and every check stays green, which
is the correct-green-and-inert shape this project already shipped once.

So settle it end to end. Seed one row of every two-ended model, export
through the real payload builder, delete the account so the cascade
empties what a hand-listed sweep would miss, restore through the real
admin route, and count each row back out. A model that never travels
comes back at zero and says which one it was.

The two-ended list is a literal tuple now, so the seed registry can be
keyed by it: adding a name without seeding and counting a row for it is
a compile error rather than an unproven claim.

Also names `src/lib/illness/dto.ts` as a backup writer. The illness
day-log's symptom include is a shared constant living there, not a
literal in the records writer — the same "the caller delegates" shape
that already caught the measurement pager.
2026-08-02 19:34:47 +02:00
Marc-André Bombeck 49ffba77f3 Merge remote-tracking branch 'origin/main' into work/sharing-routes
# Conflicts:
#	src/__tests__/delegable-surface-guard.test.ts
2026-08-02 18:07:11 +02:00
Marc-André Bombeck 81e5c7464b test(sharing): point the refusal arm at a route that can never be delegable
`GET /api/measurements` was the resolver test's example of a route refusing
under a switch. It is delegable now, so that arm was asserting the opposite
of what ships and went red on the migration — which is the arm doing its job,
not a defect in it.

Split in two. The measurements route keeps both of its real claims: unchanged
un-switched, and the owner's rows and not the delegate's once switched. The
refusal moves to `GET /api/share-links`, which is non-delegable permanently
and by design — a delegate who could mint a clinician link would hold a door
that survives the owner revoking their access. A route that can never join
the frozen list cannot break this arm the same way twice.
2026-08-02 17:36:44 +02:00
Marc-André Bombeck 210380f46d Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
2026-08-02 17:13:30 +02:00
Marc-André Bombeck 7a39a649be feat(sharing): open the medication reads to a delegate
Ten routes: the cabinet list, the medication detail, its cadence, schedule
revisions, side effects, inventory, phase config and intake history, plus the
account-wide intake list and the batched compliance read.

`GET /api/medications/compliance` is the one delegable read that spends a
quota, and its rate-limit bucket now keys on the ACTOR while everything below
it scopes to the record. Both halves matter: a delegate hammering the route
must burn their own allowance rather than lock the owner out of their own
cabinet, and switching records must not hand the same caller a fresh one. The
test reads the `rate_limits` row rather than inferring it from a 429, because
a bucket keyed on the wrong person still answers 200.

`GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar
fields and no secret, and it tells a delegate whether the owner holds a live
ingest credential and for which medication — in the same file as the PUT that
mints one that would survive revocation.

The two intake validation breadcrumbs move onto `auditLog()` for the actor
stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached
under a switch.
2026-08-02 17:06:23 +02:00
Marc-André Bombeck fe765a7ae2 feat(sharing): open the cycle reads to a delegate
Four routes: the cycle history, the day log, the profile and the custom
symptom vocabulary.

All four gate on `requireCycleEnabled(user.id, user.gender)`, and both
arguments now come off the record. That is the correct reading and the one
that had to be verified rather than assumed: whether cycle tracking applies
is a property of the record, so a delegate who tracks nothing still sees the
owner's cycle, and a delegate who tracks their own sees nothing when the
owner does not. Both directions are asserted.
2026-08-02 16:57:07 +02:00
Marc-André Bombeck 10f9a83356 feat(sharing): open the sleep, nutrient, illness and workout reads
Eight routes: the two sleep reads, the nutrient overview and its daily
series, the illness episode list, detail and day logs, and the workout list.

The illness day-log read is the one worth reading twice. Its queries are
episode-scoped rather than user-scoped, and what makes them safe is the
`loadOwnedEpisode(id, user.id)` hop above them. That hop now resolves against
the record, so a delegate cannot reach their own episode from inside somebody
else's account — asserted, because it is the kind of thing that would look
fine and behave wrong.

`GET /api/workouts/[id]` stays refused. It differs from the list beside it by
one conditional include that decrypts a paragraph written about the owner.
2026-08-02 16:52: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 af0f749740 feat(sharing): open the lab and clinical-record reads to a delegate
Ten routes: lab results and their detail, the biomarker catalogue and its
detail, allergies, family history, the mental-health screener history, and
the health-profile facts. All GET arms; every write arm in the same files
keeps `requireAuth()` and refuses under a switch.

The family-history payload is the one admitted read that carries third-party
health information by design — it describes the owner's relatives — and it is
admitted because a caregiver reading a family history is what the feature is
for and the rows are the owner's own.

The mental-health module gate resolves against the record, not the caller.
That is the recorded decision: the module map says which parts of a record
exist, so a delegate who enabled the screener on their own account gains no
screener view inside somebody else's. The integration test asserts both
directions.
2026-08-02 16:38:39 +02:00
Marc-André Bombeck a463b1fa01 Stop recording the address a delegate accepts a grant from
A consent record has to answer who consented and when, and the grant row
already does. It does not have to answer from where, and an address kept for
as long as both accounts exist is personal data held past the point it
evidences anything. Nothing read the column, which makes it exactly the
write-only field scripts/audit-column-readers.ts exists to surface.

The acceptance stays evidenced: audit_logs records it with the caller's
address under that table's own retention. The column never reached the wire,
so the grant view and the OpenAPI contract are unchanged.

Written before the feature shipped, so migration 0295 drops an empty column.
2026-08-02 16:36:12 +02:00
Marc-André Bombeck 255cbf5fe0 feat(sharing): open the measurement reads to a delegate
Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the
measurement list, the single reading, the two series reads, and the two
reminder reads. Each is admitted in the delegable classification with its
five answers written; each enters the frozen list with the argument for why
a delegate reading through it cannot extend their own reach.

Only the GET arms move. The POST, PUT and DELETE arms in the same files keep
`requireAuth()`, which refuses outright while a switch is active, so a file
entering the list admits a read and nothing else.

The two validation breadcrumbs on the list and series reads now write through
`auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the
only writer that stamps `actorUserId`, and without the stamp a delegate's
malformed query lands on the owner's record looking like the owner's own.

Integration coverage drives the shipped GET exports against real Postgres:
the owner's rows reach a delegate holding a live grant, the delegate's own
rows do not, a caller without a grant is refused `sharing.access.denied`, the
request after a revocation is refused with no re-login, and a caller who
never switched sees exactly what they saw before.
2026-08-02 16:30:46 +02:00
Marc-André Bombeck f9ad2d3986 Format the account-access round trip 2026-08-02 15:43:49 +02:00
Marc-André Bombeck 562123d4d1 feat(sharing): publish which records an account may open
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.

`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.

The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.

The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.

Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
2026-08-02 14:25:55 +02:00
Marc-André Bombeck 56447a9457 feat(sharing): invite, accept, switch, revoke, renounce
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.

Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.

Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.

Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.

The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck 843c74c98c feat(sharing): record who acted when it was not the account's owner
Adds AuditLog.actorUserId (migration 0294) and threads it centrally, out
of the wide-event context the acting-account resolver already stamps. No
call site passes an actor, so no route can forget one.

NULL keeps meaning "acted for themselves" — the reading every existing
row carries. The threading fills the column only when a request resolved
an acting account AND the row is being filed under that account, so a
self-action, a refusal, and a delegate's row about themselves all stay
NULL rather than acquiring an id that would rewrite what the history says.

Delegated reads coalesce to one row per owner, delegate and day, through
a partial unique index and an insert that expects to lose the race. The
alternative is a row per request on the busiest table in the schema and
an activity view made of identical lines.

The column carries no foreign key, against the grain of user_id beside
it: SET NULL there erases an account from its own history, while here it
would turn "the delegate opened this record" into "the owner opened this
record". An id that no longer resolves reads as a deleted account; an id
that silently became NULL reads as a lie.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck 3f932040ad test(sharing): hold every arm of the acting-account resolver
The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.

The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.

The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.

The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
2026-08-02 11:32:13 +02:00
Marc-André Bombeck 48f29cc802 test(sharing): hold the revoker pair at the database
A revoked grant with no revoker, or a revoker with no revocation, are
both rows that cannot answer the question the table exists to answer.
The migration already enforces the pair; this asserts it against real
Postgres rather than trusting that the domain module is the only writer.
2026-08-02 10:03:04 +02:00
Marc-André Bombeck 07e8ca9ef5 test(sharing): teach the wipe guard that ownership is not always user_id
The delete-all-data guard counted an account's surviving rows with a
literal `WHERE user_id = $1`, which is the same assumption the wipe loop
itself used to make. Account grants relate two accounts and carry no such
column, so the guard raised `column "user_id" does not exist` and stopped
before it could assert anything.

The predicate now comes from the wipe plan's own owner-column
declaration, so the guard asks the question the route answers instead of
its own version of it.
2026-08-02 10:03:04 +02:00
Marc-André Bombeck 33c879dd6b feat(sharing): add the account grant and its lifecycle module
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.

That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.

The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.

The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.

Three lifecycle paths, each proven against the real route:

- Delete All Data removes grants from either side. The wipe loop asks
  every model for "this account's rows", and a table relating two
  accounts has no user_id to answer with, so wipe-plan.ts now declares
  the owner columns and translates. Wiping only the grantor side would
  leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
  classified NOT_IN_BACKUP: rolling data back is a decision an admin
  makes, but rolling an authorization back is a decision nobody makes,
  and a grant revoked on Tuesday must not come back alive out of
  Monday's file. After a restore, sharing is off and both people
  re-consent.

The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
2026-08-02 10:03:04 +02:00
Marc-André Bombeck 9c4235b66d fix(backup): a retired catalogue key must not cost the whole file
Three of the restore's lookups resolve a key against a seeded catalogue:
cycle symptoms, illness symptoms, mood factors. Each threw on a key it
could not resolve, inside the one transaction that wipes and rebuilds the
account. So a single renamed symptom rolled everything back and answered
500. No measurements, no doses, no notes, nothing. A year-old backup read
by a newer instance is the ordinary case for a backup, and the catalogue
drifts between those two dates by design.

The unresolvable link is dropped and named now. What it costs is one
association between a row and a symptom; the day-log, the mood entry, the
flow, the temperature and the encrypted note all come back. The count and
the exact keys ride the response, the audit row and the wide event, and
the admin surface shows them in a panel that stays put instead of a toast
that leaves. A quiet drop would be the older failure of this same seam
wearing a success message.

A dangling `cycleId` or a dose recorded against a medication the file
never carried still throws. Those name rows the file was supposed to hold,
so a missing one means the file contradicts itself. A catalogue key names
a row the instance owns, which is why it gets the other answer.

`IllnessSymptom` carries no owner column at all, so a key that will not
resolve there can only ever be drift; the file was never able to supply it.

The restore does not mint the missing catalogue row back. That would
preserve the link at the price of reference data colliding with whatever
the next migration seeds under that key, and a label nothing can translate.
2026-08-02 09:04:36 +02:00
Marc-André Bombeck 03d2a712d0 Move the contract tests to the end that now writes
Three integration cases pinned the old behaviour: the sync handshake stamping
the checkpoint, and the calendar read persisting the prediction. Both are the
faults this release removes, so the cases were asserting a bug.

Each file now asserts the read writes nothing AND that the other end does. The
second half is not decoration: without it, "nothing was written" would pass
just as well against a column nothing writes at all.

The cycle case seeds its own history and its own profile row rather than
reaching a route to get one, because reaching the route is exactly what the
prediction must no longer depend on.
2026-08-02 02:00:08 +02:00
Marc-André Bombeck 3b37d73f31 Merge branch 'work/ecg-ingest' into release/v1.35.3 2026-08-01 19:50:52 +02:00
Marc-André Bombeck 42e16a6e12 Stop the plaintext-waveform check passing on a coincidence
It asserted the stored ciphertext does not contain each sample rendered
as a string. Base64 is full of digits, so a short sample value like 3
matches by accident: the check failed on a mutation that had nothing to
do with encryption, and would equally have passed while leaking. It now
asserts the column holds the keyId.base64 envelope and nothing else,
which a serialised sample array cannot satisfy.
2026-08-01 19:35:16 +02:00
Marc-André Bombeck 8f90fd3174 Prove the ECG ingest, the dedupe and the migration on dirty data
A round trip through the real handlers: post a recording, read it back
through the list and the detail routes, and check what a client actually
receives rather than a fixture rebuilt at each end. Post it twice and
the second call says updated, not inserted, and the table does not grow.
Feed the same strip to the export-archive importer afterwards and it
says skipped; feed it to the importer first and the route says
duplicate. Both directions leave one row.

The migration test drops the index, manufactures the collisions the
resolution step exists for, and runs the migration file through the same
executor the deploy uses. It checks which row survived each tiebreak,
that the salvaged event link followed the keeper, that rows differing
only by user, source or sampling rate were left alone, and that the
constraint rejects a new twin afterwards. Applying the migration to the
empty testcontainer proves none of that.

The Withings ECG tests now hand the transaction the same delegate the
shared writer uses, so they still watch the real write instead of a stub
standing in for it.
2026-08-01 19:26:47 +02:00
Marc-André Bombeck ad1554a36f Make the round-trip case actually round-trip
The flip from an authored recipe back to the defaults was written with the
retired pillar as the thing left out. With that pillar gone, both halves of the
case sent the same full list, so the first read false where it expected true and
the flip was never exercised at all.

It now takes out a pillar that still exists, and says at the assertion why the
two halves have to differ.
2026-08-01 17:56:20 +02:00
Marc-André Bombeck 96c10f07e6 Give the today-just-in spec an account that earns a hero
The spec watched the day flip from provisional to final on a hero that
only its own subject was keeping on screen: the arrival marker was the
last thing in the calm-degrade guard holding the hero up for the bare
fixture account, so removing the arrival chip took the hero with it and
left the spec nothing to observe.

Putting the arrival back into the guard would bring back a worse
problem. An arrival-only digest lands in the compact all-clear
composition, which would tell someone with no readings at all that
everything is clear - a claim about their health made from nothing, and
made only on the days something happened to sync. The guard stays as it
is; the fixture was what was wrong.

So the spec now seeds its own precondition: one integration needing a
reconnect, which is an ordinary Today for anyone whose readings come
from a device. It is read fresh on every request rather than through the
dashboard snapshot cache, so unlike a seeded score or briefing it cannot
lose a race with a cached snapshot, and the rail item admits no module
gate and no dismiss path. The row is removed again afterwards.

While the spec was open: it now pins the state it starts from. It only
ever asserted that the pending-sleep note was absent at the end, which a
note that never rendered also satisfies. It asserts the note is up and
the day reads provisional first, so the two halves are one assertion
with the number flipped.

The component side is pinned in unit tests, since the spec needs a built
app and a database to run.
2026-08-01 13:21:04 +02:00
Marc-André Bombeck 46146f389c Test the configured flag over the real snapshot and digest assembly
Five accounts, each writing its selection through the real PATCH route
and reading the flag back off the dashboard snapshot, the daily digest
and the derived-metric payload. Nothing is hand-built between the ends:
a builder that forgets to carry the field returns undefined and the
assertions fail.

The four negatives are the ways an account reaches the default
composition without having authored it, including one that kept every
pillar (hasSelection is true, the flag is not) and one that took out a
pillar its modules had already withdrawn. A sixth case walks the same
account through a save and an undo, so a value cached under a key that
does not include the recipe cannot pass.
2026-07-31 18:10:25 +02:00
Marc-André Bombeck 5d13f0e69c Prove the false drop cannot come back
The named failure now has a test that drives the whole chain a request
takes: fixture rows through the real reader, the real score record
write, the real dashboard snapshot, the real verdict rung, and the real
digest that gives the morning push its sentence. Only the IO around that
chain is faked. A version built on a hand-written snapshot literal would
have stayed green through a release where the assembly between the two
ends dropped the field, which has happened here before.

Every case runs twice on the same data. Once with the recipe changed
three days ago, once with it changed a month ago. The second run is what
proves the fixture really does produce an eleven-point fall, so the first
run's silence is the guard working rather than an account with nothing
to say. The decline is gradual on purpose: a cliff would raise each
pillar's own noise floor and the delta would be suppressed for a reason
that has nothing to do with the recipe.

Beside it, the boundary's edges, the notice key's two dimensions and the
seam's shape, each with a case proving the guard stays quiet on innocent
data. Against real Postgres, a row now has to come back carrying the
recipe version and change date the account's stored configuration
supplied.
2026-07-31 16:48:40 +02:00
Marc-André Bombeck ab8e9a164d Carry the recorded score days through wipe, backup and restore
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
2026-07-31 15:13:47 +02:00
Marc-André Bombeck 5784d5ec72 Prove the blood-pressure basis survives the assembly, not just both ends
The grader returns the score and its basis together and the pillar builder
copies both across, and each of those ends already had its own test. Neither
sees what happens between them: the envelope hand-off, the reader's narrowing,
the pillar list, the JSON that finally goes out. A field can be produced
correctly, consumed correctly, and never reach anyone, which is exactly how a
payload field shipped earlier this month and carried nothing on the wire.

These tests read the assembled response and assert the basis that arrives, so a
future hand-picked key list on that path fails here rather than in someone's
browser.
2026-07-31 11:15:27 +02:00
Marc-André Bombeck af70312306 Merge work/onboarding-honesty 2026-07-31 08:10:39 +02:00