Commit Graph
999 Commits
Author SHA1 Message Date
Marc-André Bombeck b10a3ef22e fix(onboarding): leave a trail for the three settings columns the flow writes
The setup flow writes a record's module map, its glucose unit and its unit
preference. All three have a dedicated settings route that records an audit
row, and the flow recorded none, so "why did my modules change" had no answer
in the activity panel. Restarting the flow did write a row, which made the gap
look like an oversight rather than a decision, because it was one.

Same event names as the dedicated routes, since it is the same column and the
panel should group them. The module row names the keys that actually moved and
the domains kept for their content.
2026-09-10 11:57:11 +02:00
Marc-André Bombeck cc6e6ca221 fix(onboarding): derive the module map only from a questionnaire that was finished
The only gate on the derivation was Q1 being set, so one answer was enough to
turn the whole questionnaire into a module map. Two ways that landed wrong and
they compound. Somebody answers who the record is for, closes the tab, and
later finishes the legacy four-step wizard, which posts to this same endpoint
and knows nothing about these questions: the five unanswered ones each read as
their conservative default, eleven surfaces are written off, and the
once-only marker makes it unrepeatable. Or any client patches one answer and
posts the confirm straight back.

The gate now asks whether every question has an answer or a deliberate pass.
A half-answered flow derives nothing, is not stamped complete, and keeps its
confirm step pending, so finishing the questions later still does the work.
The legacy arm is untouched: a caller with no setup row still answers the
fixed acknowledgement it always did.
2026-09-10 11:42:38 +02:00
Marc-André Bombeck 7a1f3fe32d fix(onboarding): stop the setup flow asking for units the account already holds
The spec's sixth question is skipped when the account already carries the
preferences, and the flow had no way to know: `User.unitPreference` is
nullable with null meaning "default metric", and the account payload coerces
that away, so nothing downstream could tell a chosen metric from an unset
column. The two columns are read raw on the server now, into the same
vocabulary the wire publishes, and a record whose account holds both reads its
`units` step as done — the question answers itself and is never put again.

Read-side only: the stored ledger keeps saying pending, because the person
really has not answered it, and an answer given later still lands.

The two writes the units answer makes — the display columns and the record of
the answer — go in one transaction, so a failure between them can no longer
leave the columns changed with no record of what changed them.
2026-09-10 11:39:32 +02:00
Marc-André Bombeck dbfad0f28e feat(api): publish the setup state on the account payload, and read it
Additive: `onboarding` carries the nine steps with their status, the answers as
given, the flow's own completion stamp and the one task it offered. Always
present — a record that never entered the flow reads as empty answers with nine
pending steps, so a client branches on the statuses rather than on whether the
field exists, and an unknown step id is skipped rather than refused.

It is resolved for the CALLER's own record rather than for the active one,
unlike the two module fields beside it. The three write routes are actor-only,
and publishing a setup state the browser cannot write would be a read it could
act on and never complete.

Both ends land here, because a field with no reader is the failure this
repository keeps rediscovering. The getting-started checklist orders its rows
from the answers — medication first for somebody who takes one daily, the
data-source row first for somebody who named a wearable — and it stays on screen
while the flow is unfinished. That is the second half: five readings were never
evidence that the setup finished, and now a pending step, an unconfirmed set of
answers or an offered task that produced nothing keeps the list where it is.

The ordering only applies once the answers are confirmed. Re-ordering the
dashboard under somebody who is still mid-question would be movement they did
not ask for.
2026-09-10 11:03:47 +02:00
Marc-André Bombeck 8d418bc6f8 chore(release): v1.38.15 — the rest of #939, one adherence figure, and an API that says when to come back
Version anchors, the OpenAPI document and the changelog entry. Refs #939.
2026-09-10 07:45:55 +02:00
Marc-André Bombeck 8b1ca7ee5a Merge the notification dispatch end-to-end journey, the account-scoped reminder sweep and the published notification preferences 2026-09-10 06:37:56 +02:00
Marc-André Bombeck ee73e840e0 Merge the backup and restore end-to-end journey, the restore that leaves instance settings alone and the 422 for an unreadable copy 2026-09-10 06:09:23 +02:00
Marc-André Bombeck fd6fa18913 feat(api): publish the resolved notification preferences on the account payload
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.
2026-09-10 06:04:39 +02:00
Marc-André Bombeck f3321b8031 feat(api): let the admin reminder sweep name one account
`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.
2026-09-10 06:00:27 +02:00
Marc-André Bombeck 6e4f3fb4c4 docs(ops): what a restore replaces, and the console path
Two gaps in `backup-restore.md`. It said the restore "replaces the account's
data tables" only in a paragraph about grants, so nothing stated the
consequence for the account's own record: the rows it gained after the snapshot
was taken are deleted with the rest of their class. And the console restore was
named once in passing — `## Restore` documents only the off-host script — so an
operator restoring from the browser had no procedure to read.

