Commit Graph
397 Commits
Author SHA1 Message Date
Marc-André Bombeck fdc60f69c2 feat(documents): search and import from Paperless-ngx and Papra on request
A person stores one connection per system (base address, Papra
organization id, API token encrypted at rest) and can search it by
document name with tag and date filters, then import picked documents
one per request through the same ingest path as an upload: source-key
answer before any download, content dedup, deleted stays deleted, quota,
provenance, and the person's own AI reading setting. An import can link
the document to the visit, condition or vaccination it was picked for.

The operator lists the instances in DOCUMENT_SOURCE_ORIGINS; unset, the
feature is off. Only listed origins are contacted, through safeFetch with
the origin pinned, redirects refused, timeouts and byte caps. The routes
are cookie-only and owner-only. The token is registered for rotation,
removed by Delete all data, and not carried in backups.
2026-09-26 13:02:36 +02:00
Marc-André Bombeck cd79cfaae9 fix(reminders): keep a check-up's due date when only its details are edited
Every edit of a reminder recomputed its due date from now, so fixing
the label or the location of an open, overdue check-up moved it to its
next slot, the same "gone until next year" the reminder tick caused.
The due date is now recomputed only when the edit changes when the
reminder recurs: the interval, the rule, the first due date (compared
by value, since the edit forms resend every field), or switching a
disabled reminder back on. A notify-hour edit keeps the due day and
moves the hour on it, together with a snooze pinned to that slot.
2026-09-26 10:05:54 +02:00
Marc-André Bombeck bec79879b5 Merge branch 'feat/document-import-token' into release/v1.39.2 2026-09-26 00:22:43 +02:00
Marc-André Bombeck 10a1c3ca2e fix(documents): keep every import key deleted, answer re-sends cheaply
A keyed upload that matched a stored document by its bytes never recorded
its key, so deleting that document let the next import run store it again.
Such keys are now kept as aliases of the document, the source-key check
reads them, and the purge carries them into the ledger with the row's own.

A source key may ride the query string and is then answered before the
body is read or the hourly allowance charged; an upload answered as a
duplicate hands its slot back. GET /api/documents/inbound/source answers
whether a key is held, under the same documents:write scope, so an
importer can skip a download.

aiRead=defer is stored on the row. The summary catch-up, the summary job
and lab auto-staging skip such documents until an explicit Read with AI
or Generate summary clears the marker; backup and restore carry it.

A documents:write caller no longer sees the quota figures on a full vault.

Refs #1038
2026-09-25 22:35:11 +02:00
Marc-André Bombeck 100412a3a6 Merge branch 'feat/condition-body-site' into release/v1.39.2 2026-09-25 22:09:01 +02:00
Marc-André Bombeck 14f0fc4d4f feat(body-sites): record a body site on conditions and add a body-site view
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.

- migration 0354: two nullable columns on illness_episodes, reusing the
  laterality type from 0349
- create and edit accept bodySite and laterality; on PATCH an absent key
  keeps the stored value, so an edit from the shipped iPhone app, which
  never sends either field, leaves them untouched
- GET /api/body-sites: the sites the record holds across visits and
  conditions, folded after the decrypt and bounded per kind; with site
  (and laterality) the visits and conditions there, one hop to their
  documents, lab results and visits. Read in the profile section;
  conditions only under an illness grant with the module on, and links
  into ungranted sections come back as unlabelled placeholders
- the link service reads document and visit links from the condition's
  end of the two existing tables
- /checkups gains a Body sites tab; the visit and condition forms share
  one body-site field pair that suggests sites already used; a
  condition's page links its site into the view
- the doctor report, the clinician page and the MCP doctor-visit summary
  name the site on conditions; the Coach illness block carries it
  sanitised, and MCP get_illness_recovery returns it fenced
- backup and restore carry both fields in both file kinds; the site is
  registered as ciphertext and rotates with the key
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 21:55:14 +02:00
Marc-André Bombeck 55a32ff7d1 feat(documents): add a document upload token for imports from other systems
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.

Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.

The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.

