The save routes evaluate the same policy the senders do. A target on a
private network the operator has not listed is a 422 with
meta.errorCode = private_origin_not_approved and a message that names
NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a
listed private address saves although the plain public floor would
have refused it. The settings schemas take the target predicate as a
parameter so the validator module never reads the environment; the
exported constants keep the public floor for the OpenAPI contract.
The test routes forward the sender's refusal as the same 422 and code
instead of a bare 500, so the card can say why the test failed rather
than sending the operator to the wide-event log. A delivery fault is
still a 500. Both test routes and the dispatcher reach the same sender
function, and a structural test holds them there.
The OpenAPI descriptions name the code on all four routes; the test
button's error map gains the code in every locale.
Refs #947
The medication detail's notification section decides between the
server-side reminder switch and the client-managed chip on
`notificationPrefs.medication.clientManaged`, read off `GET /api/auth/me`.
That payload never carried the field — the preferences live on their own
endpoint, which is not on the app-boot path — so the flag was `undefined`
for every account: the chip never rendered, and a person whose phone owns
the reminders was shown a switch that no longer decides anything. The
write path and the dispatch gate were correct the whole time; only the
web reflection was missing its end.
The payload now carries the fully-resolved block from the same resolver
the dedicated endpoint uses, so the two cannot disagree, and an account
that never opted in reads `false` rather than absent. No extra query: the
session already loads the row the column sits on.
The pair guard over this payload gained the direction that could not see
it. It asked that every published field has a reader; a reader with no
field was invisible. It now also reads the response types clients declare
for this endpoint and holds them against what the route publishes. A
typed guard was tried first and does not work here — each consumer
declares its own local interface, so the compiler checks every file
against its own private claim and agrees with all of them.
`POST /api/admin/notifications/reminder-check` walked every active
medication on the instance and dispatched a reminder for each overdue
slot it found. That is the operator button's intended behaviour and it
stays the default, but it left any other caller — a browser journey, a
smoke check — writing into every account's delivery ledger at once with
no way to say otherwise.
The body now accepts an optional `userId` that narrows the sweep. It can
only narrow: the route is `requireAdmin()`, cookie-only by construction,
so a Bearer caller never reaches it however wide its scope, and the
selector feeds the Prisma `where` rather than the authorisation. A
bodyless POST — what the admin panel sends — is unchanged. An unknown key
is a 422 instead of a silently instance-wide run.
Published in the contract alongside the notification diagnostic, so the
scope is readable rather than folded into the console-only exemption.
A `DataBackup` row that fails to decrypt answered 500 on the restore, the
download and the preview. It is not a server fault: either the key that wrote
the copy is no longer in `ENCRYPTION_KEYS` — a rotation that dropped the legacy
entry before the script reported zero rows, the case the rotation runbook warns
about — or the stored bytes are not the ones that were written. Every other
bad-input arm on those routes already answers 4xx, and the 500 also paged the
error reporter as if the process had broken.
All three now answer 422 with `meta.errorCode = backup.payload.undecryptable`
and one shared sentence, so the three routes cannot drift into three wordings
for one condition. The download's second 500 stays: a payload this instance
wrote and cannot parse really is a fault here.
The two routes that open a stored copy move from the unpublished list into the
contract, because the refusal is now a promise an operator can look up rather
than whatever the handler happened to answer. The restore publishes its
`Idempotency-Key` header with them.
The shared 429 named `Retry-After` and the `X-RateLimit` triple in prose, so a
generated client got no typed accessor for the field the same sentence tells
it to back off on. They are a `headers` block now: `Retry-After` and the cap
and remainder as integers, the reset instant as a date-time. The text also
says what a 429 from a ceiling that is not one of these buckets carries —
nothing, because there is no bucket to describe.
Two route descriptions still listed the pre-change header set: the ingest 429
named only `X-RateLimit-Remaining` / `X-RateLimit-Reset`, and the document
suggest 429 named only the reset instant beside `meta.retryAt`. Both are short
of the wire in exactly the direction this work exists to close.
Three refusals still answered 401 with prose alone: the Bearer re-read on
`/api/auth/me/sessions`, and the wrong-second-factor arm on both MFA step-up
routes. The shared 401 description told clients the code is the field to
branch on, so a client that took that advice landed in its default arm on all
three — and on the MFA pair that is the difference between re-prompting for a
digit and signing the user out over a typo.
The sessions re-read is the same missing-credential fact the gate already
names, so it reuses `auth.missing`. The MFA pair gets a code of its own,
`auth.mfa.code_invalid`: none of the existing ones fit, because every other
401 code asks the client to discard something, and this one asks it to keep
the session and try another code.
The description narrows to match what is actually true. The generic gates
name a reason; a route that checks a credential of its own — sign-in, the MFA
challenge exchange, a password confirmation — still answers 401 with prose,
and the text now says so instead of promising a field those responses do not
carry.
`GET /api/auth/me` publishes a boolean per module, and three different
situations collapse into the same `false`: the record has the module switched
off, the active grant's sections do not open it, or the operator switched it
off for the whole instance. A client holding the boolean has to guess between
them, so the empty state either says nothing or says something that may be
untrue -- offering a delegate a switch that is not theirs to reach, or telling
an account holder to turn on a module the operator removed.
`moduleAccess` carries the reason beside the booleans, which keep their exact
meaning: `enabled`, `disabled`, `not_granted`, `unavailable`, resolved
outside-in so the reason published is the one nothing further in can change.
`modules[key]` stays exactly `moduleAccess[key] === "enabled"` for every key,
and the two halves are built independently -- the boolean still runs through
`maskModulesToSections` -- so the test of that pairing is an assertion rather
than a restatement of a derivation. It is checked on the pure builder across a
matrix of grants, records and operator blobs, and again on the real route for a
scoped delegate, an unscoped one, and an operator-disabled module.
Both ends in one change, because a payload field with no reader is the failure
this repository keeps rediscovering. The reader is `ModuleDisabledNotice`, the
shared module-off empty state the empty-states audit asks for: it names the
reason in the reader's own language and offers the enable control only when the
record's own switch is what is off, since neither of the other two is fixable
from that page. The nutrients page adopts it and drops its bespoke block, which
had been handing every delegate a button that would refuse them.
Registered in the OpenAPI registry as `ModuleAccessMap` and carried on the
account payload; the native contract gains a field and loses nothing, since a
Bearer request carries no switch and never reads `not_granted`.
`requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa` and
`requireBearerAuth` all refused with a bare prose string and no `meta`.
So the single most important branch a native client makes — refresh the
token and retry, drop the session and show the login screen, or stop
retrying because this credential will never work — could only be made by
string-matching the English sentence "Token expired". Any rewording of
that string, or a localisation pass, silently turns a recoverable
refresh into a forced logout for every already-shipped build.
The mechanism was already here and these gates were the ones skipped:
the assistant, consent, step-up, module and sharing refusals all carry
`meta.errorCode`, and `/api/auth/refresh` emits `auth.refresh.*`. So
`HttpError` takes an optional code and the wrapper puts it in the same
field. Five values, dot.case like the majority: `auth.missing`,
`auth.token.expired`, `auth.token.invalid`, `auth.scope.insufficient`,
`auth.admin.required`. A revoked token reads invalid, not expired —
refreshing a revoked token loops forever.
Additive: an HttpError thrown without a code serialises byte-identically
to before, which is most of them. The published 401 description now
enumerates the codes and says what a client should do with each.
Response drift was structurally undetectable. `openapi:check` proves the
YAML matches the registry and the route-coverage guard proves the verb is
listed; both pass when the registered schema disagrees with what
`apiSuccess()` sends, and the coverage guard says so in its own doc
comment. That is how `/api/sync/changes` shipped five domains against a
three-domain contract and how the error envelope went years without
declaring the field the multi-issue 422 was built for.
Two bodies, the two that were broken: a full `/api/sync/changes` page
with live and soft-deleted rows in all five domains, its `cursorExpired`
short-circuit (a second shape under the same schema), and a 422 carrying
more than one issue.
`safeParse` alone would not have caught either finding, because Zod
strips undeclared keys instead of refusing them — a schema short of the
wire parses cleanly. So the parsed value is compared back to the raw
body; a strip means the contract is missing something the server sends.
Removing `cycleDays` / `cycles` from the sync schema, or `details` from
the envelope, fails all three cases.
`GET /api/sync/changes` returns five domains — measurements, mood,
intakes, cycleDays, cycles — and has since the cycle module landed. The
registered schema declared three, closed the object and required only
those three, so `cycleDays` and `cycles` did not exist in the contract:
full day-log and cycle payloads, both upserts and tombstones, computed
and serialised on every single pull, invisible to any client generated
from the spec. The body was also invalid against its own schema, since
the object was closed against the two keys it always sends.
The day-log and cycle DTOs were already published for the cycle routes,
so the two upsert arrays reuse them and only the tombstone shapes are
new. The module's own description and the operation summary were stale
in the same direction and now name all five.
This is the two-ended change the contributor notes describe: the writer
shipped, the contract half did not, and no gate noticed because every
gate proves the other end.
No wire change.
Every documented 422, 401, 403, 409, 410 and 429 points at one
`ErrorEnvelope`, and that schema declared `data`, `error` and a `meta`
closed to `requestId` + `errorCode`. Two things were wrong with it.
`returnAllZodIssues` has sent a third top-level key since v1.4.42 —
`details.issues`, one entry per rejected field — from 166 route files.
It exists so a client can fix three bad fields in one round-trip instead
of three, and the doc comment says so, yet no component schema anywhere
declared it. A client generated from the contract had no field to read
it from, so the feature was invisible to exactly the consumer it was
built for. It is now a schema, with the `unrecognized_keys` list and the
promise that no rejected value is echoed back.
And `meta` was closed while real refusals put more in it: `removedIn` /
`replacedBy` on a 410, `module` beside `module.disabled`, the
per-integration context on a failed connection test. Closed plus those
keys means the response was invalid against its own published schema and
a strict decoder is entitled to reject it. It is open now.
No wire change; both halves describe what the server already sends.
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.
Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.
`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.
The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
`PATCH /api/managed-profiles/{id}` had none, while creating a record and
inviting a Guardian have both been capped at ten an hour since they shipped —
one family answering the same abuse three ways. It matters more here than the
count suggests: `updateManagedProfile` opens a transaction and takes an
advisory lock on the id it was HANDED before it decides the caller is not a
Guardian of it, so an unthrottled caller can name any id and make the database
hold a lock about it.
The refusal now lands before the id is read, on the same bucket convention and
with the same ceiling as creation. The GET beside it stays unlimited, matching
the guardian roster read next to it: both are cheap identity reads a Guardian
may already make. The 429 is documented on the contract and the edit form's
message mapping grew the arm creation already had.
The `AccountPayload` description still claimed that under an active switch the
payload describes the caller "their preferences, their modules, their
identity", which stopped being true when the module map and the cycle flag
started answering for the active record — `ModuleMap` on the same spec said
the opposite. The iOS client reads this file.
Both descriptions now say the same thing: identity and preferences are the
caller's, `modules` and `cycleTrackingEnabled` are the active record's, and
both are masked to the sections the active grant opens. Spec regenerated.
U1. The new `unit` description on the batch entry said `value` is read in
the unit HealthLog stores the identifier in. Sixteen mappings disagree:
`convertToDbUnit` runs on every entry, and a waist circumference arrives
in metres to be stored in centimetres, an oxygen saturation and a body fat
arrive as a fraction to be stored as a percent. A client generated against
that sentence sends 98 and stores 9800.
The description now says what happens: `value` is read in Apple's own
HKUnit for the identifier, HealthLog applies its storage scaling
afterwards, and `unit` is recorded for audit rather than validated
against. Two worked examples, because the percent-shaped ones are the pair
that bites. YAML regenerated.
The native client replays its offline outbox with the same
Idempotency-Key on every attempt, and `POST /api/medications`, the
side-effect log and the inventory register ran the handler again on the
replay: after a lost success response the account was left with a
duplicate medication, a duplicate symptom entry or a second pen in the
supply count. The dedupe on `(externalSource, externalId)` only ever
covered a MIRRORED create; a manually entered medication carries
neither field and had nothing to collapse a retry onto.
All three wrap in `withIdempotency`, the composition the batch ingest
already ships, and the three operations publish the header so a client
generated against the contract can tell that a retry is safe. Zod
parsing and `requireRecordAuth` are untouched.
An integration test drives each route twice against real Postgres and
asserts what a status check cannot: one row in the table, and a replayed
201 carrying `X-Idempotent-Replay: true`.
The route was exempt from the published contract as a browser handoff. It
is not one: Nightscout has no OAuth step and no authorise page, and the
route is an authenticated JSON POST that Zod-parses a URL and an optional
token, probes the instance and encrypts the pair onto the row. It was the
only POST among eight `connect` exemptions, and its three siblings —
status, test, disconnect — have been published all along, so a client
could read every part of the Nightscout flow except the one that starts
it.
Register it from `nightscoutConnectSchema` so the request shape stays
single-source, document the 400 / 413 / 422 / 429 arms the handler
actually answers with, and drop the exemption. Removing the registration
now fails the coverage guard by name.
The record-unit conversion is now opt-in per caller and only the
`export.xml` archive path opts in. There the attribute is authoritative:
Apple stamps every quantity `<Record>` with the account's own display
unit, so `km` means kilometres and the reading is meaningless without it.
`POST /api/measurements/batch` keeps its documented contract — `unit` is
captured for audit and never read. Nothing in the tree pins the native
client's unit strings for the seventeen convertible identifiers, and a
client that reads a sample in metres while stamping the person's display
unit into the field would have had every walking distance multiplied by a
thousand on ingest. Reading the field there is a wire change and needs
the client's strings pinned first.
The mapping-table audit and the shared conversion module stay. The batch
path's identity behaviour is written down where it is decided and pinned
twice: every convertible identifier round-trips a sibling unit unchanged
by default, and a walking-distance entry sent as `km` is stored as it
arrived. The OpenAPI description says what the server actually does.
Review finding H2.
Turning a module off for a profile you look after wrote a column nothing
on screen was reading. GET /api/auth/me published the ACTOR's module map
while switched, and the navigation, the dashboard tiles and the insights
sections all gate on it — so a guardian who turned Cycle off for the child
kept seeing a Cycle destination inside the child's record, and a guardian
who tracks it saw one there whether or not the child did (#939).
The map and the resolved cycle flag now answer for the record the session
is inside. They are not display preferences: every surface they gate shows
the record's data, so the record is what decides. Locale, theme and layout
stay the actor's. The record is taken from the grant-validated active
entry rather than the session's own selector, so a revoked switch cannot
publish somebody's configuration, and with no switch the payload is what
it was.
Cycle also gets a row on the guardian's Modules surface, which it never
had. It is a delegated module, so its state is the record's own cycle
profile and modulePreferences.cycle is refused — which left a guardian no
way to turn it off for a record whose sex says it should be on. The family
names the real column instead of a blob entry the gate ignores.
Refs #939
A managed profile could be created and deleted and never changed. A name
typed as a placeholder was permanent, a household that moved kept the
timezone the record was created in, and there was no way to correct a date
of birth somebody finally had (#939).
PATCH /api/managed-profiles/{id} takes the fields creation takes, every
one optional and at least one required — an empty body is a refusal rather
than a save that audits a change nobody made. It carries the gate the rest
of the family carries: a cookie session with a fresh second factor, an
active Guardian grant on the record, and the same byte-identical 404 for
"no such record" and "not yours". A GET beside it answers the record as it
stands, because the account payload names a managed record and nothing
else, and a form filled from that would have had blank fields to send.
The new field is gender. Leaving it out of v1.37.0 meant every managed
record started with the column NULL, which is what the cycle module
derives its default from: a guardian creating a record for a child could
neither record the answer nor turn the module off from there. It reuses
the column and the three values every other surface writes, so there is
one word for one column rather than two.
Refs #939
Tile eligibility filtered the four named contexts against the per-context
summaries and asked whether any of them held readings. An account whose
source records no meal time matched none of them, so the tile was omitted
with the module on, the layout toggle on, and a reading from today. Nothing
warned; the strip was simply short one card.
Move the gate out of the page into `resolveGlucoseTiles`, which walks the
shared bucket list — untagged included — and hand the caller the label key
with the summary. Both summary producers group through the shared resolver,
so `UNSPECIFIED` reaches the wire with its own count, latest value and
trend, and a tagged account still gets its per-context breakdown.
Refs #943
The mint's rate limit bounds a burst and does nothing about accumulation:
a stuck automation retrying politely inside its 10-per-minute allowance
still leaves several hundred live credentials behind in an afternoon,
each one to be revoked by hand.
Count live rows before the create and refuse at ten.
Live, not minted-ever, so revoking frees a slot and an expired token
stops occupying one — the ceiling bounds what currently exists rather
than what the account has ever done. Scoped to measurements:write for a
reason worth stating: signing in mints a wildcard access token per
session and those accumulate on their own, so counting every row would
let ordinary browser use exhaust the budget and then refuse a mint for a
reason invisible to the person hitting it.
409 rather than 429. This is a conflict with the account's current
state, not a rate: it does not clear by waiting, it clears by revoking,
and a 429 would tell the caller the opposite of what is true.
Placed after validation, so a malformed request hears about the
malformation rather than the ceiling, and before the create, so hitting
the limit costs no credential.
Ten is set to catch a loop rather than to ration — a household running
Home Assistant, a scale bridge and a couple of scripts sits at three or
four, and it is not a security boundary since the holder can revoke and
mint freely.
Tests pin the boundary, the query shape and the ordering, and were
mutation-checked: turning >= into > fails the ceiling case, and dropping
the scope filter fails the query-shape case. The integration leg seeds
a wildcard and an expired row as decoys, then revokes one live token and
asserts exactly one slot opens — the leg that fails if the count is of
mints rather than of tokens alive.
The widgets GET grew a response field and the spec was never told. Nothing
would have caught it: `openapi:check` compares the generated yaml against
the Zod registry, and an undeclared field changes neither — so CI stays
green while the published contract quietly stops describing what the route
returns. iOS decodes this payload.
`dashboardLayoutResult` is shared by the GET and the PUT echo, but the
field is resolved per request from the caller's modules and their data, so
it exists only on the read. A separate `dashboardLayoutReadResult` extends
the shared schema for the GET rather than widening both verbs and
describing a field the PUT never sends.
docs/api/openapi.yaml is NOT regenerated in this commit: `pnpm
openapi:generate` cannot run here (the Prisma engine download is refused,
so `@/generated/prisma/client` is missing and the registry will not load).
The tree is therefore in drift on purpose and `pnpm openapi:check` will
fail until `pnpm openapi:generate` is run and the yaml committed.
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.
This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.
Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.
The contract gains the endpoint and nothing else; the account payload
already carried the field.
Three pieces of code answered "what hour is it" for the salutation. The
snapshot builder computed a `greetingHour` in the user's zone and put it on
the payload so clients would not have to; no client ever read it. The
insights hero derived one through the shared `hourInTz`. The dashboard header
derived one through a local helper that fell back to the DEVICE clock
whenever the runtime could not resolve the configured zone, which is what
happens to an IANA entry the browser's data does not yet carry. At one
instant, for one account, those three returned 20, 21 and 05.
Keep the client end and drop the server one. The hour is a clock reading, not
a fact about the record, and the snapshot body serves stale for up to an hour
past its fresh TTL, so a stored hour can name a salutation the clock has left
behind. What the server IS authoritative for is the zone, and that already
travels on the same payload and is already what both surfaces read.
Both greetings now share `hourInTz`, so an unresolvable zone falls back to the
same default the rest of the app uses instead of to the device. The header's
private helper is gone; it had no other caller.
Two tests pinned the old shape by asserting the payload carried an hour.
Inverted rather than deleted: the payload must now be shown NOT to carry one,
which is the property that was missing when the field went unread for months.
`greetingHour` leaves the response contract, so a native client decoding it as
required needs the field made optional or dropped.
With the switches now enforced at dispatch, a user could still flip a
channel on, see the toggle stick, and get nothing. Refuse the enable
instead, with 403 and a message that names the reason: the ntfy and
Telegram config endpoints on both accepted body shapes, and the Web Push
subscribe endpoint, which is the enable step for that channel because it
creates the channel row.
Telegram is checked before the webhook registration, so a refused enable
makes no outbound call and stores nothing. Disabling a channel keeps
working while the switch is off — a user must always be able to turn a
channel off, whatever the operator has done.
The three refusals are published in the contract; `/api/settings/global-services`
already exposes the switches so a client can hide the control instead of
provoking the refusal.
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.
The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.
Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
The mint used requireAuth() with no scope, which refuses a narrow token
but still admits a cookie-equivalent one. The reach is not the problem —
a wildcard token can already do everything this endpoint does. The
lifetimes are: a native access token lives a day, and what it mints here
lives a year. So a credential that leaked for an afternoon could leave
behind one that outlives revoking it, and the revocation would look like
it had worked.
requireCookieAuth() closes that. It is the helper the passkey
registration and trusted-device routes already use for the same reason,
and the sharing guard recognises it as cookie-only.
The prose said more than the code did, in three places: the route header
and the OpenAPI description both claimed the endpoint "cannot mint
another token", which held only for narrow tokens, and the 403 arm
described a scope refusal that a Bearer never reaches. All three now say
what is true — no Bearer credential reaches this endpoint at any scope,
and a Bearer is refused 401 before the operator switch is read.
Tests follow the status change: the integration case asserting a scoped
token cannot mint now expects 401 rather than 403, since the refusal is
authentication and not scope, and a companion case pins the wildcard —
the credential class this change is actually about — and asserts no
token row is created.
`stdResponses` already carries 401, 422 and 429. The three response
blocks added with the measurements:write scope each declared their own
422 (and, on the mint route, a 429) BEFORE spreading it, so the generic
wording overwrote the specific wording and TypeScript refused the
duplicate key. The build failed at auth.ts with "'422' is specified more
than once".
Move the explicit keys after the spread, which is what the MCP mint next
door already does and the only order in which a per-route description
survives.
Worth stating because the failure mode is quiet: had TypeScript allowed
it, the published contract would simply have carried "Request validation
failed" everywhere instead of naming
measurement.create.source_not_permitted and its siblings.
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.
Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.
The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.
A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.
Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.
Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.
Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
Three descriptions in the OpenAPI registry still describe the floor that
v1.38 removed, and one of them contradicts its own error-response
description two dozen lines below it.
- The score-config PATCH still said the write is refused below three
distinct areas and one physical measurement. The route refuses the
empty selection and nothing else; its 422 description already said so.
- The dashboard snapshot's healthScore still said the block is null until
three eligible domains include a physiological one. It is null only
when no pillar is readable at all.
- The pillar domain field still pointed at a three-distinct-domains rule
that no longer exists, where what a client needs to hear is that domain
count comes resolved on scoreBasis and must not be counted out of
composition.
Regenerated docs/api/openapi.yaml.
The user-facing description of the score lives in the docs site's own
repository, not here; nothing under docs/ describes the composite.
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.
The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.
Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.
The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.
This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.
Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.
Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
`scoreBasis` rides the composite, the dashboard snapshot's flattened hero
block and the daily digest's score. It says how many distinct areas of
health the number counted, how many the method recommends, which tier that
lands in, and whether any counted pillar was a physiological measurement.
Optional everywhere, because two of the three surfaces are served out of a
cache and a payload written before this release cannot grow a field. The
snapshot and digest blocks have taken six additive fields this way already;
this is the seventh.
Resolved on the server for the same reason `configured` is. Counting
`composition` client-side would get it wrong more often than right — three
of the seven pillars share the cardiometabolic area, so a three-pillar
score can rest on one.
One shared component, `HealthScoreBasis`, declared once in the insights
schema module and referenced from the other two registry modules, rather
than three inline copies that drift the first time the block gains a field.
Declared through the assigning `.meta()` form: a bare `.meta()` statement
registers an instance nothing references, and the id then never becomes a
component.
The composite's `insufficient` reason narrows to the single value
`no_usable_data` and says so in the published description. That field is an
open string in the document, so the change is additive on the wire even
though the value space shrank.
The score-config 422 description drops the two retired reasons and states
plainly that the empty selection is now the only refusal, so a client does
not keep a three-area rule of its own in front of a write that no longer
has one.
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.
Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
`GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and
threads it to the dispatcher. Omitting it changes nothing: every engine
keeps its own default, so a caller that asks for nothing reads exactly
what it read before.
Ninety is where the tier this route promises runs out. The rollup router
opens its WEEK floor at 91 days, and the baseline engines cannot compose
a spread from WEEK buckets, so a 91-day request falls through to a raw
`measurement.findMany` with no row cap — for a densely sampled type that
is a full history scan, and the analytics-read budget allows 120 of them
a minute per account. The trailing `series` is capped at 30 points
regardless, so past the ceiling a wider window would only broaden the
mean behind the value. Out of range is a 422 rather than a clamp.
An unhonoured window is visible rather than silent. `provenance.windowDays`
is the window the engine actually used and `coverage.historyDays` the days
that actually backed it, so a three-week record answering a thirty-day
request reports both numbers instead of implying coverage it does not
have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the
widest; a new suite freezes that it is the only metric that does, so an
arm added later cannot quietly drop the parameter.
Recovery keeps its canonical resolution under the wider window. A worn
band stamps the wake morning and the computed proxy the night before, and
only `resolveCanonicalRecovery` pairs them; a test seeds forty such
nights, asks for sixty days, and holds the answer to forty nights with a
flat trend — a bypass leaves eighty rows and a false climb.
Contract: model `WellnessScoreValue` field for field and publish it. The
envelope's `value` has to stay an open record because one route serves
eighteen differently-shaped metrics, so nothing can `$ref` the shape and
it went out undocumented — `series`, `daysInWindow` and `asOf` were on
the wire with no way to find them short of inspecting a live response. It
is registered in the forced-components slot instead, held against the
runtime interface by a test, and the fields it does not cover are named
in the schema comment so the remaining gap is known rather than invisible.
Both routes were exempt from the contract as `shareLink` — "authenticated
by the URL credential, for a recipient with no account". That reason
holds for the page a person opens. It does not hold for two routes whose
whole purpose is to hand a practice a file it files into another system,
which is exactly the audience a contract has. `/c/{token}/d/{id}` was
published all along, one route away in the same tree.
Each entry states the gate it shares with the page, the frozen selection
it can never exceed, the flat 404 that covers every miss class including
a documents-only link, and the 20/h per-link bucket. The FHIR entry says
plainly that it is a download and not a REST face, and names the two
resource families the bundle deliberately omits.
The create-request description claimed "there is no FHIR or other
machine-readable face behind a share token", which stopped being true
when these routes landed. It now describes what a link actually serves
and records that the insurance leaf is refused outright.
The coverage guard gains the other half of its staleness check. It caught
an exemption for a route that had disappeared, and never the reverse — an
exemption left behind after the route was published, which is how these
two would have stayed on the list while the contract already carried
them. Break-proof: re-add either entry and it goes red naming it.
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.