Both are written down now, including which button does what, that the restore
targets the snapshot's owner rather than the admin running it, that
instance-wide settings ride along only when the dialog's opt-in is ticked, and
what a copy this instance can no longer decrypt answers.
2026-09-10 05:26:56 +02:00
Marc-André Bombeck d3f087475c fix(backup): refuse a stored copy that will not open with 422
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.
2026-09-10 05:25:30 +02:00
Marc-André Bombeck 6a2282fef8 Merge Retry-After on every refused request, stable list pagination and the corrected error contract 2026-09-10 05:10:05 +02:00
Marc-André Bombeck cf9e8b8790 docs(api): declare the 429 headers instead of only describing 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.
2026-09-10 05:07:08 +02:00
Marc-André Bombeck 2c63b0c4a7 fix(api): give the remaining 401 throws a code, and say what the code covers
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.
2026-09-10 04:59:40 +02:00
Marc-André Bombeck 7c9740c657 Merge the per-module access reason on the account payload 2026-09-10 04:41:57 +02:00
Marc-André Bombeck 47a5079cfa feat(api): say why a module's surfaces are not there, not only that they are not
`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`.
2026-09-10 04:41:38 +02:00
Marc-André Bombeck 21dd7c7e48 fix(api): give the generic auth refusals a stable errorCode
`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.
2026-09-10 04:24:34 +02:00
Marc-André Bombeck 06d2104808 Merge managed-profile editing, sex at creation and module switches per record 2026-09-10 04:19:35 +02:00
Marc-André Bombeck 0cd996e7de docs(api): declare the two sync domains that were already on the wire
`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.
2026-09-10 04:05:04 +02:00
Marc-André Bombeck fd8fc0ec0e docs(api): publish details.issues and open meta on the error envelope
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.
2026-09-10 04:03:02 +02:00
Marc-André Bombeck 08100b6207 fix(api): put Retry-After and the rate-limit triple on every 429
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.
2026-09-10 03:55:46 +02:00
Marc-André Bombeck 0c46df1413 fix(api): put a ceiling on editing a managed record's identity
`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.
2026-09-10 03:24:50 +02:00
Marc-André Bombeck 3d163d3602 chore(release): v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf
Version anchors, the OpenAPI document and the changelog entry. Refs #943, #944.
2026-09-10 03:05:20 +02:00
Marc-André Bombeck 25ba09e714 Merge the precise off-host deletion note 2026-09-10 02:19:00 +02:00
Marc-André Bombeck 242be8100a Merge the truthful batch unit description and the dry-run count fix 2026-09-10 02:19:00 +02:00
Marc-André Bombeck 0bae48180a docs(api): say which account payload fields describe the record
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.
2026-09-10 01:57:12 +02:00
Marc-André Bombeck 7f54f339b2 refactor(backup): drop the retention value nothing could ever read
E2. `loadOffhostConfig` parsed BACKUP_RETENTION_DAYS, clamped it to a
minimum of one day and put it on the config object, where no caller read
it. Neither annotation fits: it is not deliberately internal, because
there is no internal use, and no consumer is pending. The design forbids
one outright — the worker's grant excludes DeleteObject on purpose, so
nothing here can act on a retention window without giving a compromised
worker the ability to wipe the history. A clamp that guards nothing only
read as an enforcer the worker is not.

So the field and the clamp go, and the reason sits where the parse was.
The variable keeps its place in the two example files and on the compose
whitelist: it is the number the operator sets the bucket's own lifecycle
rule to. The docs table said "defaults to 30", which implied the app had
an opinion; it now says the app does not read it.
2026-09-10 01:38:40 +02:00
Marc-André Bombeck e4333f8f71 docs(api): say which unit the batch route reads a sample in
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.
2026-09-10 01:29:08 +02:00
Marc-André Bombeck 05e5e0d6c4 Merge the synthetic post-deploy journey 2026-09-10 01:07:00 +02:00
Marc-André Bombeck 7d9532b53c Merge the off-host backup variable documentation 2026-09-10 01:07:00 +02:00
Marc-André Bombeck e06c42d261 Merge the Nightscout connect registration 2026-09-10 01:07:00 +02:00
Marc-André Bombeck 122d57c376 Merge idempotency on the three medication create routes 2026-09-10 01:06:59 +02:00
Marc-André Bombeck 3bd0664edf Merge the untagged glucose bucket across every consumer (#943) 2026-09-10 01:06:58 +02:00
Marc-André Bombeck 6fe1897941 fix(medications): accept an Idempotency-Key on the three replayed creates
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`.
2026-09-10 01:04:57 +02:00
Marc-André Bombeck 8995bef61c feat(ops): synthetic post-deploy journey
Post-publish verify proves the published image boots. Nothing proved that
on a running instance a person can sign in, write a reading and read it
back — which is exactly the shape of the defects this line keeps
rediscovering: a value written and never read.