Refs #1038, #938
2026-09-25 21:48:04 +02:00
Marc-André Bombeck 1e040605be feat(medications): accept units per dose like 1.5 and let the editor enter them
A medication's units per dose, a schedule's override and a restored
backup now accept any value above 0 and at most 100 with at most four
decimal places, which is exactly what the Decimal(10,4) column stores.
Before, only whole numbers 1-100 and the fractions 1/4, 1/3, 1/2, 2/3,
3/4 passed, so one and a half tablets could not be recorded. A restore
now refuses a value no write route would accept.

The medication editor keeps its buttons and adds an Other field that
reads 1.5, 1,5, 1 1/2 and 1½, holds the step while the text does not
read, and previews the value it will save. The supply tab's packaging
dialog uses the same reader; it refused every non-whole value before,
so a medication at half a tablet could not have its packaging saved.
Cards and the supply tab show a whole number plus a fraction as 1½.

The rule, the input reader and the display format live in one module
without imports so the editor and the validators share them.

Refs #1034
2026-09-25 09:58:55 +02:00
Marc-André Bombeck e5236d2e6a fix(medications): keep a record-only schedule unless the write names trackIntake
The shipped iPhone app cannot see the schedule of a medication kept as a
record, because it is served with an empty schedules list. Its editor opens
such a medication as daily at 08:00 and, once any schedule field is touched,
sends a full replacement built from that default, plus asNeeded and oneShot,
and never trackIntake. On a record-only medication the update route now
drops schedules, asNeeded and oneShot from a write that does not name
trackIntake and applies the rest. The web schedule editor and the wizard
name the flag on every save.
2026-09-25 09:24:38 +02:00
Marc-André Bombeck 94ec9ab932 Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only 2026-09-25 09:20:15 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

The medication wire serves such a medication with schedules: [] and the
stored rows in an additive recordedSchedules field, so a client that arms
local reminders from schedules stays silent without an update. Switching
tracking off or back on archives a schedule revision at that instant, the
untracked stretch with an empty payload, so it never counts as missed and
the projector does not put a dose from before the switch back on the card.

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck c327ae81d5 Merge branch 'feat/procedure-history' into release/v1.39.1 2026-09-25 08:23:34 +02:00
Marc-André Bombeck ad0d50fa28 feat(visits): record procedures and surgeries with a body site
A visit can be filed as a procedure or surgery (new EncounterKind
PROCEDURE) and carry a body site in the person's own words, encrypted at
rest, plus an optional side (LEFT, RIGHT, BOTH). An existing visit
switches to the new kind by an ordinary edit, keeping its date, reason,
outcome and links.

- migration 0349: enum value, laterality type, two nullable columns
- GET /api/encounters/procedures: the DONE procedures, newest first,
  searched after the decrypt over site, side and reason, with body-site
  choices and a total computed over the whole history
- create and edit accept bodySite and laterality on any kind
- /checkups gains a Procedures tab with a body-site filter; the visit
  form offers the site for a procedure and keeps it in view on any
  visit that carries one
- backup and restore carry both fields; the body site is registered as
  ciphertext and rotates with the key
- MCP get_visits narrows by kind and reads the whole record for
  procedures when no window is named; the Coach visit block names a
  procedure's site
- i18n in all seven locales, OpenAPI regenerated

Refs #1025
2026-09-25 08:11:37 +02:00
Marc-André Bombeck d0e321c54a Merge branch 'fix/v1391-contract-and-ingest' into release/v1.39.1
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/measurements.ts
2026-09-25 08:04:30 +02:00
Marc-André Bombeck f443ab6615 feat(measurements): accept sleep stages by name on POST /api/measurements
A bridge such as Tasker or Home Assistant could only send sleep stages
through the batch route, in HealthKit's terms: the category identifier,
a stage codepoint from 0 to 5, and a value it had to work out in
minutes. It can now post one segment to `POST /api/measurements` with
`type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`,
`CORE`, `DEEP` or `REM`, and either `startDate` + `endDate` or
`measuredAt` (the end) + `value` (minutes).

The row is the one the batch route writes for the same segment, and the
dedupe is the batch route's: the opposite-source same-reading check,
then one reconcile over both unique identities. Without an `externalId`
the server derives one from the stage and both instants, so a re-post
answers 200 `duplicate`, a corrected start answers 200 `updated`, and a
new segment answers 201 `inserted`. The narrow `measurements:write`
token is accepted and still writes only MANUAL rows. Every other body
on this route behaves as before.

