Commit Graph
108 Commits
Author SHA1 Message Date
Marc-André Bombeck bcb31c80db Merge the release with the security review fixes into the data-safety review fixes 2026-10-25 18:36:54 +01:00
Marc-André Bombeck d2b8da7839 refactor: keep the size, cast and error-code guards where they were
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
2026-10-25 18:06:32 +01:00
Marc-André Bombeck d3257a1b6b fix(documents): tell people when the document reader is busy, and attribute its work
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck 9db5e755e6 Merge the release into the deletion grace work
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
2026-10-25 15:18:46 +01:00
Marc-André Bombeck da9d941a24 Merge the release with the security fixes into the backup health work 2026-10-25 15:03:17 +01:00
Marc-André Bombeck 77ace106b3 fix(http): refuse state changes a browser sends from another site
The session cookie is SameSite=Lax, and SameSite compares the registrable
domain, so a page on a sibling subdomain of the instance could send a
CORS-simple POST with the person's cookie attached. safeJson accepted any
Content-Type containing "application/json", so a text/plain body that
mentions it in a parameter, which needs no preflight, was parsed as the
person's own request: readings added, entries deleted, integrations
disconnected, a delegate invited within the re-proof window.

apiHandler now refuses a POST, PUT, PATCH or DELETE before authentication
when the browser marks it Sec-Fetch-Site same-site or cross-site, or, from
a browser that sends no fetch metadata, when its Origin names a host other
than the one the request went to or APP_URL. A request with an
Authorization header, or with neither header (the phone app, scripts,
webhook senders), is unaffected, as are the webhook and URL-credential
routes that authenticate with their own secret. The JSON media type is
compared exactly, with parameters allowed, in safeJson and in every route
that branched on a substring of it.
2026-10-25 13:52:04 +01:00
Marc-André Bombeck a69666fa16 feat(account): hold account deletion and data wipe for a grace window
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:

- Account and managed-profile deletion lock the account (migration 0488:
  deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
  Every session, refresh token, API token, connector, share link and
  step-up elevation is revoked in the same transaction, and the record,
  its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
  erasure date, after the credential was proved and before a second
  factor is asked for. The session and Bearer resolvers and the acting-
  account resolver refuse a pending account too, so a credential minted
  in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
  clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
  ended, exactly as the immediate deletion did, with the off-host purge
  request committed alongside. The Guardian invariant is decided again;
  a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
  the writer the weekly copy uses, keeps it out of the wipe, and holds
  its off-host purge back with a new not_before on the request. The
  ordinary restore brings the record back; restoring that copy withdraws
  the held purge. The purge job deletes the copy when its window ends.

Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
2026-10-25 13:50:14 +01:00
Marc-André Bombeck d3c672cc3f feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:

- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
  (innerKeyIds) or an off-host object inside the bucket's retention
  (OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
  named by the id their objects record;
- API_TOKEN_HMAC_KEY.

Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.

Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
2026-10-25 13:32:29 +01:00
Marc-André Bombeck 275367f6e9 feat(medications): end a course now through its own action
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.

End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.

The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.

Refs #1280
2026-10-24 22:24:15 +02:00
Marc-André Bombeck 4752115d7d feat(documents): add Recently deleted for deleted documents
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.

GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.

The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.

Refs #1275
2026-10-24 20:51:35 +02:00
Marc-André Bombeck 5a7bd95c7b fix(documents): read a long document through the lab scan's read core
Extracting lab values from a stored document still sent the whole text
in one call. A multi-page report produced more JSON than one call's
output ceiling, the answer was cut mid-fact, the parse failed twice, and
the read came back as 422 "Couldn't read the document. Try a clearer
copy." The lab scan had already learned to read the same report a page
or a part at a time, but each surface carried its own copy of the read,
so the document path never got it. It also ran under the shared 60 s
timeout, dropped every fact past 120 (or every fact when one did not
match the schema), and charged a flat reservation.

The read now lives once, in lib/documents/ocr-read.ts and ocr-plan.ts:
the per-read limits, the corrective retry, the salvage of a cut
answer's complete rows, the wire re-check and budget reservation before
each read, and the planning of a PDF a page at a time and text in parts
at line boundaries. The lab scan and the document extraction (the
extract route, its background run and the lab auto-staging) both read
through it, and the background run plans its reads on the same deadline
as a lab scan. The per-read budget moves to a shared metered budget.

The document path validates the answer fact by fact, keeps the lab
scan's row cap (400), and says why nothing came back: 422
documents.inbound.outputLimit when the reads stopped at the output
limit, 429 when the budget refused them, extractFailed only for answers
that were not the schema. A run whose reads were cut or not reached
reports `incomplete` on its result.

A guard holds the class: the output ceiling is set only in the core,
both surfaces read through runOcrReads, both runs share the deadline,
and a lab read and a document read go out under the same limits.

Refs #1257
2026-10-24 10:34:31 +02:00
Marc-André Bombeck bfa6873d96 feat(tokens): add a per-token Apple Health setting
PATCH /api/tokens/{id} sets mirrorToAppleHealth on a measurement token:
cookie session only, the caller's own tokens only, revoked ones included,
any other token refused with tokens.mirror.not_measurement_token. It
stamps mirrorChangedAt and audits only when the value changes. The mint
accepts the setting (off by default), the token list returns it, and
GET /api/tokens/measurements counts the EXTERNAL readings no token
reaches.
2026-10-23 19:28:05 +02:00
Marc-André Bombeck 0182f91092 Revert "feat(topics): compute topic overviews and topic pages on the server"
This reverts commit cc04e0944d.
2026-10-23 10:46:55 +02:00
Marc-André Bombeck cc04e0944d feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).

Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.

The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
2026-10-23 08:18:43 +02:00
Marc-André Bombeck a84fb4b467 feat(import): import lab results and medications from CSV
Two streamed endpoints read a spreadsheet record by record with a
column mapping from the query: separator, decimal mark, date order and
zone are stated, never guessed per row. Lab rows resolve to markers the
way scanned reports do, convert units where the conversion is known and
keep what the file said; medication rows map name, dose, daily times,
as-needed and a dated window, checked against the create schema, with
reminders off unless asked for. A dry run writes nothing, a re-upload
only finds duplicates, and every refused row comes back with its line
and reason.
2026-10-21 22:17:59 +02:00
Marc-André Bombeck 5eb7caebae feat(activity): add an activity calendar with personal baseline and goals
A week or month of steps, active minutes, workouts by sport and training
load, each day placed against the person's own spread over the 84 days
before the period and each period against the median of the twelve weeks
or six months before it. Optional weekly and monthly goals live on the
account (activity_goals_json), travel with the backup and are cleared by
a data wipe. The calendar sits on the workouts page; the steps page links
to it. Computed server-side without AI.
2026-10-21 21:38:32 +02:00
Marc-André Bombeck eaaff23858 Merge branch 'fix/v14311-integ' into release/v1.43.11 2026-10-21 14:13:37 +02:00
Marc-André Bombeck ba1271275f feat(fitbit): stop at the Web API turndown and point to Google Health
Google turns the legacy Fitbit Web API off on 30 October 2026 (support
ended on 30 September). From that day the hourly poll, the backfill,
the sleep repair and the token refresh make no call, the connect, sync
and test routes refuse with `provider_retired`, and the connection
reads as not connected to the health read and the watchdog, so the
shutdown pages nobody and asks nobody to reconnect. The connection row
and everything it imported stay.

The status envelope publishes `retirement` on the Fitbit entry (the
day, whether it has come, the successor), and the Fitbit card shows
when the connection stops, how to move to Google Health (same devices,
one more consent, tokens cannot be carried over) and that the data
stays; sync and connect grey out after the day. Both integrations can
run side by side meanwhile: every type Fitbit writes is laddered with
Fitbit just above Google Health, so a day both deliver counts once.

The test setups pin the turndown off, so the Fitbit suites do not turn
red on the day; the turndown's own tests flip it.
2026-10-21 12:46:14 +02:00
Marc-André Bombeck 79d905f089 fix(labs): reserve and re-check each page of a scan, and read digits from the text layer
The route reserved one read's budget and checked the wire once, and a
20-page report then ran up to 40 calls for many minutes on that single
admission. Every read after the first now reserves its own share of the
day's budget and asks the wire again (aiEgressRefusal) before it is sent;
when either refuses, the scan stops there, keeps the pages already read
and names the rest with stoppedBy budget or unavailable. The run settles
against everything it reserved, with the provider's reported usage net of
its cached share, and a scan that fails after several calls is charged
for them instead of refunded in full.

A rendered PDF page now goes out with its own text layer, read by pdfjs
beside the image, so a digitally produced report's digits, decimal
separators and units come from its characters rather than from a 1600 px
picture. Splitting the PDF into single-page documents would need a PDF
writer the project does not ship; the text layer needs none and helps
every provider. A scanned page has no text layer and reads as before.

Codex's cut reply keeps the text it streamed (on a non-enumerable
property, so no log or event serialises it), and its closed rows are
salvaged like every other provider's. The merge folds a reading only into
one an earlier page listed, never two rows of the same page, and the
printed range is part of the key. OCR'd text the deadline cannot split
small enough for one read is refused up front as labs.ocr.tooLarge.
2026-10-21 11:10:02 +02:00
Marc-André Bombeck 10cfdf8b66 fix(labs): read a lab report page by page and keep a cut-off read's rows
A scan sent the whole report to one call under an 8000-token ceiling and
validated the reply against a 100-row envelope. A long multi-panel report
lost everything either way: the reply stopped mid-row and failed to parse,
which the person saw as "try a clearer photo", or it held more than 100
rows and the schema refused all of them. One malformed row refused the lot
as well. Reasoning models made it worse, because the call sent no
reasoning setting and OpenAI, Codex and Claude's 5 generation reason by
default against the same output allowance.