`scripts/synthetic-journey.mjs` walks that journey in plain Node 22 with no
dependencies, so it runs from a workstation, a runner or the deploy host.
It warms `/`, `/api/health`, `/api/version` and the measurements list first
and discards those timings (the first hit on each route after a deploy is a
cold start), then asserts five legs in order: the served version equals the
expected one, the password flow signs in through a cookie jar, a weight
reading is written with an Idempotency-Key, the same row reads back with
its value, unit and source, and the reversible delete leaves the account
clean. One line per leg with the elapsed time; the first failure ends the
run non-zero with the status and a body excerpt that has every token shape
redacted out of it.

The workflow is `workflow_dispatch` only, with `base_url` and
`expected_version` as inputs and the credentials read from repository
secrets. It is deliberately not wired into the deploy path: the target
instance and the secrets are the maintainer's call.

Mutation proof, against a production build served from
`.next/standalone/server.js` on a throwaway Postgres.

Green:

    warm      35 ms  (discarded)
    ok        version         2 ms  serves 1.38.13
    ok        sign in        78 ms  session cookie held
    ok        write          64 ms  wrote cmtup9lwu0007zhovn0mxajdz
    ok        read back       8 ms  77.7 kg (MANUAL)
    ok        delete         27 ms  account left clean

    synthetic journey green

Read leg broken deliberately — the same server behind a throwaway forwarder
that empties the `GET /api/measurements` list and changes nothing else:

    ok        version         4 ms  serves 1.38.13
    ok        sign in        38 ms  session cookie held
    ok        write          40 ms  wrote cmtupa4ck000gzhovwnij64fz
    FAILED    read back       8 ms  read back: the list came back without the reading
              status 200
              body   {"data":{"measurements":[],"meta":{"total":0}},"error":null}

    synthetic journey RED on "read back"

Version leg broken the same way, by asking the instance for the previous
tag:

    FAILED    version         3 ms  version: instance serves "1.38.13", expected "v1.38.12"

    synthetic journey RED on "version"

`--self-test` carries that proof in the repository: it runs the journey
against an in-process instance nine times, once with nothing broken and
once per broken leg, and fails if a broken leg still comes back green. The
workflow runs it before it touches the real instance.

Gates: typecheck, `node --check`, lint (the three known warnings, no
errors), prettier, the workflow and CI-determinism guards.
2026-09-10 01:04:30 +02:00
Marc-André Bombeck 8ad5b90f88 docs(ops): the off-host worker never prunes; the bucket lifecycle does
The runbook said the worker prunes objects older than BACKUP_RETENTION_DAYS. It never calls DeleteObject; its grant is PutObject, GetObject and AbortMultipartUpload, on purpose, so a compromised worker cannot wipe the history. The sentence now says what expires objects and what the variable is for.
2026-09-10 00:48:31 +02:00
Marc-André Bombeck 610083aa4d fix(ops): stop the distance repair losing its rollup tail in silence
The rollups and the cached status insights are recomputed after the
transaction, so a throw there left the rows repaired and every chart on
the pre-repair numbers — and no later run would notice, because the
audit row makes the account skip from then on.

The tail is wrapped now. The audit row is written with
`rollupsRefreshed: false` and only flipped once the recompute returned,
so a re-run can tell "repaired, rollups pending" from "done" and says so.
A run that could not finish the tail names the account, prints the
backfill to re-run — `scripts/backfill-rollups.ts --user <userId>` — and
exits non-zero.

Review finding M2.
2026-09-10 00:37:15 +02:00
Marc-André Bombeck cd9b6450d5 docs(api): publish POST /api/nightscout/connect
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.
2026-09-10 00:36:31 +02:00
Marc-André Bombeck 69e4ba7b8a docs(ops): name the backup precondition on the distance repair
An in-place multiplier with no reverse operation was documented with only
a dry run in front of it. Point at the backup runbook where the write
happens.