Requested in #972.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 0cc73d3c88 fix(ingest): treat upsert_failed as a retry in the nutrient and cycle batches
The nutrient batch docs told a client to log and drop an `upsert_failed`
entry. The route means the opposite: a write group failed on the
server's side and nothing was stored. The docs now say to send the
entry again on a later sync, and keep the three validation skips
terminal.

A retry under the same Idempotency-Key used to replay the cached
failure, because the 200 carrying `upsert_failed` was cached like any
other. Both the nutrient and the cycle bulk routes now mark that
response `no-store`, as the measurement batch already does for
`failed`, so the retry writes again.

The cycle bulk route already answered its validation refusal with
`cycle.bulk.invalid` and an over-cap batch with `cycle.bulk.too_large`.
Both are now pinned by tests and named in the route's 422 response.
2026-09-25 07:39:07 +02:00
Marc-André Bombeck 702a13873d Merge branch 'main' into release/v1.39.1 2026-09-25 07:21:34 +02:00
Antiheld86 5ec361abec feat(measurements): mirror EXTERNAL in Zod, keep those rows correctable
Adds the `EXTERNAL_SOURCE` leaf beside `scopes.ts` and mirrors the value in
`measurementSourceEnum` so the read shapes and the sync decoder see it. It
stays out of `WRITABLE_MEASUREMENT_SOURCES`: a source a client could assert
would prove nothing about where a reading came from, and that constant is
published to clients as `ingest.writeAllowlist`.