A PDF of more than one page is now rendered and read a page per call on
every provider (up to 20 pages), as many reads as the run's deadline
allows, and the pages are merged in order with a reading printed twice
listed once. A photo, a one-page PDF on a provider that reads PDFs, and
OCR'd text up to 4000 characters stay one read; longer text is split at
line boundaries. Each read carries an explicit reasoning off, so a model
that can stop reasoning does and one that cannot gets its minimum budget
on top of the answer's. A reply cut at the output limit is detected from
the finish reason (Codex's incomplete stream included), keeps the rows
that closed before the cut and is named in the result instead of retried;
when no read produced a row the run fails as labs.ocr.outputLimit. A
provider failure after the first page keeps the pages already read.
Rows are validated one by one, the row cap is 400 for the scan and the
commit, and a report past it keeps its first 400 and says how many it
had. The run settles what the provider reported it spent rather than the
fixed reservation, so a page-by-page read still counts against the day's
budget in full.
2026-10-21 10:40:45 +02:00
Marc-André Bombeck 8db7c71b0e fix(api): one helper for list pages, exact cut signal, total order
listPage (src/lib/http/list-query.ts) reads one row past the limit and
cuts it back, on top of the existing list-cap signal rather than beside
it. Every capped list now gets its page there, and every read passed to
it orders by something ending in the row id, so rows that share a sort
value cannot repeat or vanish at a page boundary.

- practitioners, vaccinations and encounters were cut at their take
  with no signal; they now answer meta.truncated and accept offset, and
  their web readers walk the pages instead of stopping at the first
- symptoms/events reported hasMore on a page that was exactly full; it
  is now true only when a row past the page exists
- the document list's hand-rolled limit + 1 goes through the helper
- security-activity answers an out-of-range limit with the 422 its
  published schema already promised instead of clamping it

list-page-guard binds every apiListSuccess route to listPage, forbids
hand-cut lists and length-derived hasMore, and requires the id
tiebreaker in every listPage read.
2026-10-20 13:29:14 +02:00
Marc-André Bombeck c300574a8e fix(api): read the error code before the status
A read-failure primitive decided from the status alone, so a 401 from a
step-up or re-proof gate would have read as an ended session and sent
the reader to sign-in. The code now decides first and the status is the
fallback for codes it does not know.

The two invitation routes answer an unknown name or e-mail with
sharing.invite.no_account. Both have a second 404 with a different next
step, and since every error now carries a code the clients match on it
instead of on the status. The status defaults move into a module the
client can import, and the contract text no longer says these arms
carry no code.
2026-10-20 09:48:15 +02:00
Marc-André Bombeck 0057b03699 refactor(openapi): derive the http code family from the status table
The catalogue listed the status defaults a second time by hand. It now
reads them from DEFAULT_ERROR_CODES, the table the handler applies.
2026-10-20 09:39:50 +02:00
Marc-André Bombeck fbbb535832 feat(api): give every error envelope a meta.errorCode
Roughly half of the refusals answered prose alone, so a client could branch
on the code for one 404 and had to fall back to the status for the next.
apiHandler now completes every error envelope with the status's generic
http.* code when the route named none; a route's own code still wins. The
family joins the published catalogue, and the profile form only shows a
code's sentence when the code has one.
2026-10-20 05:53:37 +02:00
Marc-André Bombeck c7f69fc212 fix(auth): answer 503 when the session store cannot be read
The cookie resolvers folded a failed query into null, so a database restart
answered 401 and every client discarded a good credential. The lookup now
raises SessionStoreUnavailableError, which reaches the wire as 503 with
auth.session.unavailable. Logout still clears the cookie on a failed lookup
and records a warning.
2026-10-20 05:43:58 +02:00
Marc-André Bombeck e2b7e7350e fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
2026-10-16 17:11:04 +02:00
Marc-André Bombeck 4daad8f2fb Merge branch 'v143/life' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-16 03:39:40 +02:00
Marc-André Bombeck da860aeb3e Merge branch 'v143/dawarich' into release/v1.43.0
# Conflicts:
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-16 02:51:02 +02:00
Marc-André Bombeck b838ed4403 feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their
stubs. A nightly job (03:20) and the connect backfill read the timeline,
visits and residency endpoints through safeFetch and store one row of daily
values per local day: distances, places visited, minutes away from the home
place and the sealed country with its travel-day verdict. A private instance
needs its exact origin in DAWARICH_PRIVATE_ORIGINS.

The connection joins the shared ledger and the connection health model,
watched for errors only. The day carries a movement block, the model
projection drops the travel country, the two movement channels join the
correlation matrix under the environment module, travel days become trip
suggestions, and the days travel in the environment backup section.
2026-10-16 01:20:53 +02:00
Marc-André Bombeck f2be772eaf feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists 2026-10-16 00:54:38 +02:00
Marc-André Bombeck 877920f5f7 feat(pulse): weigh sparse hours in the pulse day mean by the stored method 2026-10-16 00:49:41 +02:00
Marc-André Bombeck 6146082824 Merge branch 'v143/supplements' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/lib/ai/capabilities/types.ts
#	src/lib/export/backup-plan.ts
2026-10-16 00:28:29 +02:00
Marc-André Bombeck 5c039a220b Merge branch 'v143/sso' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-15 23:59:03 +02:00
Marc-André Bombeck eb3cc4237f feat(medications): store a supplement's composition, read its label with the document AI, and carry it through backup and restore 2026-10-15 23:29:40 +02:00
Marc-André Bombeck 9eae352bb1 feat(claim): hand over a managed profile through single sign-on
POST /api/auth/claim/sso checks the handover link and the username and seals
the link id, the username and a per-start binding into an hl_claim cookie.
GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted
OIDC state, and the callback binds the provider subject to the profile in the
claim transaction, refusing a subject or verified email another account
holds, a link that is no longer usable, and a live session.

The handover status and the claim preview name the redemption methods; a
single-sign-on-only server hands over through the identity provider, and only
the password claim stays refused there.
2026-10-15 23:12:59 +02:00
Marc-André Bombeck fd44a8381b Merge branch 'v143/ai' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-15 23:12:51 +02:00
Marc-André Bombeck 6f673a7b84 feat(ai): resolve an AI usage level per record and publish it as ai.usage
Three levels (frugal, standard, full) with an operator ceiling and default
and a per-account choice within them, resolved in the capability layer for
the record and published on GET /api/auth/me as ai.usage: the level in
effect, the stored choice, the bounds, why (user, default, admin_cap), the
three options with whether each is allowed and an estimate of tokens per
day scaled from the record's last seven full days of coach_usage, and the
measured mean. A delegate sees the level, never the owner's spend.

PUT /api/auth/me/ai-usage stores the account's own choice or null for the
default, refusing a level above the ceiling with 422 ai.usage.aboveCeiling
and meta.maxLevel. The new supplementLabel capability needs standard or
above; below it the capability is unavailable with the new usage_level
reason, refused with 403 ai.usage.levelTooLow and meta.requiredLevel.

Refs #1217
2026-10-15 22:49:01 +02:00
Marc-André Bombeck 81be73fca8 feat(labs): merge markers, keep aliases and read lab series per marker
POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
2026-10-15 22:22:40 +02:00
Marc-André Bombeck 3786deb503 feat(api): publish the v1.43 contract with routes that answer 501 until filled
Every new endpoint of the contract exists, wrapped in apiHandler, with its
authentication, module gate and request validation, and answers
501 not_implemented until its behaviour lands. The OpenAPI document carries
the new operations and schemas and the additive response fields on the
changed routes. The four server feature flags are published as false.

contract-stub-inventory.test.ts registers the stubbed routes; each one
leaves the register with the change that fills it. The route, sharing,
session, security, module-gate and outcome inventories name the new routes
deliberately, and the error-code catalogue lists the codes the routes emit
today.
2026-10-15 21:49:02 +02:00
Marc-André Bombeck 334cb4e979 Merge branch 'v1422/search' into release/v1.42.2 2026-10-11 13:06:07 +02:00
Marc-André Bombeck 274cf4900e test(notifications): pin the content preference in backups and the action error codes 2026-10-11 12:46:12 +02:00
Marc-André Bombeck 1766ffa3d2 feat(search): search the record from one endpoint 2026-10-11 12:27:28 +02:00
Marc-André Bombeck 46047e57c0 fix(google-health): keep long backfills out of reauth parking and settle overlapping sleep segments
A history backfill carried one access token through walks that outlive it.
After an hour every request answered 401, which classifies as a revoked
grant, so the account was parked at error_reauth with a refresh token that
was still valid, and every later run skipped it until a manual reconnect.
Running beside the hourly sync, the backfill also pushed the account over
Google's per-minute quota, and each 429 failed its whole collection.

- Walks take a token source that renews the access token before it expires
  and once more when Google refuses it mid-walk.
- A 429 is waited out (Retry-After, RetryInfo, else a doubling backoff) and
  the request repeated; it is never a reauth.
- error_reauth only short-circuits a run when the token endpoint refused the
  grant (needsReauth). A connection parked by a data request's 401 refreshes
  on its next run and clears itself when that run completes.
- One run per account at a time: syncs, the backfill and the sleep repair
  share an advisory lock. The hourly poll skips a held account, a manual
  sync answers 409 sync_in_progress, the backfill waits a bounded time.
- A backfill attempt records each collection it finishes cleanly
  (backfill_progress) and the next attempt resumes with the rest.
- The incremental watermark is stamped with the cycle's start, not its end.

Overlapping sleep segments that share a stage and an end share the natural
key (type, measuredAt, source, sleepStage). A re-score that moved one row
onto a sibling's slot threw P2002 and failed the sync on every run.

- A batch keeps one reading per natural key, chosen the same way each time
  (longer segment, then smaller externalId).
- A move that still collides is settled by reconcileExternalMeasurement,
  which retires the stale row, instead of failing the sync.
- Updates run before inserts, and the key-format rescue no longer re-keys a
  row this batch is moving.
- The sleep replace-by-window protects every session's fresh rows, so two
  overlapping sessions no longer tombstone and resurrect each other hourly.

Refs #1194, #1195
2026-10-10 12:59:25 +02:00
Marc-André Bombeck 509166afc4 fix(environment): keep one account from draining the Open-Meteo budget
The backfill route shared the analytics-read bucket and sent every
explicit range without a key, so one account could queue two-year
backfills until the instance-wide daily budget was gone for everyone.

The route now allows three backfills a rolling hour per account (429
environment.backfill_rate_limited) and one queued backfill per
twenty-minute slot, keyed on the account (409
environment.backfill_pending). Every fetch made for an account is also
charged to that account's own share of the daily budget, one twentieth
of it, so a refused request ends the run as budget-blocked and the
nightly run continues.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 0924285cd6 feat: serve the day, its index, the timeline and life events
Replace the day, timeline and life-event stubs with their implementations
(#613) and drop the not-implemented helper, its error code and its OpenAPI
response with the last stub.

loadDay reads one local day for every reader: the readings live inside the
record's own day window (never the UTC rollups), through the canonical-source
engine the mood sheet already uses (resolveDayReadings in linked-context.ts),
with the person's usual range over the 30 days before; the record sections
from their own tables, each in the storage form it speaks (an instant in the
local window, a date string with its own zone, a calendar date). A switched-off
module's section is not read; a section the grant does not cover is named
not_shared and not read.

The day, the day index and the new visit preparation (GET /api/day/notable)
are admitted on the readings and narrowed per section through
actingDomainVisibility, so a scoped grant sees exactly its sections. The
timeline and its readiness stay whole-record reads and narrow their lanes the
same way.

Notable days are deterministic keys: a three-month high or low with history
behind it, a first reading, and in the visit preparation a regular reading
that stopped. The timeline lays out spans and points from the owning tables,
standing items without a date, and series by zoom: months from the rollup
tier inside its five-year window and from one cached live query before it.
The readiness inventory rates each lane with one link per gap.

Life events are created, edited and soft-deleted as MANAGE verbs with audit
rows, encrypted at rest, and join the sharing guard and the handler matrix.
get_day reads a day for the Coach and the MCP endpoint without life events,
notes or document names; its step carries the date it read.
2026-10-08 14:31:13 +02:00
Marc-André Bombeck b8f6372963 Merge the day and timeline contract into release/v1.42.0
# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-08 13:13:49 +02:00
Marc-André Bombeck 9d4a50282e Merge tickets part 2 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 13:07:02 +02:00
Marc-André Bombeck b299904552 Let a record define its own vaccines
A dose can now name a vaccine the catalogue does not list. The person
defines it once (a name, the diseases it protects against from the
catalogue's antigen list, the series length and the booster interval) and
logs doses against it; such a dose counts into each listed antigen's
series, settles a booster reminder keyed to one of them and offers a
booster, the way a catalogue combination does.

One resolver (resolve-vaccine-entry.ts) now answers for every consumer
that asked the catalogue directly: the series, the satisfy matcher, the
booster mint, the dose DTO, the doctor report, document links, the
upload suggestion, MCP search and the client's info sentences and
booster offer. The catalogue wins when a dose carries both.

The custom routes resolve the record through requireRecordAuth (list at
READ, create at WRITE with the actor-keyed write bucket, edit and remove
at MANAGE with the C4 overwrite audit) and join the sharing guard, the
MANAGE matrix and the activity feed. Removing a definition tombstones it
and lets go of its doses, each keeping a name. Definitions travel in the
backup and restore before the doses that name them; the dose form picks
them, adds one without leaving the form, and the page lists them for
editing. With the last contract stub implemented, the 501 helper and the
not_implemented code leave the tree.
2026-10-08 13:00:37 +02:00
Marc-André Bombeck 7eb5659ab9 Merge iOS server asks and client work into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/auth.ts
2026-10-08 11:27:42 +02:00
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00