Commit Graph
6301 Commits
Author SHA1 Message Date
Marc-André Bombeck 3e063f48ae fix(medications): a skipped dose leaves the dashboard tile's denominator
The two compliance engines disagreed about a deliberate skip. The dose-history
ledger (`tallyComplianceFromLedger`, behind the medication card, the dose
history, the doctor report and the Health Score pillar) has excluded a skipped
dose from the denominator since v1.15.9 — a skip is a pause, not a miss. The
schedule-anchored engine behind the dashboard tile and the adherence storyline
built its denominator from the raw expected-slot count with no skip term, so it
counted the same slot as expected-and-missed. A twice-daily day with one dose
taken and one skipped read 100 % on the card and 50 % on the tile.

The skip term now lands in the engine, not at the call sites: the day's
expected count drops the doses the user skipped, capped at the minted slots,
and a day whose every expected dose was skipped falls out of the buckets the
same way it falls out of the ledger denominator. The schedule anchoring, the
per-day buckets and the duplicate-log cap are untouched — only the skip
treatment moved. `skipped` outranks `autoMissed`, mirroring
`reconstructDoseHistory`, so a dose the auto-miss cron marked forgotten still
counts against the rate.

A new parity test feeds one fixture (twice daily, one taken, one skipped, one
missed) to both engines and pins them to the same figure, so the class cannot
come back through either side; both new cases were confirmed to fail against
the previous behaviour. The tile-engine suite gains the corrected figure.
2026-09-10 04:01:32 +02:00
Marc-André Bombeck 1d2744a109 fix(api): give every offset-paged list a unique tiebreaker
`/api/measurements` and `/api/labs` sort on one non-unique column and
page with `skip`/`take`. When the sort key ties, Postgres does not
promise the same order for the query that fetches page N and the one
that fetches page N+1, so a client paging its own history can receive a
row twice and never receive another — silently, on both sides.

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

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

The fixtures seed ids in an order unrelated to insertion order, so the
assertion on order inside the tied block can only hold if the secondary
key is doing the work: with the tiebreaker removed the new file fails on
all three cases.
2026-09-10 04:01:14 +02:00
Marc-André Bombeck 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 50a65b6f80 test(e2e): cover the second-factor journey end to end
Enrol TOTP from the settings surface, sign out, sign back in through the
challenge, and step up for a recovery-code rotation — in a browser, against
a real server, on a throwaway account the journey seeds and drops itself.