The value-edit gate in `PUT /api/measurements/[id]` needs the other question
answered, though — whether the number is the user's own to fix — so it now
reads a separate `USER_CORRECTABLE_MEASUREMENT_SOURCES`. The 409 that
protects a Withings row exists because the value is the provider's reading;
the hardware behind an ingest token is the user's own scale, and locking
those rows would contradict what the settings card and the Home Assistant
guide promise. That is the only site reading the wider set.
2026-09-25 06:55:17 +02:00
Marc-André Bombeck 237acc0002 fix(backup): export, upload and restore a large account as streams
An account of 1.25 million measurements could not export its data or be
restored from any backup (#1031). Measured in a 1 GB container:

- The full export and the passphrase export built the whole payload and
  its JSON string in memory; the app ran out of heap and restarted.
- The weekly backup's JSON is 662 MB, longer than any string V8 can hold,
  so `unpackBackupBlob` threw on every read. Restore, download and the
  restore preview all answered "could not be decrypted".
- Upload accepted 10 MB, a fraction of a large export.

Every reader and writer of a backup file now streams it:

- `scanBackupJson` reads a backup's bytes once, keeps every section but
  the named bulk arrays, and hands their elements out one at a time.
  `readStreamedBackup` uses it twice: a first pass that validates each
  measurement against the element schema and keeps the rest of the file,
  and a second pass that hands the measurements to the writer in batches.
  Stored copies and off-host objects are authenticated whole on their
  compressed ciphertext before any plaintext is read; only the
  decompression is streamed.
- Restore writes the measurements with one `INSERT … FROM unnest` per
  batch. Prisma caches a query plan per `createMany` row count, and one
  1 000-row plan kept about 145 MB alive, which alone put the restore past
  the heap limit. The transaction budget grows with the measurement count.
  The awards and ECG sections are handed the ids they reference that were
  written, not every written id.
- The export routes write the file into the response as it is produced.
  The passphrase export spools its ciphertext to a temporary file because
  the HLX1 format puts the tag first; the format is unchanged, so existing
  archives open as before.
- Upload accepts the raw body, JSON or gzip, up to the 512 MB request
  ceiling, checks it as it arrives and stores it in the same pass; the admin
  page compresses the file in the browser before sending it. Multipart stays
  for small files.
- Download streams the stored JSON; the preview counts measurements from
  the stream. The monthly restore drill and `scripts/restore-backup.ts`
  read the off-host object the same way; the script can write `.json.gz`.

On the seeded account in a 1 GB container: export 25 s at 389 MB peak,
passphrase export 25 s, both uploads 19 s, restore of the weekly backup
94 s at 554 MB peak, and export, upload and restore round-trip to exactly
the same rows. The encrypted-column guard now also recognises the raw SQL
statement that writes `DataBackup.data`.
2026-09-25 02:05:19 +02:00
Marc-André Bombeck f28153c832 fix(tz): refuse bare UTC offsets as a timezone
The runtime accepts "+05:30" as a zone id, so isValidTimezone let it through,
but Postgres reads a bare offset in AT TIME ZONE with the POSIX sign (east
of UTC is negative), and the day-grain SQL aggregates would cut every day on
the wrong side of UTC.

isValidTimezone now accepts named zones only: whatever the runtime resolves
to a bare offset is refused, while named zones, their aliases, UTC and the
Etc/GMT family stay accepted. Every write that already validated through it
refuses offsets now, and the screener and mood linked-context request schemas
validate their zone too. A value stored before this change can still sit in
the database, so the SQL call sites that read the stored zone directly (the
dashboard summary, achievements, the doctor report, the tiered AI context)
fall back rather than splice it, as the resolver and the day aggregates
already did.
2026-09-24 12:23:00 +02:00
Marc-André Bombeck e89f41710a Merge branch 'feat/v139-targets-and-links' into release/v1.39.0 2026-09-24 09:53:41 +02:00
Marc-André Bombeck b480ac4543 feat(documents): link a document to visits and vaccinations from its own sheet
A document could show its visits read-only and its vaccinations not at
all, so a childhood record covering a dozen doses had to be attached
from a dozen dose forms. The document's detail sheet now lists the
visits and vaccinations it is filed against and, for someone who may
manage the vault, links and unlinks them in one place, with the records
dated near the document on top. Both ends write the same link tables
through the link service, so the dose's form and the document's sheet
cannot disagree about what is filed where.

The document detail response carries the linked doses, named by
catalogue slug or the person's own wording. It is null rather than
empty for a grant that opens the vault but not the health background,
so a shared page never claims to record no doses. The update route
takes a replace-set of vaccination ids, up to 100 so one record can
cover a whole primary schedule; ownership is re-checked, a foreign id
answers 404 and the change lands in the audit log beside the other link
fields. Linking from the dose suggestion now refreshes the sheet.

Refs #1024, #1005
2026-09-24 09:39:27 +02:00
Marc-André Bombeck f158f01f18 Merge branch 'feat/ai-extraction-routes' into feat/ai-jobs-stored-text
Brings in the extraction routes and the capability re-check at the wire
(`aiEgressRefusal`), so the jobs and stored-text work can share it.
Conflicts in the document routes, the fenced chat, the auto-read toggle
and its contract text resolve to the extraction branch: those files no
longer call the per-pick document consent helper.
2026-09-24 09:27:43 +02:00
Marc-André Bombeck 8325f3964d feat(consent): add the extraction consent kind and purge on withdrawal
A new receipt kind, ai_extraction, covers reading documents, lab report
scans and typed medication text with an external model. Switching the
document auto-read on now mints ai_extraction instead of ai_full, so it
no longer grants the Coach or the analysis as a side effect. Existing
ai_full receipts keep covering everything.

DELETE /api/consent/ai/latest now runs through withdrawConsent: the
revoke and, when it leaves no receipt covering the analysis, the
deletion of the text a model wrote under it commit in one transaction.
That is the status notes, the cached briefing, model-written period
narratives, reaction lines and workout paragraphs. Coach conversations,
facts and plans and document summaries stay. The purge is audit-logged
as consent.ai.purge with counts only.

Refs #975
2026-09-24 09:05:09 +02:00
Marc-André Bombeck bd8ea9d86b feat(ai): gate document, lab and medication extraction on their capabilities
Reading a stored document (suggest, summary, extract, index, chat,
reindex), scanning a lab report and turning medication text into a
schedule now answer under documentAi, labsOcr and medicationExtract. The
operator's master switch and the new reading-documents switch stop the
work and the egress; a module off for the record, a delegate inside
somebody else's record, and a missing receipt refuse with the capability
envelope. Existing no-provider codes keep their status.

The document pick and the labs pick re-check the capability for the
provider they picked, immediately before anything is sent, so a job
enqueued before a switch flipped finds no provider and falls back to the
provider-free text layer. Consent follows the document rule for all
three: any provider that leaves the machine needs an ai_extraction or
ai_full receipt, answered for the provider actually picked.

The fenced Coach chat and attachments answer under coach and documentAi.
Uploads are always stored and indexed; the background summary is only
enqueued while documentAi is open. The capability probes report the
capability beside the provider answer and keep a read offered when only
the receipt is missing. medications/extract keeps its 503 for no provider
and adds ai.provider.none, and its budget refusal gains the
coach.budget.exceeded code.
2026-09-24 08:44:57 +02:00
Marc-André Bombeck a7888a9011 feat(ai): replace the Correlations switch with Reading documents
The operator switches become master, Coach, Daily Briefing, Status
notes and Reading documents. Correlations goes: it gated statistics,
not anything a model writes. Reading documents covers the document
vault, lab scans and medication extraction.

The Coach module's operator availability now follows the Coach
switch, so there is one place to turn the Coach off server-wide. The
migration folds a stored Coach availability of false into the switch,
and gives everyone who hid the Coach the AI analysis opt-out, since
hiding the Coach no longer stops nightly briefing and status notes.

A switch read that fails now fails closed. The insights module is
described as AI analysis in every locale, and the switch guard now
keeps both retired switches out and freezes the callers of the old
operator-only gate.
2026-09-24 07:36:47 +02:00
muhdusama c223b6d77d fix: scope Apple Health schedule exemption 2026-09-20 20:46:47 +10:00
muhdusama 30e454fb83 fix: allow scheduled Apple Health mirrors 2026-09-20 00:26:05 +10:00
Marc-André Bombeck bff083f532 fix(cycle): accept a symptom logged without an intensity
Picking a symptom without tapping an intensity is how most of them are
logged. The sheet sends severity null for those, the schema took a number
or nothing at all, and the day log came back 422 with two issues for two
symptoms. All the sheet could say was "Could not save. Try again.", and
the cycle saved fine as soon as the symptoms were removed, which sent
people looking in the wrong place.

Nothing else disagreed: the link row has always stored an unrated symptom
as a plain presence, and the writer already maps a non-number to NULL.
Only the gate at the door refused it, on both the create and the patch
path, and no test touched either schema.

Refs #1001
2026-09-18 18:51:38 +02:00
Marc-André Bombeck fafb5cdc08 feat(notifications): add a Gotify payload format to the webhook
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras.

Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does.

The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter.

Refs #947
2026-09-13 14:19:48 +02:00
Marc-André Bombeck 8ed46eef58 Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard 2026-09-10 20:53:38 +02:00
Marc-André Bombeck 94bed12edc fix(egress): keep loopback grantable under an exact operator origin
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.

::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.

Refs #947
2026-09-10 19:08:04 +02:00
Marc-André Bombeck a2e407909c Never derive answers given for somebody else onto the guardian
"Finish without the profile" completed the flow with no record id, and
the route then derived the child's answers onto the guardian's own
record. The server now decides: answers given for somebody else with no
record to apply them to complete and latch the flow without deriving or
seeding anything, whatever the client sent. The checklist carries the
profile instead and reads none of those answers as the guardian's own.

A record id the caller does not guard answers 404 like every
id-addressed managed-profile route, one answer for missing, not managed
and not theirs alike; the contract documents the field, the 404 and the
422 text. Pinned by two integration cases and a second child journey.
2026-09-10 18:58:21 +02:00
Marc-André Bombeck 9c306f04e7 fix(notifications): name the never-grantable refusal and redact the grant log
A loopback, link-local or metadata target answered
private_origin_not_approved with a message telling the user to have the
operator list it, and the parser refuses such an entry forever: a dead
end the route test even pinned. Those targets now answer
private_origin_not_grantable (catalogued, on all four routes, translated
in seven locales) with a message that says no grant can open them and
to use the relay's LAN address. Both senders carry the reason into the
outcome so the test button forwards the same code.

The malformed-entry warning printed the entry verbatim, which is where
a pasted token or userinfo sits; the parser now hands callers scheme
and host only, plus the reason in words. The Nightscout list throws a
named error with that redacted entry and reason, and the connect route
answers it as a 422 with the message instead of a bare 500, so a
self-host that listed http://localhost:1337 reads what to change.

The two settings cards translate a coded save refusal like the test
button does; the docs and the changelog name the sub-host and sibling-
port narrowing; the dispatcher floor tests assert the policy marker so
a real NXDOMAIN cannot pass for a refusal.

Refs #947
2026-09-10 18:45:57 +02:00
Marc-André Bombeck 6adbee581e Apply the review of the setup flow: the child's answers reach the child
"Someone I look after" derived the child's answers onto the guardian's
own record. The completion now takes the managed record the profile
step just created, checks the same access rule the guardian's modules
route applies, and lands the same derivation and dashboard seed on that
record through the same record-keyed write; the guardian's own flow is
stamped complete and latched without touching its map. Three
integration cases pin it, and the child journey reads the child's map
through a real switch — it asserted the guardian's map before, which
pinned the wrong record.

Also from the review: the checklist's visit row read a meta the visits
list never publishes and now counts its body; the first-result tile
read the measurements list as an array and now shows the reading; the
demo allowlist admits the disclaimer and tour writes, with a structural
test over every onboarding route file; restart clears the old first
result, and the screen trusts the ledger before painting a result as
done; the headroom sweep's raw-key check matches the flow's own key
shapes; the demo migration script stops copying the dropped column; the
questions-to-pass rule and the shell's onboarding-path rule are pure and
pinned; the baseline form composes the shared action row; the schema
diff loses the unrelated formatting churn.
2026-09-10 18:19:34 +02:00
Marc-André Bombeck 71175290af Merge remote-tracking branch 'origin/main' into fix/private-notification-origins
# Conflicts:
#	src/app/api/settings/ntfy/route.ts
#	src/app/api/settings/webhook/route.ts
2026-09-10 18:07:55 +02:00
Marc-André Bombeck 984a75d036 fix(api): name the private-origin refusal on webhook and ntfy routes
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
2026-09-10 16:46:13 +02:00
Marc-André Bombeck ea3a8deed2 fix(egress): share the origin-grant grammar and floor its ranges
Lift the Nightscout allowlist parser and verdict into
src/lib/private-origin-policy.ts so a second operator grant can reuse
one grammar instead of a second parser. The Nightscout validator keeps
its exported names and behaviour; its tests are unchanged.

Add the floor both precedents lacked: an entry whose host is a literal
address in 127/8, 0/8, 169.254/16, ::, ::1 or fe80::/10 is not a grant,
and the operator-approved pinned dispatcher drops an answer in those
ranges even when the name is listed. RFC1918, CGNAT and IPv6 unique-
local stay grantable; that is what the list is for. Loopback inside the
container is the app itself, and on host networking every admin port of
the host; link-local is the cloud-metadata endpoint. No relay lives on
either.

The pinned Nightscout transport test resolves its listed name to a
loopback server on purpose, so it stubs the grantable-address predicate
for that harness only.

Refs #947
2026-09-10 16:34:04 +02:00
Marc-André Bombeck a062610895 Retire the five-step wizard's route, goals and columns
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.

The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
2026-09-10 15:32:35 +02:00
Marc-André Bombeck 3f44b882ac feat(backup): carry the setup answers out of the account and back in
Nothing recomputes an answer. The module map they produced survives on the
account row, but the map says what is switched on, not that the person told the
flow they take medication daily and have a visit next month — so a restore
without the row opens a blank questionnaire for somebody who already answered
it, and leaves the checklist ordering itself from nothing.

Both ends live in one file, the arrangement the visits and the score already
use, because a reader who greps only the restore route gets a false negative
when the route delegates.

`modulesDerivedAt` is the field that makes this more than history and it is
asserted rather than defaulted: it is the once-only latch on the module
derivation, so a restore that let it default to null would hand the next confirm
permission to re-apply the questionnaire over every module decision taken since,
silently. The round trip seeds it non-null and reads it back.

The file's schema validates the three answer objects loosely and the restore
narrows them through the same fail-soft readers the account payload uses. Being
stricter there would refuse a whole account's restore over one retired chip.

The wipe takes the row for the reason the reset already takes the wizard's own
columns: the record the answers were about is gone, so the answers describe
nothing.
2026-09-10 11:03:56 +02:00
Marc-André Bombeck cc98c56172 feat(onboarding): store what a record needs, and map it to modules
The setup questionnaire the design spec asks for needs somewhere to put its
answers. `OnboardingRecord` is that place: one row per RECORD rather than per
person, so a guardian running a managed profile's setup writes the child's row
and not their own, and the three JSON columns hold the answers, the nine
ordered steps with their status, and the one task the flow ends on.

Nothing here is encrypted, and the schema comment says why: the columns hold
closed enum vocabularies, not free text, which is the same class of value as
the module map and the goal slugs sitting in plaintext beside them.

The needs to modules map lives next to the module keys rather than in the flow,
because a module renamed or retired has to break the map at compile time rather
than leave a chip that quietly changes nothing. The derivation is total — every
directly-owned key gets an explicit value — and the merge beside it is
one-directional: what the answers name is switched on, what they do not name is
switched off only where the record does not already carry a deliberate on. The
two delegated keys are absent by construction: `cycle` is reported separately
because its real column is on the cycle profile, and the Coach is not offered by
this flow at all.

Readers are fail-soft. They sit on the payload that rides every app boot, and a
value written by a release that named an area this one retired must not take the
shell down with it.

Nothing is retired: the four-step wizard, its goal slugs and the one-time tile
seed keep working and keep their meaning until the new surface replaces them.
The two states are disjoint — the slugs decide tile order, these answers decide
which modules are on, and neither reads the other.
2026-09-10 11:03:24 +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 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 f521b03c35 feat: let a guardian edit a managed record, and record its sex
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
2026-09-10 00:01:50 +02:00
Marc-André Bombeck 5078867203 feat: let an account choose the unit its glucose is shown in
`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.
2026-09-03 06:05:58 +02:00
Marc-André Bombeck 963f61cf0f Drop a reference window whose floor sits above its ceiling
A lab report's reference range reaches the record by four paths. Three of
them validate that the floor does not exceed the ceiling; the document
path did not, and the catalog store writes what it is handed. An
integration run against a real Postgres confirmed the consequence: a
100/30 pair landed on the row and on the freshly minted biomarker, so
every later reading of that analyte inherited a window that cannot be
true and a high value came back reassuring.

Dropping beats swapping. A transposed pair says the transcription is
wrong, not which of the two numbers is, and inventing an order would
state something the report never printed. The verbatim string survives
either way, so nothing the document actually said is lost.

Three points, answering according to what each one is:
- the extraction schema drops the pair, because every field there is
  catch-guarded so one bad value cannot sink a 120-fact document
- the review-screen edit refuses with the wording the lab schemas
  already use, because a person can see both numbers and fix them
- the commit path drops it as the last gate, which is what covers facts
  staged before this rule existed and confirmed afterwards

parseReferenceRange has answered a transposed PRINTED window this way
since it was written, and the commit path already counts a window it
could not read at all. This extends both to the case that was readable
and cannot be true.
2026-08-31 19:38:08 +02:00
Antiheld86 cba7a0fce5 Add a self-service measurements:write API token scope
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.
2026-08-31 08:46:16 +00:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
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.
2026-08-26 16:42:45 +09:00
Marc-André Bombeck fb344c4f78 Remove two dead alias constants
SENSITIVE_LEAVES and COACH_SUGGESTED_ACTION_TYPES each aliased a
constant defined elsewhere and were never imported. Neither is a
re-export the way the barrel lines were — the definition itself is the
dead thing, and both doc comments describe a consumer that does not
exist: the purity guard reads SENSITIVE_LEAF_IDS from ./catalogue
directly, and nothing mirrors the suggested-action allowlist.

The imports that fed them go with them.
2026-08-23 19:11:12 +02:00
Marc-André Bombeck 0e9c5f1a00 Drop unimported re-exports outside the barrels
Eighteen modules carried a convenience re-export of a symbol defined
elsewhere. In each case the callers the comment describes now import the
concrete module directly, so the line had no importer left. Where the
whole re-export block went, the comment explaining it went with it —
doctor-report-pdf-core, trend-card and medication-extract-prompt each
claimed to be a single import surface that nothing was using.

Definitions are untouched; imports that existed only to feed a removed
re-export go too.
2026-08-23 19:11:12 +02:00