Review finding M1.
2026-09-10 00:27:54 +02:00
Marc-André Bombeck 361e577864 fix(ops): refuse an account whose rows are not the thousandfold class
The provenance criterion proves where a row came from, not which build
imported it. An account re-imported on the fixed build carries the same
`EXPORT_XML_SOURCE_MAX` stamp and the same `stats:` external id with the
right numbers and no audit row, so it is selected exactly like a broken
one — and its rest days, under 200 m because the phone stayed at home,
survive the plausibility check after another thousandfold multiply.

So one candidate row that would leave the range now refuses the whole
account: nothing written, no audit row, the offending rows and a printed
reason instead. The idempotency guard only ever held against the
script's own prior run; this holds against the case it could not see.

The runbook says the same in words — a re-imported account must not be
repaired, the re-import already did it — and says what the dry run is
for: it is where the decision is made, not a formality in front of one
already taken.

Integration coverage for the re-imported account: correct rows, an
`--apply` run, nothing changes and no audit row is left behind.

Review finding H1.
2026-09-10 00:27:43 +02:00
Marc-André Bombeck 65e9ecbde6 fix(ingest): keep the batch sample's unit captured, not authoritative
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.
2026-09-10 00:24:59 +02:00
Marc-André Bombeck 90ca2cd899 chore(api): regenerate the OpenAPI contract
Picks up the managed-profile read and edit endpoints, the sex field on the
create body, the record-scoped module map on the account payload, and the
delegated cycle field on the record's module settings.

Refs #939
2026-09-10 00:02:15 +02:00
Marc-André Bombeck 5ab7a2ca81 fix(dashboard): paint the blood-glucose tile for untagged readings
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
2026-09-09 23:03:21 +02:00
Marc-André Bombeck bdfff04975 docs(api): say what the batch ingest does with a sample's unit
The field carried no description while the server ignored it. It no
longer ignores it, so the contract has to say so.

Refs #944
2026-09-09 22:45:17 +02:00
Marc-André Bombeck bc7eff8322 feat(ops): repair walking distances stored a thousand times too small
The unit fix only helps the next import. An account that imported an
archive before it carries a decade of days understated 1000x, and the
authoritative repair — re-importing the archive on the fixed build —
needs an archive the account may no longer have.

`scripts/repair-apple-health-distance.ts` is dry-run by default and
selects rows only by the stamp the archive fold itself writes: the
EXPORT_XML_SOURCE_MAX provenance plus the `stats:` external id minted
for that fold. Never by "the value looks small", so a row a native sync
has since overwritten is left alone. `--apply` multiplies one account
per transaction and writes the audit row in the same transaction, which
is what makes a second run a no-op. The archive's own unit is named by
the operator (`--unit=mi`) because the discarded attribute is the defect
itself, and a row whose repaired value would leave the plausible range
is reported rather than written.

Refs #944
2026-09-09 22:45:13 +02:00
Marc-André Bombeck d464a3efe4 Merge remote-tracking branch 'origin/main' into fix/managed-record-capabilities
# Conflicts:
#	public/sw.js
2026-09-09 21:38:11 +02:00
Marc-André Bombeck b5c392636f feat(sharing): publish writable and manageable domains on the account payload
Every accountAccess entry on GET /api/auth/me carries writableDomains
and manageableDomains: the sections the grant opens, intersected with
the sections whose routes accept a delegated write at that level. A
READ grant gets two empty lists, a WRITE grant gets writable sections
only, a MANAGE grant gets both, and the vault appears in neither.

The client parser checks the structural invariants (no repeats, within
the grant's sections, manageable within writable, nothing below the
level) and requires the two published views of the active record to
agree on the lists. Regenerate the OpenAPI document and extend the
payload consumer guard with the active-record readers.
2026-09-09 19:42:26 +02:00
Marc-André Bombeck b8a3890ef4 chore(release): v1.38.12 — the scanner findings are cleared
Version anchors, the OpenAPI document and the changelog entry for the dependency release.
2026-09-09 19:19:50 +02:00
Marc-André Bombeck e0cf6c2403 chore(release): v1.38.11 — sign out everywhere means everywhere
Version anchors, the OpenAPI document, the changelog entry, and the
README sentence that still declared immunization records out of scope
while the vaccination record has shipped since v1.37.3.
2026-09-08 20:23:30 +02:00
Marc-André Bombeck 69fc8bf2e3 chore(release): v1.38.10 — the off-host backup streams, the rotated token sunsets 2026-09-06 16:02:50 +02:00