Four refusals ride alongside the four positives: a wrong code at the
challenge (401, no session cookie, and the ticket's attempt counter moved),
the enrolment code replayed (401, refused by the monotonic totpLastStep
floor), the rotation once the session's mfaVerifiedAt has aged past the
five-minute window (401 with meta.errorCode auth.stepup.required), and a
recovery code presented a second time (401). Each was proved to fail by
removing the control it stands on and watching that one assertion break.

Codes are computed from the enrolment secret with the otpauth dependency the
server verifies against, at explicit timestamps, so nothing waits on a step
boundary: the sign-in code is generated one step ahead of now, inside the
±1 drift window and necessarily above the replay floor.

The fixture reaches Postgres for the two things that have no endpoint by
design — ageing the step-up stamp, and emptying the per-IP auth buckets
this journey would otherwise exhaust. The account carries the Playwright
project name so the two browser projects never drive one factor state.

Adds data-testid hooks on the TOTP card, the login step, and the password
form so the spec asserts on stable attributes instead of rendered text.
2026-09-10 03:53:11 +02:00
Marc-André Bombeck 923b832891 test(e2e): correct three claims the report journey's comments make
The mobile exclusion carried an inherited second reason — that mobile
emulation emits no download event for the panel's anchor click. The
precedent it came from contradicts itself: the JSON example-download
test in `settings-export.spec.ts` asserts a real download event in the
mobile project, and that spec also measures this very panel's tap
targets there. The honest statement is that the panel's mobile layout is
covered elsewhere and this journey is about the artefact.

The bytes are read off the download because the saved file is what the
person ends up with, not because a consumed body reads back empty —
Playwright buffers the response independently of the page.

And the filename assertion covers the name the client anchor invents,
not the route's `Content-Disposition`; the two differ by a word, so it
is not contract coverage of the header.
2026-09-10 03:49:32 +02:00
Marc-André Bombeck 094eb13b5f test(e2e): read the report's period line in the account's date order
The period assertion hardcoded a slash-separated month-first rendering.
The field order comes from the account's date-format preference, so a
DMY account prints dots and fails with a message claiming the report
carries no period at all, while an en-GB rendering matches, swaps the
two fields and computes a nonsense span in silence.

The order is now read off the account and the separator is captured once
and back-referenced, so all three renderings parse and none can be read
as another. `AUTO` is refused rather than guessed at: the fixture pins
an explicit order, and an account that lost it should say so.

The empty-window control also asserts the span it got. It had two silent
fallbacks under it — the panel sends a day count when a date field is
empty, and the route falls back to its 90-day default on a bound it
cannot parse, neither a 422 — and both landed on a window that holds the
seeded readings, so the missing-section assertion below was passing on
the fixture's luck.
2026-09-10 03:49:21 +02:00
Marc-André Bombeck 19bee96ad2 test(e2e): prove the report card is closed by the destination, not the level
The delegate spec asserted the absence of the export card inside a
READ-level record and presented that as the UI half of the MANAGE
boundary. It is not. `/settings/gesundheitsakte` is a personal
destination, and the shell answers it with the "not part of shared
access" panel at every adult grant level — read, write and manage
alike — so the same two absences would pass unchanged inside the record
the delegate manages. The assertion could not fail for the reason it
named.

Both arms now open the page. Inside the managed record, where the route
answers 200 for the same request, the card is still absent: that is what
makes the destination the cause. The level-sensitive statement stays
where it belongs, on the route's 403, and the file header now says which
half proves what.
2026-09-10 03:48:47 +02:00
Marc-André Bombeck 33f5bed802 test(e2e): give the doctor-report journey its own account
Generating a report writes: the route remembers the scope and the
practice name on the account row, and the export panel a later visit
opens is therefore the collapsed "repeat run" state. The journey was
generating on the shared fixture account, which is the account
`settings-export.spec.ts` asserts the first-run state against in nine
places — an expanded picker, an empty summary, a disabled button. The
two files run in an unconstrained order under two workers, so the first
symptom was an order-dependent red in CI; against a persistent local
database the flip never cleared and the red was permanent.

The journey now seeds, generates and reads back on `e2e-report-owner`,
with its own cookie jar. The shared account and the whole-record
fixtures also get `report_selection_json` and `last_report_practice_name`
cleared on every seed, which repairs a database an earlier run already
dirtied and keeps a delegate's scope choice from being left on a record
owner's row.

The account's date order is pinned rather than left to the locale, and
its export bucket is purged beside the share-link and login buckets: the
10-per-hour ceiling is shared by every export route, keys on the actor
and outlives a run by an hour, so repeated local runs would otherwise
meet a 429 the spec reports as a missing artefact.
2026-09-10 03:48:37 +02:00
Marc-André Bombeck db8ac95342 test: cover the tile's rate attribute in the unit gate, and correct two notes
`data-latest-rate` is a production attribute whose only reader was the browser
suite, so a change to the rate's shape would have surfaced three minutes and a
whole server away from the gate that should own it. One unit test now renders
the chart over a loaded window and asserts the root carries the last day's
rate.

Two comments were wrong. The card's take / skip buttons do not collapse to
icons at narrow widths — both render their label unconditionally beside the
icon — so only the true half of the reason for addressing them by `data-slot`
survives: the labels are translated copy.

And the Enter press in the wizard's times-of-day helper is not decoration.
The field's overlay input holds what is being typed and hands it to the form
only on Enter or on blur (`src/components/ui/time-field.tsx:175-184`), while
the add button is disabled on an empty draft — so a fill followed straight by
a click waits out the actionability timeout on a button that never enables.
Proven by removing it: the flow died at exactly that click. The line stays,
with the reason written beside it.
2026-09-10 03:45:32 +02:00
Marc-André Bombeck c39bf0d0ed test(e2e): name the tile's disagreement about a skip, and assert both refusals
The dashboard assertion called itself "the assertion that they cannot drift
apart silently" and then pinned 50. They have already drifted. The tile's
denominator is the day's expected slots, which a deliberate skip does not
leave, and its numerator counts only rows carrying a `takenAt`
(`src/lib/analytics/schedule-anchored-compliance.ts:141-164`). The card's
engine — `src/lib/analytics/compliance.ts`, the documented single source of
the percentage — drops a skip from the denominator outright
(`src/lib/analytics/compliance/ledger.ts:43-47`), which the counts nine lines
earlier already assert. One afternoon, two answers. The note now says that,
names which engine is authoritative, and the number stays pinned as what the
server's daily buckets really produce rather than as agreement.

The refusal flow was named for a schedule that does not exist and proved
something else: the 404 comes from the ownership guard, before the body is
read and before any schedule code runs. `expect(refused.data).toBeNull()`
proved nothing either — the error envelope always carries a null `data`. So
the test is renamed for what it does prove, the empty assertion is dropped,
and the case the old title promised is added: a pin onto an instant that is
not one of the medication's slots, refused with the documented 422.
2026-09-10 03:45:20 +02:00
Marc-André Bombeck f544cae9a9 test(e2e): read the card's rate once, and keep the journey inside one day
Two separate ways the flows could not deliver what they promised.

The card read polled for up to seventy seconds and escaped a missing figure by
reloading the page — which re-mounts the list and issues the very batched
compliance call it was waiting out, so each poll spent one more of the
allowance it was short of. Meanwhile the budget is thirty seconds tripled by
`test.slow()`, so one ride-out consumed the whole test and the run died on a
Playwright timeout rather than on the "the card never painted an adherence
figure" message the note promised. The inner poll was also wrapped in a second
one with a thirty-second budget, and `expect.poll` does not abort an in-flight
callback, so a seventy-second call ran to completion inside it.

With the journey on its own account there is nothing to ride out: the read now
waits twenty seconds for the attribute and fails with the diagnosis attached.
The nesting is gone too — the step that waits for the card to re-read its
schedule polls the attribute directly.

The other one is the day boundary. A clock-anchored slot is inside its own
on-time window by construction, so the TAKE is always today's; the dashboard
assertion is not, because it reads the latest day and a day's expected count
carries no now-cap. Cross midnight between the take and the final read and the
latest day is a fresh one with two doses scheduled and none taken, which
answers 0 where 50 is asserted. The flow is a couple of minutes long, so it
declines to start in the last ten minutes of the day and says why.
2026-09-10 03:45:09 +02:00
Marc-André Bombeck d529d5976c test(e2e): move the adherence journey onto its own account
The batched compliance read allows thirty calls a minute per ACTOR, and the
journey spends that allowance twice over: it writes doses and then reads the
resulting rates back off the same cabinet. On the shared account two siblings
spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and
drives a client-side round trip back to it, and `delegated-writes.spec.ts`
opens `/medications?new=1` on a SECOND session row of the same account, which
is a different session but the same actor and therefore the same bucket. Add a
serial group's retries and thirty reads inside one minute is reachable rather
than theoretical.

The cabinet has the same shape of problem in the other direction: the clear
before each test deletes every medication row of the account it runs on, and
that is today the account every other authenticated spec holds.

So the journey gets its own account and its own jar. The rate-limit bucket,
the medication cabinet and the dashboard tile's denominator become private at
once.

Two fixture repairs ride along, because they are the same helper:

- The clear now goes out as the app's own DELETE per row instead of one raw
  `DELETE FROM medications`. Three fifteen-minute per-user cells hold
  medication state and a raw statement evicts none of them; the file survived
  only because every test happened to write through the API afterwards, which
  is an accident of ordering rather than a property. The rows are still
  enumerated in SQL, because the list route serves a memoised shape and a row
  it has not caught up with would hide from it.

- `ageMedication` takes both stamps from its caller instead of deriving them
  from `CURRENT_DATE`. That resolves in the database session's zone — UTC in
  CI — while the plan dates the spec sends are computed in the browser's
  pinned Europe/Berlin, so between midnight and 02:00 the row silently started
  a day earlier than the caller asked for. The docstring now says `starts_on`
  is overwritten, not adjusted.
2026-09-10 03:44:48 +02:00
Marc-André Bombeck 297395372b Merge pull request #945 from MBombeck/release/v1.38.14
v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf
v1.38.14
2026-09-10 03:43:31 +02:00
Marc-André Bombeck 7b06a235e1 test(e2e): cover doctor-report generation end to end
The doctor report had unit coverage for the renderer and route coverage for
the envelope, and nothing that pressed the button. Two browser journeys close
that.

`doctor-report.spec.ts` seeds a few weeks of blood pressure and weight, one
allergy and one medication through the page's own `fetch`, picks a window in
the export panel and generates. The saved download is asserted as a real
artefact — `%PDF` magic, a `Content-Length` that agrees with the bytes, a
length no empty document reaches — and then parsed back to text, so the
period line, the blood-pressure section and the drug list are read out of the
document rather than inferred from a 200. The period is checked as a span, so
the assertion survives the account's timezone. Its refusing control is a
window with no readings: still a valid, readable report that names the period
and carries no blood-pressure section, with the reference data that is not
window-scoped as the control against a blank page passing.

`doctor-report-delegate.spec.ts` covers the access boundary the route draws at
MANAGE. A delegate inside a record it may only read is not offered the export
card at all, and the route refuses the request that card would have sent with
`sharing.access.denied`. The positive control is the same delegate, the same
payload and the same switched-record path against a record it manages, so the
one variable between pass and refusal is the access level. Both requests carry
the record-context headers the shipped client sends: without them the fence
refuses with the same status and the same error code, which would make the
refusal pass for the wrong reason.

Seeding is idempotent — a re-posted reading collides on its natural key and
answers 409 — so a second local run and a repeated run both stay green. The
delegate journey takes its own cookie jar because it moves the session's
record selector, and both specs run in one project.
2026-09-10 03:27:02 +02:00
Marc-André Bombeck fd337a4e7a fix(import): match the molar-mass annotation by the shape Apple writes
The catch-all angle-bracket pattern read as an HTML sanitiser to static analysis. The annotation Apple writes is a number, so the pattern now matches exactly that, and a unit with anything else between the brackets stays unknown instead of being silently trimmed into a lookup.
2026-09-10 03:25:16 +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 92e43ebf67 Merge the synthetic journey's host allowlist and cleanup 2026-09-10 02:18:59 +02:00
Marc-André Bombeck c673255355 fix(settings): stop a timezone edit writing the actor's language into a record
A managed record with no recorded language opens the edit form's language
control on the actor's, which is the same starting point the creation form
uses. The diff then compared that control against the record's NULL column, so
every save of such a record carried `locale` — a guardian correcting only the
timezone silently recorded their own language as the record's, and the audit
row said the language changed.

The comparison is against the value the control opened on, frozen at mount.
Nothing is sent unless somebody moves it.
2026-09-10 01:59:08 +02:00
Marc-André Bombeck 038fc72be2 fix(settings): keep a managed record's edit form across a refetch
The fields component was keyed on the query's `dataUpdatedAt` under a comment
saying the key stopped a refetch landing mid-edit from replacing what somebody
is typing. It did the reverse: the timestamp moves every time the query
resolves, so invalidating the `managed-profiles` prefix from anywhere — the
guardian list on another row is enough — remounted the fields and threw the
half-typed name away.

Keyed on the record's id instead, which is stable for as long as the form is
about the same record.
2026-09-10 01:57:42 +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 8ff1b865fc fix(settings): send a managed record's cycle flag only when it moved
The Modules save posted `cycleTrackingEnabled` on every submit, and the route
writes it whenever it is present. An explicit boolean beats the sex-derived
default in the cycle gate, so a guardian turning Labs off for a record created
without a recorded sex wrote cycle OFF as a deliberate answer; setting the
record's sex to FEMALE afterwards then left cycle off with nothing on screen
saying why. It also listed `cycleTrackingEnabled` as changed in the record's
audit trail on every save of any module.

The flag now rides only when it differs from the value the form opened on,
which is what the edit form's `managedProfileEditPatch` already does. The
decision moved into a pure `managedModulesPatch` so a test can see it — what a
form does NOT send is invisible in its markup.
2026-09-10 01:56:17 +02:00
Marc-André Bombeck 0cccb4de99 fix(sharing): keep a scoped grant out of the record's module configuration
Publishing the active record's module map and cycle flag on the account
payload was right for a whole-record grant and too generous for a narrowed
one. `accountAccess.active` is set for any live grant, level and scope
included, so a delegate scoped to `measurements` read whether the owner
tracks their cycle, their screeners, their illness episodes and their
supplement intake, and the cycle flag derives from the owner's recorded sex.

Both fields are now masked to the sections the active grant names: a closed
section answers `false`, which is the answer a module that is off already
gives and the answer the browser's route inventory has always acted on. An
unscoped grant names the whole record and is unaffected, so the own-record
and whole-record payloads are byte-identical to what they were.

The masking table lives beside the sharing vocabulary rather than inside it,
and runs one way only: no value in it can turn a module on, admit a read or
widen a grant.
2026-09-10 01:54:29 +02:00
Marc-André Bombeck 617beb40dc test(e2e): cover the medication-adherence journey end to end
The percentage on a medication card is the number this product is for, and
nothing in the browser suite walked the path that produces it: the wizard
specs stub `/api/medications` and stop at the POST body, and the compliance
engine's own tests are unit tests over pure functions. The join was the part
nobody checked.

Three flows against the seeded account, serial and in one project because
they read counts back off a cabinet they own:

  - a twice-daily medication created through the real wizard, one dose taken
    and one skipped from the card, and the figure read back on the card, in
    the intake history and on the dashboard tile;
  - a dose recorded against a schedule that does not exist, refused, with the
    rate exactly what it was before;
  - a plan due on two weekdays whose other five days stay out of the
    denominator, with a daily twin given the identical doses as the control —
    without it, "100 %" would also be what an engine that counted no days at
    all would say.

The dose times are derived from the clock rather than picked from the
wizard's suggestions: the card offers one dose, so with fixed times there is
always an hour at which that dose belongs to yesterday or tomorrow, and the
day the write lands on decides what the tile reads. Each medication is aged a
few days as well — compliance is reconstructed from the creation stamp
forward, so a medication created a second ago reads the empty-set 100 % and
no take can move it.

Four surfaces gain data attributes so the journey addresses controls and
figures rather than translated copy: the card's take / skip buttons, the
card's two adherence rates, the intake-history row status, and the dashboard
tile's most recent day, which existed only as bar geometry. Presentation is
unchanged; the compliance block's fixed-geometry guard normalises the two new
rate attributes alongside the ones it already did.
2026-09-10 01:46:13 +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 a4909d3e00 docs(env): say exactly which object the off-host worker deletes
E1. Two halves of one correction. `.env.example` still said backup objects
older than BACKUP_RETENTION_DAYS are pruned, which no code does, so a
self-hoster reading that file alone still expected the worker to keep the
bucket tidy. It now carries the same sentence as the production example.

And that sentence overreached in the other direction: "the worker never
deletes an object" is not true of `_healthcheck/<ts>.bin`, the 1-byte key
the Test-connection button writes and then deletes. The delete swallows
its own failure, so an operator who granted exactly the three documented
actions leaves one orphan per press and is never told. Both files now say
"a BACKUP object", and name the one key and what happens without
DeleteObject on that prefix.
2026-09-10 01:31:17 +02:00
Marc-André Bombeck c3b5585fd6 fix(ops): stop the distance dry run promising work it will refuse
Low, review 2. An account with one out-of-range candidate is refused
whole, rows and audit row included, but the dry run counted its rows into
"N row(s) would be repaired" — so the number an operator read before
--apply was larger than the number --apply could ever produce. The refused
accounts are now counted and reported separately, and the per-account line
says its rows are not in the total.

The refusal itself told the operator to check the Health app "before
forcing anything". There is no --force and never was. It now names the
only route that exists: re-run with the matching --unit if the archive
really was in another one.
2026-09-10 01:30:26 +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 79cb08efc7 test(ops): prove the journey's redactor on a key-shaped body
Low, review 2. The self-test walked every leg but never once asked whether
the redactor actually holds. The excerpt path is the only thing between a
token in a failure body and the run log, so it gets its own case: a body
carrying this project's own key prefix must come back with the token
replaced.
2026-09-10 01:26:40 +02:00
Marc-André Bombeck 3ba2fe3b82 fix(ops): prove the journey's reading is gone, not just accepted
Low, review 2. The delete leg asserted HTTP 200 and printed "account left
clean". An instance that answers 200 and keeps the row would have printed
the same line, which is the failure mode the journey exists to catch.

The leg now reads the id back and requires a 404. The self-test carries a
mock that answers 200 without deleting; with the read-back removed that
case goes green, which is the proof the check can fail.
2026-09-10 01:26:24 +02:00
Marc-André Bombeck 25cb79ecfc fix(ops): let no failed journey leg leave a reading behind
S2. The legs ran in one loop that returned on the first red one, so a
failed read back never reached the delete and the probe account kept the
row. Worse, the id was assigned only after the write's await returned: a
POST that timed out once the server had already committed left a row the
script could not even name.

Legs 3 to 5 now sit in a try/finally. The finally sweeps whatever the
delete leg did not, and when there is no id it asks the instance for the
row by a marker the write puts in `notes`, unique per run and known before
the request goes out. The sweep never throws — the red leg is the verdict,
and it says out loud what it could not remove.

--self-test asserts the mock holds no rows after every case that got as
far as the write, so a red read that skipped the delete is caught.
2026-09-10 01:26:04 +02:00
Marc-André Bombeck 742eeaad3c fix(ops): refuse a synthetic-journey target that is not ours
S1. The journey signs in with a real account and POSTs that account's
password to whatever BASE_URL names, and the workflow took BASE_URL as
free text. Anyone with repo write, or a leaked token, could dispatch the
run at a collector of their own and keep a working credential; secret
masking guards the log, never the egress.

The host now has to be on a short const allowlist in the script, checked
before anything is sent, with exit 2 and a named refusal otherwise. The
workflow input is a choice between the two hosted instances, so the form
cannot offer a third; the script's own check still stands behind it for a
dispatch that goes round the form.

--self-test covers the refusal, the prefix lookalike a startsWith check
would have waved through included.
2026-09-10 01:25:01 +02:00
Marc-André Bombeck ccca056f64 feat: let the record's module map filter its own navigation
The other half of the switched-payload change, found by the integration
test that asserted the door was gone and got the door. `isNavDestinationVisible`
short-circuited on `sharedRecord`, so a switched session skipped the module
filter entirely and every destination the grant's scope allowed was offered.

That was right while the map was the actor's: a delegate's own preferences
must not hide a domain the record granted. The map is the record's now, so
the objection is gone and the line is what makes a guardian's toggle visible
inside the profile it was set for. Scope decides which doors the grant opens;
the map decides which of them the record uses at all.

The read-only profile summary needs its marker checked against `!sharedRecord`
rather than reached through the removed short-circuit, and the case that
pinned the old reading is turned round with the reason written out.

Refs #939
2026-09-10 01:09:50 +02:00
Marc-André Bombeck bed2ab568e fix(test): read the audit details as the string the writer stores
`auditLog` serialises `details` with `JSON.stringify` into a text column,
so the assertion was reading a field off a string and finding nothing.

Refs #939
2026-09-10 01:09:50 +02:00
Marc-André Bombeck 1ab2e1b524 chore: regenerate the OpenAPI document and list the page-side seed after integration 2026-09-10 01:09:39 +02:00
Marc-André Bombeck 1dc4f70fed Merge the Apple Health import end-to-end journey 2026-09-10 01:09:27 +02:00
Marc-André Bombeck 80bcf75786 Merge the glucose end-to-end journey 2026-09-10 01:07:00 +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 6338da8cf6 Merge the per-field profile rejection surfaces 2026-09-10 01:06:59 +02:00
Marc-André Bombeck f3209f0a93 Merge the release-gate noise removal 2026-09-10 01:06:59 +02:00
Marc-André Bombeck 7036b2d225 Merge the de-flaked end-to-end specs 2026-09-10 01:06:58 +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 071b110e8b Merge the Apple Health record-unit fix and the distance repair (#944) 2026-09-10 01:06:58 +02:00
Marc-André Bombeck 8702d85c9d test(notifications): pin aps.mutable-content on the medication reminder
The client's notification service extension replaces the medication name
on the lock screen, and iOS routes a payload through an extension only
when the alert carries `mutable-content`. The dispatcher has set the flag
on every alert it builds since the category work for the v0.5.4 client,
but the only assertion on it sat inside a category test, where dropping
it would have failed nothing and the name would have quietly reappeared
on the lock screen of a device that asked for it to be hidden.

Two cases now say it out loud: the medication reminder carries the flag,
and so does another event type, because that is the shipped behaviour and
narrowing it would take a capability away from the client rather than
tidy something up.
2026-09-10 01:05:03 +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