mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
4e182862c59eb0c626ac5efe1fcdb9c25f11909d
1332
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4e182862c5 |
Let a passkey satisfy the passkey-removal step-up
The gate landed with a softer mode for accounts holding no second factor, on the premise that they could not clear it. That premise was wrong. A passkey is a possession factor here and the codebase already says so in three places: a primary passkey login stamps Session.mfaVerifiedAt, passkey sits in FRESH_FACTOR_METHODS so a passkey-proved elevation reaches the fresh-factor routes, and the enrolment policy counts a primary passkey as satisfying it outright. The only thing refusing such an account was the reachability pre-check inside requireFreshMfa, which counts second factors and nothing else. So the mode is gone and the route asks for a full freshFactor: true with proofSource: "any-possession" — a second knob that widens which credentials count as "could ever prove this", and changes nothing about the gate. A passkey-only account now gets a real gate with the re-provable auth.stepup.required rather than no gate at all, and the dead-end auth.stepup.mfa_not_enrolled is reserved for an account holding nothing. hasSecondFactorEnrolled is deliberately NOT widened. It answers a different question, and requireFreshMfaIfEnrolled asks it before deciding whether account deletion, the data reset, the password change, the encrypted export and key rotation gate at all — counting a passkey there would change five destructive actions in passing. The two predicates sit beside each other with docblocks saying why they differ, and collapsing them turns eight existing tests red across three files. Recovery is named rather than assumed. On Bearer: mint an elevation with method "passkey". On the web: sign in again, which is what stamps the session — there is no in-page re-proof for a cookie session because the mint endpoint is Bearer-only by design. That limit is written into the route docblock instead of left to be discovered, and the removal's refusal now renders its own sentence in all six locales rather than the server's English "second-factor" prose, which was wrong for the account this card mostly serves. |
||
|
|
283699db63 |
Refuse a backup that claims a section it does not carry (#237)
A restore's first act is to delete the class of records it is about to rebuild, so a payload whose documents section is simply absent did not "restore everything else". It emptied the vault, reported success, and left the operator to find the hole later. The whole file is refused instead, above the transaction that wipes the account, and the refusal names the section and says nothing was changed — an operator holding the only copy of an account cannot act on "restore failed". The sections ride the error envelope as meta.errorCode backup.section.missing, and the admin console holds them in a panel beside the skip report rather than a toast that outlives nothing. The manifest is the discriminator, and only the manifest. A portable export leaves the mental-health screeners and the consent receipts out on purpose and declares both as omitted; those files still restore. So does a file whose section is present and empty, which is what an account with no documents honestly writes, and so does every backup written before the manifest existed. The integration test proves the distinction with the same builder and the same deleted keys, differing only in what the manifest said. The route is deliberately outside the published contract — requireAdmin is cookie-only — so the OpenAPI document is unchanged. |
||
|
|
e3be71f0e5 |
Gate passkey removal on the same step-up as second-factor removal
DELETE /api/auth/passkeys/{id} took a plain cookie session or a wildcard
Bearer, while DELETE /api/auth/me/mfa/webauthn/{id} beside it demanded a
fresh factor proof. The softer gate sat on the primary sign-in credential:
the last-method check stops an account locking itself out, but nothing
stopped a hijacked session stripping the credentials the owner signs in
with.
The route now goes through requireMfaManagementAuth, the same gate the
security-key removal uses, in a new "if-enrolled" mode. An account with a
second factor pays the full price on either transport — a freshly verified
cookie session, or a factor-proved X-Step-Up elevation on Bearer, so the
native client keeps the capability rather than being sent to the website.
An account with no second factor keeps the contract it had, because a gate
it can never satisfy is not a safeguard but a locked door in front of its
own credential list.
The elevation is spent immediately before the delete, so a foreign id or a
refused last credential does not burn a proof that then has to be minted
again. The route is added to the elevation allowlist with its reason, and
the either-factor enrolment test that was written out three times in the
API kit is now one function.
|
||
|
|
fffdc83ca4 |
Make the reset-every-threshold delete ask first
DELETE /api/user/thresholds without ?metric= erased the account's whole
override set on an omitted query parameter alone: no confirmation, no dry
run, and no rate limit of its own while the PUT beside it had one.
The wide form now wants { "confirm": "RESET_THRESHOLDS" } in the body — the
same typed-confirmation shape /api/settings/account and /api/settings/data
already use — and refuses 422 without writing anything. The ?metric= form
is untouched: it resets one band the person can retype, and the targets
sheet fires it in a loop. Both forms now run the PUT's 30 / 5 min limit.
The settings card's "reset all" control already opened a confirm dialog;
it now sends that answer to the server too. Its token is compared against
the route's declaration in the tests, so the two ends cannot drift apart
into a permanent 422.
|
||
|
|
1ae21b5971 |
Merge pull request #828 from MBombeck/feat/retired-routes
Answer a retired route with 410 rather than a bare 404 |
||
|
|
f4c0eb0ab9 |
feat(api): publish retirement in the contract, the capability read, and the coverage guard
Three surfaces, one source. The registry the proxy answers 410 from now also emits the tombstones into `docs/api/openapi.yaml` and rides on `GET /api/meta/capabilities`, so a retirement cannot be honoured at runtime and invisible everywhere a client could have read it first. The contract publishes each retired path with 410 as its only response, the removing version, and the replacement. `deprecated: true` is imprecise — the word means "still works, stop using it", and these do not work — but it is what makes a generator warn at the call site, and the description carries the exact truth the flag approximates. `security: []` because the answer is produced before any handler and reads no credential; publishing it as authenticated would tell a client to sign in to be told a path is gone. The capability read carries the same list. That surface is the one a client already polls on launch, so a retirement can reach it before the failing call rather than through it. The coverage guard gains a third state. Published and exempted both describe a route that exists, so removing one used to leave exactly one legal move — erase it from the contract as well — and that move is invisible to every client already calling it. A registered path is now required to be absent from `src/app` AND present in the contract, which is the one combination the phantom-path case rejected outright. Both ways out are named in the failure message. Its docblock says what the state cannot do, because the first draft overclaimed: a check over one commit's state cannot see a deletion, so it cannot insist that a removal should have been registered. Proving that needs the previous release tag, and the job running this suite checks out at depth one with no tags, which would make the check one that silently cannot fail. What is held instead is the registration's integrity, in three directions and with the mutation for each written down. The sharing-denial guard learns the same state: a retired path resolves no record fence because it reaches no handler. |
||
|
|
97d0c26ceb |
fix(glp1): match side-effect tags by key, not by an English/German word list
The GLP-1 side-effect chips in the mood form write the localised label into the flat `MoodEntry.tags` column, and three readers matched that label against a hand-written list of English and German words: the therapy timeline, the Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales. Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the day never reached the timeline, the Coach grounded its answers on an empty side-effect list, and a clinician read an empty table over a record that had three months of symptoms. Nothing on any of those surfaces said the table was a filter rather than the truth. The three lists had also drifted apart from each other. The chips are a closed set with stable keys, so matching the label was the defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue — seven keys, each with the i18n key its chip renders — and the mood form now draws its strip from it, so the write side and the read side cannot diverge again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading `messages/<locale>.json` for every entry in `locales`, rather than transcribing the labels into a second list. Transcribing would have fixed today's four locales and reopened the same hole on the day a seventh is added; deriving means a new bundle is matchable as soon as it lands, with no edit in the matcher. Comparison folds case, accents and separators, so the English and German tags already in the database keep resolving and a straight apostrophe matches the typographic one the French bundle uses. All three readers now report the catalogue key instead of the stored string. The same recorded symptom yields one key whatever language it was tagged in, which also fixes the mixed-language output an account gets after switching UI locale; the doctor-report PDF resolves the key through the label map so the clinician reads it in the report's language. A tag outside the catalogue is still never counted — a free-text mood tag must not reach a doctor as an invented symptom, and the rest of somebody's day does not belong on a therapy timeline. It is no longer dropped without trace: the timeline route annotates the unresolved count on its wide event, so the classification rate stays answerable while the tag text stays in the database. The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`. Those are removed. No chip writes them, so they only ever matched a hand-typed tag in two of six languages; keeping them would have rebuilt the defect inside its own fix. Restoring either symptom means giving it a chip and a label in all six bundles. The response schema for `GET /api/insights/glp1-timeline` now enumerates the keys, and its description no longer documents the bilingual match as if it were the contract. |
||
|
|
251a42bb0a |
Grade the Coach turn that went looking and found nothing
The numeric grounding guard keyed its activation off `loop.toolResults` alone.
A tool that finds nothing resolves to `{ present: false, reason: "none" }` with
no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools
snapshot fallback is populated only in the non-tool branch — so the guard sat
out every turn where the record was empty, which is precisely the turn where a
fabricated figure is likeliest and does the most harm. Ask about a metric you
have never logged and an invented number was persisted and streamed verbatim,
with no withheld-figure marker to say it had never been checked.
`toolTrace` is the discriminant, because it records the pure misses the results
array drops. Tools ran and every one missed: grade the reply — an empty ledger
there is not missing information, it is the positive statement that nothing can
be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to
grade against it rather than no-opping. The model answered without calling a
tool: still dormant, unchanged, because the tool-mode base prompt carries no
pre-computed figures and flagging a legitimately recalled one was the v1.32.1
regression. The inventory counts ride along as a widener so a plain count
restatement stays grounded.
Eliding the digits is not enough on an empty record. "Your sleep averaged […]
minutes, up from […]" still asserts a series, an average and a direction for a
metric with no readings; only the precision goes, the false claim stays. So a
turn that missed every tool and lost a figure to the guard is replaced with the
honest answer, in all six locales. Scoped so a reply whose figure reconciles
against a prior turn or an inventory count is left alone.
|
||
|
|
d49479bc64 |
Pin where the Coach stops checking its own numbers
Three characterisation tests for grounding gaps found while verifying that
every figure the Coach states comes from the record.
The numeric grounding guard rewrites any figure the turn's Grounding Ledger
cannot account for, and it holds: removing the strip reddens the existing
cross-turn ledger test. What is not covered is its activation. On the tool
path the guard runs only when `loop.toolResults` is non-empty, and a tool that
found nothing resolves to `{ present: false, reason: "none" }` with no
`available` payload, which the loop drops. So the guard disarms itself exactly
when the record is empty: ask about a metric with no readings, and an invented
figure is persisted and streamed verbatim, with no withheld-figure marker to
tell the reader it was never checked. The no-tools snapshot fallback is
populated only in the non-tool-mode branch, so the tool path has nothing to
fall back to, and every cloud provider runs the tool path.
Separately, a rollup bucket outlives the rows it was folded from. The read-swap
falls back to live SQL only on an empty band, `ensureUserRollupsFresh` repairs
the DAY tier over the trailing 90 days only, and it keys off
`Measurement.updatedAt`, which a hard delete does not bump. The integration
test reproduces the hard `deleteMany` in the WHOOP body sync against a
MONTH-band bucket: the deleted value keeps reaching the Coach's coarse tail
while the all-time extremes beside it report only the surviving value, and the
block is stamped current because `asOf` is derived from raw reading age and
knows nothing about rollup age.
All three assert current behaviour and carry a docblock saying so. Closing
either gap will redden them, which is the point.
|
||
|
|
50b483d8d1 |
Merge the health-data route group
Two groups created environment.ts independently; one carried all six paths, the other only the overview read, so the fuller side stands and the barrel keeps one import. |
||
|
|
c985f634aa | Merge the environment and ingest route group | ||
|
|
30930b46d0 |
Merge the auth and integration route groups
Two component-id collisions, both from the same cause: two paths that
are two doors onto one action, documented independently.
DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call
revokeDeviceCascade and answer with the same shape. Each grew its own
DeviceRevokeResponse, which the emitter refuses outright. The response
moves to shared.ts so neither module owns it and the next door onto the
same call finds it; the two envelopes keep separate names because an
envelope names an operation.
The settings module took both halves by hand rather than by keep-both:
one side added nine paths, the other three, with no overlap but a shared
import block and a shared reminder-thresholds entry that a mechanical
union duplicated.
|
||
|
|
bb9bf0ba68 |
Refuse what these four routes used to absorb in silence
Four surfaces answered a bad request by quietly substituting a good one. Each fix comes with the test that catches it, and each test was confirmed red against the old behaviour before the fix went back in. `PATCH /api/user/ai-provider` inspected its body key by key with `typeof` guards, so a numeric `model` or a boolean `baseUrl` failed every guard, produced no entry in the update, and the write went ahead without it — a 200 that says "saved" for a field that was discarded. A body of nothing but such keys came back as "No valid fields" naming none of them. The real schema now lives in `src/lib/validations/ai-provider.ts`, the route runs one `safeParse`, and a mismatch is the multi-issue 422 every sibling answers with. The accepted field set is unchanged, and the object is deliberately not strict: an unknown key has always been ignored here and still is. The one thing the schema has to carry that no other does is that this body holds plaintext credentials, so no rule may interpolate the value it rejected — noted where the next `.refine()` would go. `?slice=` on `/api/analytics` was compared against one literal and fell through to the DEFAULT body on anything else, so `?slice=summary` answered 200 after running the heaviest chain on the surface. Invisible and expensive at once. Closed enum, 422 on anything outside it. `?metricType=` and `?limit=` on `/api/personal-records` were parsed defensively, which read as robustness and behaved as silence — and in one direction it was worse than silence: dropping an unrecognised `metricType` WIDENS the read to every metric, so a typo returned more of the record than was asked for. Both refuse now, and the 500 ceiling refuses rather than clamps, because serving a different number of rows than were requested is the same quiet substitution. No caller in the tree sends a value the new rules reject. `POST /api/ingest/medication` bucketed on the client address, so a household running a chat bridge, a button and a home-automation rule — the normal deployment for this route — put all three in one 60/min cell and let the chattiest starve the rest. It already resolves a per-caller identity two lines further down and threw it away; the bucket is that token now. A request that resolves no token keeps the per-IP bucket it always had, through the shared anonymous helper, so a flood is still capped and a broken proxy chain collapses into the tight global cell rather than a shared "unknown" one. Its 429 goes through `apiError` and carries `meta` like every other refusal. Two older tests pinned the defects by name — "drops an unknown metricType silently" and "falls back to default on garbage ?limit" — and one pinned a throw where the rest of the surface returns an envelope. All three are rewritten with the reason beside them. The generated YAML is deliberately not in this commit. |
||
|
|
edccc17a4d |
Register the rest of the export, insights and medication surface
Twenty-five path entries across nine domains, all of them served and none of them described. Same cause as the first batch: `openapi:check` compares the registry against the YAML and never the route tree against the registry, so a route registered nowhere drifts with every gate green. Four groups where the contract had to say more than the shape does. The full backup is the download that carries a whole record in plain text, so what it contains is a safety question. It has two modes and this route reaches only one: called with no options, it always emits the PORTABLE form — decrypted columns, no tombstones, no row ids — while the disaster-recovery form belongs to the worker and the admin path. Three things it leaves out say so in the payload's own manifest: document bytes, workout GPS and per-sample series, and the sensitive pair, where the screener answers include the PHQ-9 self-harm item and a consent receipt restored elsewhere would assert an agreement that operator never obtained. The undeclared gap is named too, since roughly thirty models the plan marks as backed up still have no reader or restore branch. The four intake mutations differ in ways a caller has to know before firing one. The by-id delete, the bulk delete and the purge all tombstone, so the sync feed carries each removal; only the purge's compliance rollups are dropped outright, and those recompute. The first two refund the inventory stamp because not taking a dose puts the units back; the purge deliberately does not, because refunding a whole history would inflate the current container count instead. The bulk delete drops unmatched ids silently, so a partial match succeeds with a lower count than the ids sent. The two provider-configuration surfaces are documented by what they do NOT return. The chain answers provider types and boolean state, never a credential. The per-medication ingest endpoint returns a raw token exactly once, on the PUT that mints it, and a re-enable answers a null token with `created: false` rather than re-issuing — the stored value is an HMAC and there is no path back. Insight generation costs money, so both gates are published with the refusals they produce: the hourly per-user limit, and the day's token ceiling, which is refused at reservation time before any provider is contacted and carries its own code so a client can tell "too often" from "the day's spend is gone". A 200 there does not mean a provider ran. Two things the code said that were no longer true. The seeded-question route's docblock declared it deliberately unregistered; it is registered now, and a comment contradicting the registry is worse than none. And the idempotency guard went red on `/api/insights/feedback`, whose exemption said the path could not be published — which is exactly what that leg exists to catch, so the entry is gone rather than the assertion. `docs/api/openapi.yaml` is regenerated separately. |
||
|
|
aa80cb45c1 |
Say which rule a rejected Web Push subscription broke
`POST` and `DELETE /api/notifications/web-push` both answered a flat
`apiError("Invalid data", 422)` and discarded the Zod issues. A non-HTTPS
endpoint, an endpoint aimed at an internal host, an over-long one and a missing
subscription key produced byte-identical bodies, so a browser whose
subscription was refused could not tell a fixable client bug from a policy this
instance will never satisfy. Both now answer the multi-issue envelope every
other body-parsing route in the tree uses.
Echoing the issues is safe here and the route says why, because it is the kind
of thing that gets re-derived wrongly later: the endpoint is a subscription's
routing secret and `keys` is its crypto material, but `sanitiseZodIssues` emits
`path`, `code` and `message` only — the rejected value lives in `issue.params`
and stays server-side — and every message this schema can produce is a fixed
string or a length/format default rather than an interpolation. A test pins
that none of the four secrets reaches the wire, so a future schema change that
adds an interpolating message fails here instead of leaking quietly.
The tests assert on content rather than status: a status-only check passes
against the flat error it replaces, which is how this survived. Two of them
compare whole bodies between two different refusals and require them to differ.
The two `/test` routes were checked in the same pass and need nothing. Neither
accepts a body — they take no parameters at all — so neither runs a Zod parse
to discard. Their 422s already carry `not_configured`, `vapid_not_configured`
and `rate_limited_self` as machine-readable codes.
|
||
|
|
b6e34d1264 |
Close five honesty gaps on the integration surface
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.
The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.
Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.
The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.
Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.
The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.
The contract follows the behaviour in the same commit.
|
||
|
|
e39e2b60c2 |
Let the GLP-1 read reach the record its own write already reaches
`GET /api/medications/{id}/glp1` resolved the caller as themselves while
the POST beside it has resolved the record since v1.37.0. A manager
inside a shared record could therefore add a titration step to a
medication whose history the same path refused them: may write, may not
read. No reading of the sharing model produces that shape, and the
sibling reads on the same medication — `/inventory`, `/side-effects`,
`/cadence` — have all resolved the record at READ level for releases.
The read joins them at that level rather than at the POST's MANAGE,
because it is strictly narrower than the write already admitted. The
module was already on both reviewed allowlists, so nothing is newly
delegable; its reason line said the module was reached through the
MANAGE arm alone, which is no longer true and now says so.
The gap survived this long because a route that refuses every switched
caller is indistinguishable from a route nobody classified. Three legs
close that: a delegate scoped to medications reads the owner's titration
history and still cannot reach their own medication, a delegate scoped
elsewhere is refused, and a caller who never switched is unchanged. Both
delegate legs assert the refusal CODE rather than the status, because the
pre-fix answer was also a 403 — verified by reverting the handler, at
which point the first leg reads 403 for 200 and the second reads
`sharing.not_permitted` for `sharing.access.denied`.
|
||
|
|
a283211586 |
Describe the remaining auth, settings and notification-transport surfaces
Twenty-eight more verbs that exist, are called, and said nothing in the contract: registration, password rotation, the profile patch, the SSO entry point, passkey enrolment, the Codex device-auth flow, the device list and its revocation cascade, four per-user preference scalars, the email channel and the four channel self-tests, the record wipe, the privacy facts, the update check, and the Web Push transport. Four of them needed more care than the rest. `GET /api/auth/oidc/login` is documented as the two-armed route it is. The browser arm redirects to the provider; the native arm takes `client=native` plus a mandatory S256 challenge and returns to a compile-time scheme with a one-time code — or an MFA ticket when the account has a second factor. The two PKCE exchanges in play are named apart, because confusing them is the way to get this wrong. `POST /api/auth/register` is the only genuinely anonymous surface in the set, and its refusals are stated exactly: the SSO-only block, the 5-per-15-minutes bucket that collapses rather than falls open, the invite that is consumed whenever it is sent, the zero-user bootstrap that mints the first admin under a lock, and the one 409 that covers a taken email and a taken username so it cannot be used to enumerate accounts. `POST /api/auth/password` is a change behind authentication, not a reset, so it is described as one. `DELETE /api/settings/data` names what it takes and what it leaves: ninety-nine models hard-deleted, the account's own personal columns reset, credentials and interface preferences kept, one transaction, and a confirmation string that is deliberately not the one account deletion wants. Two collisions surfaced while wiring this up and are resolved rather than worked around. `PUT /api/auth/profile` and `PATCH /api/user/profile` are two doors onto one handler, so they now share one published request component instead of two descriptions of one validator. Their RESPONSES differ, though — one carries id and role, the other the display preferences, and neither is a superset — so they are named apart and the older description no longer claims to cover both. The four Web Push and APNs surfaces land in their own route module rather than beside the notification preferences: that module answers what gets sent and whether it arrived, this one the plumbing a client sets up and then verifies. Both self-tests report failure inside a 200, which the contract says plainly, because a client reading only the status will tell someone their notifications work when they do not. The generated YAML is left out; it is regenerated once the remaining groups land. |
||
|
|
49da15feaa |
Publish the nineteen routes the drift check could not see
`openapi:check` compares the registry against the committed YAML, so a route that was never registered produced no drift and no failure. These nineteen had shipped without a contract: the health probe, the dashboard analytics aggregate, the profile summary, personal records, the badge grid, the whole environmental-context module, both onboarding writes, the Apple Health import pair, the external medication ingest, the self-context adopt, the AI-provider update and the AI connection probe. Each entry is read off the route rather than inferred from a neighbour, so it carries what a client cannot guess: that `/api/health` answers a bare object and not the envelope, that `/api/analytics` records a score row and mints pattern ids on a GET, that `/api/ai/test` reports a provider failure as 200 with `ok: false` because a 5xx comes back as somebody's HTML error page, that the ingest route replays with 200 and creates with 201, and that a home location re-stamps its effective-from instant on every write. Three route-local request schemas move to `src/lib/validations/` so the published shape and the runtime parse are one object rather than two copies — a route module may only export handlers, which is why they could not be imported where they were. The AI-provider PATCH has no schema to move: it hand-rolls its parse field by field, so its entry says so and spells out that a wrongly-typed key is skipped in silence. The generated YAML is deliberately not in this commit. |
||
|
|
7f67c24ecc |
Describe the rest of the integration surface in the contract
Thirty-seven more paths the clients already call and the published spec did not describe: the six OAuth providers' credential, status, disconnect, sync and probe endpoints, the cross-provider surfaces under `/api/integrations/*`, the managed record's configuration tree, and the native device-revoke path. Where providers share a verb name they do not always share a contract, and the descriptions say so instead of averaging it out. Three divergences are stated per path. A disconnect refuses a repeat with 404 on six providers and answers an idempotent 200 on two. `/api/<provider>/test` answers a bare probe result while the same-named `/api/integrations/<provider>/test` adds the connection's last sync, and the Google Health one accepts a body that replaces the response shape outright. `GET /api/withings/status` refreshes and re-persists the OAuth token while it reads; the other six status endpoints write nothing, and each now says which it is. Two further facts a client cannot infer. The Withings credential DELETE writes no audit row and leaves the ledger at its last state, unlike every sibling, so its `state` is not proof of a live connection afterwards. The WHOOP connect ticket is a credential in a response body, returned once and stored only as a hash. The Google Health credential schema moves to the validations module; the managed record's six strict patch schemas are exported from where they already lived, so the contract publishes the objects the handler parses rather than a restatement. |
||
|
|
63f791120a |
Stop the API switch hiding live tokens, and cap the unit-preference body
Four defects the registry work surfaced, each with a check that fails without
the fix.
`GET /api/tokens` no longer consults the instance-wide API switch. That switch
gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a
token's ability to authenticate: nothing on the `requireAuth` path reads it, so
a token minted before an operator flipped it stays live while it is off, and
refusing the list hid exactly the credentials their owner most needed to see,
their own signed-in phone among them. The revoke keeps the gate; the asymmetry
is stated in both route comments and pinned by the test, because widening it is
a decision about what the switch means rather than a fix to the read.
`PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a
1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the
one endpoint in the family with no body-size limit at all — the new test proves
it by sending 2 KB and watching the old handler answer 200. Malformed JSON now
answers 400 like every sibling instead of 422; the only caller checks `res.ok`
and never the status, so nothing depends on the old code. The existing
malformed-JSON assertion was changed deliberately: it pinned the inconsistency,
not a contract.
`PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a
flat "Invalid request", so an empty name and a 200-character name produced
byte-identical bodies and the form had nothing to show beside the field.
`GET /api/user/thresholds` has a header that describes the handler. It promised
`{ defaults, overrides, effective }` and has always returned
`{ effective, overrides }`. The guard compares the two, so the comment cannot
drift again in either direction.
The two published operations whose contracts moved are updated with them.
|
||
|
|
d66c4f6cdc |
Register eleven served routes in the OpenAPI contract
Eleven routes the native client calls have never been in the registry, so
`docs/api/openapi.yaml` does not describe them. Nothing caught it:
`openapi:check` compares the registry against the YAML and never compares
the route tree against the registry, so a route registered nowhere drifts
with every gate green.
GET /api/analytics
GET,POST /api/cycle/symptoms/custom
GET /api/dashboard/summary
GET /api/environment
GET /api/export
GET,PUT /api/insights/settings
GET /api/insights/targets
GET,POST /api/medications/intake
GET,POST /api/medications/{id}/glp1
GET /api/mood/insights
GET /api/personal-records
Each contract is derived from the handler rather than from its comments:
the methods it exports, the parameters it reads with their real defaults
and bounds, the statuses it can actually produce, whether it resolves the
record fence, and which module gate it sits behind. Where the answer is
awkward it is written down rather than smoothed over — `/api/export`
answers a bare `data` key and not the standard envelope; `/api/analytics`
serves two unrelated shapes behind one query parameter and records the
health score it computes; `GET /api/medications/intake?scope=today`
projects rows before it reads them; the GLP-1 GET is not delegable while
the POST on the same path is; and `highlightInsight` on the dashboard
summary is pinned null by the handler and has never carried anything.
The intake aggregator's query and body schemas move from inside the
handler to `@/lib/validations/medication`, so the contract generates from
the same object the route parses with instead of a second copy of it. The
environmental overview gets its own route module because nothing owns that
surface. `docs/api/openapi.yaml` is regenerated separately.
|
||
|
|
b6efc6cb5a |
Register the sixteen auth, settings and discovery routes the spec never had
`pnpm openapi:check` compares the registry against the committed YAML and never the routes against the registry, so a route that was simply never registered stayed invisible to the gate. Sixteen of them are called by the native client and describe nothing: sign-out, the three per-user preference scalars, the two pre-session discovery reads, passkey login options, the passkey rename and delete, registration status, the consent latest-receipt reader and its revoke, account deletion, the instance service switches, the API-token list and revoke, the threshold overrides, the version read, and the assistant feature matrix. Each contract is derived from the handler rather than from its docblock, so the published statuses are the ones the code can actually produce — the 413 on a 1 KB body cap, the 400 the consent family answers where the rest of the API answers 422, the 403 an instance-wide API switch produces on a token READ, the last-admin and last-Guardian refusals on account deletion, and the fresh-factor arm that a Bearer transport cannot clear at any scope. Four request bodies moved to the validation modules the handlers now parse with, so the published wire cannot drift from the runtime one: the three `/api/auth/me` preference scalars into a new `user-prefs` module, and the passkey rename beside the login schemas. Behaviour is unchanged. The generated YAML is left out; it is regenerated once the remaining groups land. |
||
|
|
c8035fdd78 |
Describe the integration, notification and MCP surfaces in the contract
Sixteen routes the native client already calls were absent from the OpenAPI registry, so the published spec said nothing about them. `openapi:check` compares the registry against the YAML and never the routes against the registry, which is why the gap survived. Nightscout status / test / disconnect, the WHOOP credential and status reads, and the Withings status / disconnect pair land in the integrations module. The three per-channel configs under `/api/settings/*` join the settings module; the notification preference and delivery-health surfaces get their own module, as do the MCP connector connections and tokens. These are credential-bearing surfaces, so the contract states what each response actually carries. Every channel GET reduces its secret to a boolean, and the descriptions say so, along with the consequence: a client cannot read the value back, so an empty value on the way in preserves the stored one rather than clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable Bearer, and its description says outright that this is the only copy. Two inline request schemas move to the validations modules they belong in, so the registry documents the same object the handler parses rather than a restatement of it. |
||
|
|
a74dc289a8 |
Carry the archived schedule eras, chain and all
The register said a restored medication keeps today's schedule and loses the record of when the dose or the timing moved. Measured, it costs more than a record: the band minter reads each past day against the schedule that was live THEN, so an account that came back without its eras had its own compliance history rewritten against a plan it was never on. The hard part was supersededByRevisionId, the pointer from a corrected ARCHIVED era to the MANUAL row that replaced it. Three things about it had to be settled. It is a bare id column with no relation, so nothing in the database refuses a value that points at nothing. That makes losing it quieter and worse than a broken foreign key would be: every era consumer SKIPS a row that carries the pointer, so a corrected era whose link came back null goes live again beside its own correction, and the timeline shows one window twice with past days minted against whichever the query read first. A portable restore mints fresh ids, so the pointer cannot travel as one. It travels as a POSITION in the drug's own ordered era list. That list is the only address space both ends share: the builder sorts it totally (validFrom, then createdAt, then id) so two eras sharing an instant cannot swap places between two exports of the same account, and the pointer never crosses a drug, because every route that writes it scopes the update to one medication. The same mechanism serves both purposes rather than one carrying ids and the other positions. Writing it needs two passes, because the row a link addresses is as often written after the row addressing it as before. Pass one creates every era with the link null; pass two patches the links once every position has an id. A hand-edited file that names a position out of range, or an era's own position, gets the era and not the pointer, and the position is reported as scheduleRevisionLink with the JSON path into the file. Refusing the file would cost an operator every era of every drug over one bad integer, and writing the link would point a correction at whatever row happened to be there. A row that supersedes itself is treated the same way: every consumer would skip it, so the era would vanish from the timeline while still sitting in the table. The round trip seeds three eras with one supersede among them and asserts the shape of the chain, then asserts that exactly two are live. Three rows with every link null satisfies any count and has thrown away the only thing the pointer says. |
||
|
|
768320f3d3 |
Restore what each medication was supposed to move
The register said the drug comes back with no statement of what it was for, which is exactly what a pinned efficacy target is: a row exists ONLY where the person overrode the derived ATC / name resolution, so it is a decision and never a computation. Nothing recomputes it after a restore because there is nothing to recompute it from. The lab arm travels as a biomarker NAME rather than an id, the way a lab result's cross-reference already does. A portable restore mints a fresh catalogue id, and `Biomarker` is unique on `(userId, name)`, so the name is the only end of the reference that survives the trip. The obstacle was ordering, not machinery: the remap map already existed, but the drugs are rebuilt several hundred lines before the analytes are. The targets are collected against the id each drug actually got and written in a second pass once the biomarkers exist. The alternative was hoisting the biomarker restore above the medications, and it loses on two counts. `biomarkerByName` and `restoredBiomarkerIds` are read by the lab-result loop immediately below the block, so moving it would separate a map from its only other consumer and leave a later edit free to re-order them back with nothing noticing. And the dependency would read backwards: a pinned target is a leaf of the medication tree, and letting a leaf dictate where the clinical record gets rebuilt inverts what the file is about. The second pass states the constraint where it applies instead of hiding it in a line order. A named analyte the restore did not put back is dropped and reported as medicationTarget rather than written. `biomarkerId` is a real foreign key, so a dangling value would fail the constraint and cost the whole account over one override. Dropping beats nulling here because the override IS the reference: a row with neither arm set is what the resolver already reads as "no override", so keeping it would restore something that means nothing while still holding the primary slot. The round trip asserts the restored target points at the biomarker the restore itself wrote, resolved from the database rather than remembered from the fixture. Counting would have passed on a row whose biomarkerId came back null, and nothing else would have complained: the resolver falls back to deriving a target and the page still renders. |
||
|
|
14638a6660 |
Bring the ECG strips back from a restore
The register said the originating device may still hold them. That holds for about as long as the phone beside the watch does, and not at all once a Withings connection is revoked: the signal id stops resolving with it. So a self-hoster who exported and wiped had a file with no traces in it and a restore that reported success. Both ends live in src/lib/export/ecg-backup.ts, the arrangement the reminders, visits and Coach sections already use. Three things needed deciding. The waveform follows the note contract: ciphertext verbatim in a disaster-recovery payload, the micro-volt samples decrypted in a portable one. There is no placeholder for a strip that will not decrypt the way the Coach has one for a turn, because waveformEncrypted is a required column. Such a recording is left out of a portable file with a warning on the wide event, so the number of strips in the file is the number of strips that can be shown. measurementId is a REAL foreign key against the event row a strip was filed with, unlike the Coach references next door. A value that will not resolve does not dangle quietly here, it violates the constraint and rolls the whole restore back over one cross-reference. A portable export omits soft-deleted measurements, so that case is reachable rather than theoretical. The reference is resolved against the measurements the restore wrote, nulled when it cannot be, and reported as ecgReference. The strips are wiped and rewritten by the section itself. The wipe at the top of the restore never reached them: a recording hangs off the account, not off a measurement, so deleting the measurements only nulled the pointer and left the row behind. The round trip asserts the row column by column rather than counting it. A recording is three things at once and each is the whole value of the row on its own: a strip with no rhythm verdict is a squiggle nobody has read, a strip with no recordedAt belongs to no day, and a strip with no samples is a claim that an ECG exists which nothing can draw. |
||
|
|
a6c239d952 |
Merge pull request #820 from MBombeck/work/backup-awards
Carry the bests, the badges and the environment days through backup and restore |
||
|
|
775efad923 |
Merge pull request #821 from MBombeck/fix/proxy-relative-redirects
Stop redirecting to the address the process bound to |
||
|
|
f95fb95295 |
Carry the bests, the badges and the environmental history through a restore
Four models leave the coverage-pending register with both ends and a round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and EnvironmentTravelLocation. Two new section modules hold reader and restore beside each other, the arrangement the visits, vaccinations, reminders and Coach sections already use. The register understated two of them. The bests are "recomputable in principle", which is true and does not help: nothing recomputes them, and a recomputation over a restored history would find different rows, because sample series and GPS routes never travel, a portable file omits deleted readings, and a record set in a reading that has since been corrected cannot be found twice. The badge's unlock date is worse. The evaluator prefers a persisted date over a derived one, and several of the dates it can still derive come from counters no backup carries, so a dropped row does not relock the badge, it re-earns it today. PersonalRecord.sourceMeasurementId is the reference that needed care, and it is the dangerous kind. The schema declares no relation for it; migration 0054 created the column as a real foreign key against measurements. Writing it blind is not a dangling pointer, it is a constraint violation that rolls the whole restore back and hands the operator an empty account. Measured, with the resolution removed: "Foreign key constraint violated on the constraint: personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So the restore runs after the measurements, resolves the pointer against the rows it actually wrote, nulls what it cannot resolve and reports it under a new personalRecordReference skip kind. An unlocked badge names a definition in the code catalogue, which drifts between the release that writes a file and the one that reads it. The id is carried and written back verbatim rather than judged, the same answer the vaccination restore gives an antigen slug it cannot resolve. The readings and the location periods travel as a pair because carrying the readings alone is worse than losing them. The environment resolver reads the periods live, so with them gone the nightly seven-day lookback re-resolves every trip day to the home location and upserts over the row: an account would come back with a fortnight abroad and then have it quietly rewritten as a fortnight at home, days after a restore that reported success. Day keys stay strings end to end, and fetchedAt is restored verbatim so a two-year-old provisional reading does not claim it was confirmed today. The round trip seeds bests that share a metric type and an instant and differ only in the sport slot, two badges earned years apart (one of them under an id this build no longer defines), and a trip day beside a home day, then reads each back column by column. Counting alone passes every one of the failures worth fearing here: slots dropped so a 5 km best is presented as a 10 km best, unlock dates stamped at restore time, a trip day re-attributed to the home city, a location period whose bounds no longer cover the reading it explains. Each of those was applied on purpose and each failed on its own assertion. Two schema comments claimed no read path selects a column that this backup now selects; both say what reads it instead of staying wrong. |
||
|
|
cc8c69aa07 |
Stop redirecting to the address the process bound to
Reported from the iOS side, iOS #96. `GET /api/auth/native/login` answers location: https://0.0.0.0:3000/auth/login?flow=native behind a proxy that does not pass `Host` through. `NextResponse.redirect` needs an absolute URL, the route built one with `new URL(path, req.url)`, and `req.url` there is the address the Node process bound to. That is the primary onboarding path. ASWebAuthenticationSession follows the redirect, iOS refuses the restricted port, and the person lands on a browser error page with no way forward. Distributed builds carry no associated domains on purpose, so the browser handoff is also how passkeys are meant to work; without it a new account is down to a password and a confirmation code. Two things hid it. Every error branch of that route builds the fixed healthlog:// scheme with no host at all, so the route behaved correctly on every failure and broke only when it was supposed to succeed. And its own test built a request whose host matched, so the absolute URL it produced looked right. The assertion there used toContain, which is satisfied by the broken value just as happily as the correct one. The same line was in six more places: the error redirects of the Withings, WHOOP and Fitbit connect routes, which send a browser to /settings/integrations. Behind the same proxy those land on the same unreachable address, so all seven move to a relative Location. A relative Location is valid per RFC 7231 and the client resolves it against the address it actually called, which is the public one by definition. The alternative is reading X-Forwarded-Host, and that would put one more proxy setting between a self-hoster and a working sign-in. Three checks, as the report asked for. The route test now drives a request whose URL names the bind address, which is what a proxy hop looks like from inside the handler. A structural test freezes the pattern out of the four files and, separately, requires each of them to still reach for the helper, because a file that simply deleted its redirect would pass the first check and fail its users. The helper refuses a protocol-relative value, which is a host in disguise. Verified by breaking it: putting the old call back fails four checks, the last of them reporting the reported value verbatim. Refs iOS #96. |
||
|
|
d5dde3eb1c |
Carry the vault's filing and its staged facts through backup and restore
`DocumentConditionLink` and `ExtractedFact` were classified as backed up and came back from no restore. The register said a restored vault is unsorted and that the facts are re-derivable by re-running the extraction. The first is right; the second understates it. What a re-run cannot produce is the REVIEW DECISION on each fact, and every column holding one defaults to "nobody has looked at this yet". `POST /api/documents/inbound/[id]/confirm` acts only on a PENDING fact and commits it into the structured store, so a restore that let those columns default would offer an already-committed reading for approval a second time and write a second lab result behind it. Both ends live in `src/lib/export/document-filing-backup.ts`, beside each other like the Coach, reminder and vaccination sections. `status`, `needsReview`, `confidence` and the commitment pair are carried and restored verbatim, and the wire schema requires the first three rather than defaulting them. Ordering: the section runs after the documents, the condition episodes, the lab results and the medications. The first two are real foreign keys, unlike the Coach's bare id columns, so an unresolvable value there does not cost one edge, it aborts the transaction and the operator gets none of the account back. The last two are what `committedRecordId` resolves against. The builder therefore carries a filing or a fact only when both of its ends are carried, and the restore still checks and drops what a hand-edited file leaves unresolvable. `committedRecordId` is nulled together with its type, because a type with no id claims a commitment the row cannot name. The round trip seeds a second condition and files the page under it, so a restore that kept the count and lost the pairing fails rather than handing back a lab report filed under a head cold. A third case breaks both references in a saved file to prove the drop path runs and still answers 200. Three new skip catalogues arrive with it, and with them labels for the three that had none: a dropped Coach attachment had been reaching the operator's screen labelled "mood factor" since it was added. The label chain is exhaustive now, so the next catalogue without one stops the build. |
||
|
|
f4fe08c73e |
Carry the screener history and the consent record, disaster recovery only
Two models come off the coverage register together, and neither is an ordering or an id-remap problem. Both hang off the account and nothing else. What kept them off was a question the code never wrote down, so this change writes it down. The screeners hold completed WHO-5, PHQ and GAD administrations, and for PHQ-9 the encrypted blob contains item 9, the question about thoughts of being better off dead. The application already draws a line around that blob: it keeps a coarse item9Flagged boolean so the history view can re-surface crisis resources without decrypting anything. The intent here was to follow that line and carry the administration while leaving the answers encrypted. The schema settles it more firmly. responsesEncrypted is Bytes and NOT NULL, so there is no half-row to write and the choice is the whole thing or none of it. A plaintext export in a downloads folder is the wrong home for that answer, so the row rides in a disaster-recovery file only. The consent receipts are not sensitive in that way. Their problem is that a consent is given to an operator. A receipt restored onto a different instance asserts an agreement that operator never obtained, with a signature timestamp to make it look settled. A disaster-recovery file goes back to the same instance, so it travels there and not in a portable one. In both cases the portable file SAYS so in its own manifest rather than reporting an account that has neither. The manifest is merged rather than added beside the records one, because two keys of the same name would shadow each other depending on spread order. item9Flagged is restored from the file rather than recomputed. Recomputing would mean decrypting every administration during a restore, which is the handling this data is kept away from, and the stored flag is what the history view has been reading all along. Verified by breaking it: dropping revokedAt on the way back fails the round trip with a withdrawn consent coming back as an active one. |
||
|
|
16b10c7855 |
Carry per-drug reminder phase timings, and stop claiming the notification preferences could travel
Two entries sat side by side on the coverage register, described as "the same shape of loss". They are not the same shape at all, and the difference is worth writing down. ReminderPhaseConfig hangs off a medication, one row per drug, nested inside something that already travels. It needed nothing new and it travels now. The round trip tunes all four thresholds away from their defaults and puts one of them in PERCENT rather than MINUTES, because a restore that quietly fell back to the schema defaults would hand back four plausible numbers and change when the drug turns orange. NotificationPreference cannot travel, and the register was wrong to imply it was merely unbuilt. A preference addresses ONE notification channel by a hard foreign key. The channel model is deliberately excluded from every backup because it carries channel secrets, and the restore deletes every channel it finds. A carried preference would point at a row that cannot exist: the same constraint violation the custom mood categories were causing, waiting for somebody to implement it in good faith. So it moves to the deliberate exclusions with a reason that names the dependency instead of the symptom. Re-applying somebody's tuning to a channel they add after a restore is a feature about channel types, not a backup contract about rows, and it should be argued for on its own terms rather than smuggled in as debt repayment. |
||
|
|
d596f8c66d |
Restore an account that grouped its own mood factors
An account that ever created its own mood category could not be restored at all. Not partially, not with a gap: the restore violated mood_tags_category_id_fkey and rolled the whole file back, so the operator got a 500 naming a constraint and an empty account. The coverage register described this as custom tags landing in a grouping that no longer exists, which reads like a cosmetic loss. Measured, it is the same shape the retired-catalogue-key work already removed once: one row nobody thought about taking down everything else with it. MoodTag.categoryId is a real foreign key. Seeded categories belong to the instance and are deliberately still not carried, but a category the person made is theirs, cascades away with them, and has to travel. It now does, and it is written before the tags that point at it. The hidden set travels by key rather than id, because what people hide is almost always a seeded tag whose id differs on every instance. A key this instance no longer knows costs one hidden row and is reported: a silent drop would un-hide a factor somebody had deliberately taken out of their mood editor, and they would have no way to know why it came back. Found by experiment rather than by reading: seeding a user-owned category into the round trip turned it red with the constraint name, and the same test is green now. |
||
|
|
86231ed23e |
Carry medication stock and its ledger through backup and restore
A restored account reported no supply at all. Every pack was gone, and with it every low-stock reminder, on a feature that became first-class when medication moved to one stock per drug. The two travel together because the register itself paired them: without the events the count "cannot be rebuilt even in principle", and without the packs the events are a list of deltas against nothing. The count comes back exactly as recorded, not recalculated from the ledger. It is the value the server had resolved and the person was shown, and every reminder already sent was computed from it. The fixture makes the difference visible on purpose: thirty in, eight consumed, and a remaining count of twenty-one, because somebody dropped one without recording it. A restore that "corrected" that to twenty-two would disagree with the account's own history and look like a bug fix while doing it. Both decimal columns cross the wire as strings. Half tablets are a real prescription and a JSON number would round them. The note follows the contract the side effects and dose changes beside it already use, and the packs nest inside their drug, so a portable restore minting fresh ids needs no remap. Verified by breaking it: recomputing the remaining count from the ledger fails the round trip with twenty-one against twenty-two. |
||
|
|
1fbd3b628b |
Carry the Coach's memory through backup and restore
The transcript came back in the previous change; what the Coach keeps BETWEEN threads did not. A restored account had every word of every conversation and a Coach that has to be told its own history again — the register said as much for all three: "a restore makes the account re-tell its history", "keeps the talk and loses the commitment", "a restored account silently stops sending them". Tombstones ride only in a disaster-recovery file. All three soft-delete, and a deleted fact is hidden from injection while kept for audit — a distinction only the owning instance can act on. A portable file carrying them would hand a person rows their own app refuses to show them, and a restore that resurrected one would put a fact back into the Coach's mouth that the person had deliberately taken out of it. Neither reference here is a foreign key. sourceConversationId, on all three, and relatedPlanId on the reminder are bare id columns, so nothing in the database refuses a value pointing at nothing. That makes them more dangerous than a real key rather than less: a dangling reference costs no error, it just quietly stops meaning anything while the row goes on looking complete. The restore resolves both against what it actually wrote, nulls what it cannot resolve, and reports the count. Nulling rather than dropping is the point. A live reminder must not be lost because the plan it mentions was tombstoned out of a portable file. The reminder is still the person's and it still fires; what it loses is a pointer that was already going nowhere. Order follows from that: threads, then plans, then reminders. Running the memory before the transcript would cut every fact loose from the conversation it came out of and still report success. Verified by breaking it: making the conversation lookup always return null fails the round trip on the fact's own source field. |
||
|
|
bcb7716ffa |
Carry the Coach transcript through backup and restore
A restored account got a Coach that had never spoken to it. The conversations, every turn in them, and the record of which vault documents a thread was grounded in were all classified as backed up and travelled with nothing. The register's own entry for the messages called them the largest volume of free text an account owns after its documents. The three travel together because two of them mean nothing alone: a turn without its thread has nowhere to hang, and an attachment names a pairing rather than a thing. documentScoped is the field that makes this more than lost history. It is a permanent fence — set when a conversation is created through the fenced endpoint or gets its first document, and lowered by no code path anywhere, not by detaching every document and not by deleting one. A restore that let it default to false would hand the tool loop back to precisely the conversations the fence exists to keep away from it, and nothing in the interface would look wrong afterwards. So it is carried verbatim, restored verbatim, and asserted per thread rather than in aggregate: a restore that wrote false everywhere would still return both threads and every turn and satisfy every count. Prose follows the contract the rest of the payload already uses. A disaster-recovery file carries the stored ciphertext; a portable file carries the text, because a portable export exists to be readable by the person who owns it, exactly as it already decrypts medication notes, mood notes and document summaries. A single row under a key the instance no longer holds is named rather than thrown, so one unreadable turn cannot cost someone the rest of the transcript. Ids ride in both modes, as the reminders do, so an attachment resolves and the facts, plans and reminders still owed have something real to point at. The restore runs after the documents for the same reason, and an attachment naming a document the file does not carry is dropped and reported instead of failing the whole file over one link. Verified by breaking it: forcing documentScoped to false on the way back fails the round trip on that field by name. |
||
|
|
5e0994aab9 |
Carry medication dose changes through backup and restore
The titration history was classified as backed up and travelled with nothing. An account restored from a backup showed the current dose and no record of how it got there: the ramp, the reductions, the note the prescriber gave for each step. For a long-running drug that history is often the more clinically interesting half. Export writes the rows nested inside their drug, ordered by effectiveFrom, so a portable restore that mints fresh ids needs no remap. The note follows the contract the side effects already use: a portable export decrypts it for a human reader, a disaster-recovery export carries the ciphertext verbatim, and a restore never writes it into the legacy plaintext column. Order is the content here, not decoration. Two rows on the same drug in the same unit, 5 mg then 10 mg, describe a ramp; the same two rows returned in the other order describe a taper. Counting them back cannot tell those apart, so the round-trip test asserts the sequence and the values, not the count. MedicationDoseChange moves from the coverage-pending register into the two-ended list, which makes the round-trip registry incomplete until a row is seeded and counted. That is a compile error, not a review note. |
||
|
|
48f4cba3aa |
Carry medication pause eras through backup and restore
`MedicationPauseEra` was classified as backed up and travelled nowhere. The spans where a drug was deliberately paused are the difference between "stopped taking it" and "was told to stop taking it", and nothing else in the payload can reconstruct them: an era is a decision, not an observation, and no intake row records its own absence. Without them the compliance recomputation reads a deliberate pause as missed doses, so a restored account looks non-adherent for a stretch during which it did exactly what it was told. Both ends land together. The eras ride nested inside their medication, like the schedules and side effects beside them, so a portable file that mints fresh medication ids needs no remap — Prisma binds each child to whatever id the parent actually got. ## The open era is the point `resumedAt` is nullable end to end and the writer always emits it. An open era means the drug is paused right now, and readers run it to the present. The restore passes the null through rather than closing it at restore time: a pause that was still running when the backup was taken is still running afterwards, and closing it would silently turn an ongoing decision into a historical one. Counting alone cannot catch that — a restore that closed the era would still hand back two rows. So the round-trip test seeds one closed era and one open one and asserts both field by field. ## What is proven and what is not Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes the round-trip test's registry incomplete, which is a compile error. Verified by watching `tsc` fail with TS2741 before the count-back was written: the coupling the plan's comment describes does hold. The round-trip test itself needs Postgres via testcontainers and has not been run here. Typecheck, lint, format and the 21458-test unit suite are green; the integration proof comes from CI. |
||
|
|
09446846b1 |
Refuse an export nobody scoped, and stop naming UCUM without a code
Two findings from the deferred-audit list, both live since July. A FHIR lab quantity stamped the UCUM system unconditionally while the code was only added when the analyte map resolved a symbol for exactly that unit. A receiver was told the unit came from UCUM and then handed no UCUM code, which is a coding claim the bundle cannot back. System and code now travel together, on the value and on both reference-range bounds. The doctor-report export accepted a selection that admits nothing. The panel disables its own submit in that state, so only a hand-built request reached it, and it rendered an empty artefact while spending a rate-limit slot. This route has no documents-only mode that would make an empty leaf list a legitimate scope, so it refuses with 422. The share-link route, which does have that mode, is untouched. |
||
|
|
aafc1a3c1a |
Quiet the snooze and clear the web reminder on every intake path
The two halves of a resolved dose were split across routes: the single intake route reset the medication's snooze but never closed the pending dose-due Web Push reminder, while the bulk route closed the reminder but left the snooze standing. Each path was missing exactly what the other had. A dose drained from a device's offline queue lands on these routes, so a synced take could keep ringing as snoozed or leave a stale web badge behind. The bulk route now nulls snoozedUntil once per affected medication when a non-skipped entry actually lands, and the by-id route dispatches the same fire-and-forget web clear the other intake paths have carried since v1.18.4. |
||
|
|
9d64eb6aa2 |
Show what a backup contains before the operator types RESTORE
The restore dialog asked for its typed confirmation on nothing but a filename and a date; the counts only surfaced in the audit row after the rows were already replaced. A read-only summary endpoint now decrypts and schema-validates the stored file through the same helpers the restore itself runs and answers the summarizeBackup counts, and the dialog shows them — per section, with an other-records line so a future summary key can never silently vanish from the preview. A failed preview states its own absence and never blocks the restore. |
||
|
|
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. |
||
|
|
651d37b494 |
Let the dashboard hero show what matters most to its owner
The hero card composition was fixed: score panel first, the worth-a-look
rail beneath. A reminder-driven person asked for the reverse. The stored
dashboard layout gains an optional hero field ('score' default,
'reminders' promotes the rail into the hero slot), validated as a closed
enum, preserve-when-absent on write, omitted from the blob at the default
so untouched accounts serialize byte-identically. The choice sits in
Settings beside the other hero controls; the promoted rail degrades to
the calm all-clear line when nothing is due.
|
||
|
|
a3561b4600 |
Tell a first-run what Apple Health is actually doing
Refs #778. Two honesty gaps from the first-run report: the web onboarding presented Apple Health like a web connector although the connection only completes in the iPhone app, and the initial backfill gave no sign of progress, so a working first sync looked broken. The onboarding source step now says plainly that Apple Health is set up in the iPhone app after onboarding, without dead-ending the flow. The integration card gains a server-authoritative progress summary — rows accepted, oldest reading reached, and an honest flowing/waiting line derived from the last accepted batch. Nothing the server does not know is shown: no percentage, no ETA, no invented throttle state; a quiet stretch reads as waiting for the phone, not as an error. |
||
|
|
b1be67bfa7 |
Carry the reminder schedule and its ledger through backup and restore
The reminder cadences were the oldest entry on the coverage-pending register: carried in name, restored never — an account came back silent until every reminder was rebuilt by hand. Both models are two-ended now, in the house shape (both ends in one file, reminders-backup.ts): every column travels, nextDueAt verbatim rather than recomputed, the completion ledger beside it, tombstones in disaster recovery only, and a portable export omits a tombstoned reminder together with its ledger rows. Encounter.reminderId and VaccinationRecord.reminderId stop being dropped on restore: both remap against the restored reminders and fall to NULL with a skip report only when the file genuinely lacks the row. The wipe plan names the ledger child-before-parent; the classification guard, completeness guards and the integration round trip all demand the new coverage (proven failing with the section removed). Refs #223 |
||
|
|
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. |
||
|
|
5619783648 |
Let a checkup be skipped or pushed back, and remember every outcome
Refs #223. A due Vorsorge item could only be done, deleted or ignored; Telegram's postpone moved the date with no trace. Both decisions are first-class now, and both are honest: - POST /{id}/skip (no body): the interval restarts from the skip instant through the recurrence engine, lastSkippedAt is stamped, skipCount increments, any snooze clears. lastSatisfiedAt is NEVER touched — done and skipped stay distinguishable forever, enforced in the primitive and pinned by tests. - POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to the notifyHour in the profile timezone and moves snoozedUntil and nextDueAt to the same instant, so every due-state consumer follows without knowing snooze exists. At least tomorrow, at most five years. - GET /{id}/history: the new append-only completion ledger (MeasurementReminderEvent), one row per satisfy from every engine path and per skip, onTime derived at write time against the due instant that was current when the event landed. Two anchor fixes close the backdated-event gaps: the auto-resolve floor is now the later of last satisfy and last skip (an old reading synced late no longer resolves the cycle the user just skipped), and a backdated real result re-anchors the next due at the later of its own instant and the skip decision. Screenings stay skippable and snoozable — the satisfy-side 409 guards claimed fulfilment; a skip claims the opposite. Appointment reminders 404 on all three routes, in the lookup, like every by-id sibling. /checkups gains the postpone sheet (quick chips, date field, the skip zone with its own confirm) in both view branches, with muted meta lines and no card colour change. The MCP preventive-care tool carries the resolved fields; the OpenAPI contract publishes all three paths and the widened DTO. |
||
|
|
97cb63a77f |
Report every restored section, disclose export decrypt losses, re-fold on zone change
- The restore summary counts practitioners, encounters, the three link tables, and vaccinations — restored in full since 08-01 but absent from the report, so a file carrying visits or an Impfpass read as if those sections were empty. - A portable export that cannot open a field's ciphertext still nulls it fail-soft, but now names the loss in the file's own decryptFailures manifest — an export with a lost emergency note is no longer byte-identical to one where the note was never written. - A timezone change enqueues the medication-compliance re-fold: the rollup keys its rows by the local day minted under the OLD zone, and the coverage probe compares counts, not keys, so nothing else could notice the stale bucketing. |