Commit Graph
571 Commits
Author SHA1 Message Date
Marc-André Bombeck edf6fffc32 test: admit the per-call reasoning level in the reasoning_effort guard
The key now appears twice in each client: the bound chain setting, sent only when the call carries no level, and the call's resolved level. The guard pins both, the condition in front of the bound value, and the absence of a literal.
2026-10-06 19:26:30 +02:00
Marc-André Bombeck 210becb28b feat(coach): lay down the reasoning, trail and memory contract
Add the shared contract the reasoning release builds on, with no change
to how a turn runs today.

- Schema: operator reasoning controls on app_settings (0376), the
  encrypted trail text on coach_messages (0377), and source,
  last_used_at and source_message_id on coach_facts (0378).
- Provider contract: CompletionParams.reasoning and onReasoning,
  CompletionResult.reasoning and providerState, AiMessage.providerState.
- Reasoning levels, thinking budgets and the background-job table in
  src/lib/ai/reasoning/levels.ts; coachPrefsJson.reasoning, absent
  meaning medium, salvaged field by field like every other pref.
- Stream: activity, memoryNote and planProposal frames, done.stop and
  done.withheldResults, result.interim, a compare chart kind, eight
  table refs, three more clarification kinds with an assumed choice,
  the change_assumption chip, and assumptions, activity, stop,
  memoryNote and planProposal on the provenance. Zod mirror, type
  parity and the native-client key rule cover every new frame.
- Chat request: memoryDecision and planDecision, parsed and not yet
  acted on.
- Stub contracts for the memory functions and the activity recorder,
  inert until their implementations land.
- Backup, restore and rotation carry the new columns; a fact's message
  reference is resolved against the restored messages like its
  conversation reference.
- Message keys for the trail, memory, plans, assumptions, replies,
  interim tables, comparison charts and reasoning settings in all seven
  locales, each reachable from a catalog and checked both ways.
2026-10-06 18:39:50 +02:00
Marc-André Bombeck bed47387de fix(ui): show text-field focus as a neutral border, not a purple ring
Every text-entry control (Input, Textarea, Select and NativeSelect
triggers, the date and time fields, the calendar's dropdowns, the Coach
composer and the hand-built note fields) now marks focus by turning its
border the new neutral input-focus tone, about 5:1 on the dark
background and 4:1 on the light one, with no ring. A text field matches
:focus-visible on every mouse click, so the ring was on for everyone.
An invalid field keeps its red border and draws its destructive ring
only while focused. Buttons, links, checkboxes and switches keep their
rings.

text-field-focus-guard.test.ts fails when a text-field class string
brings a focus ring or a primary/ring-coloured focus border back.
2026-10-05 21:08:48 +02:00
Marc-André Bombeck 130ace74ee fix(crypto): keep updatedAt when key rotation re-seals a value
Key rotation rewrote every ciphertext column through a plain Prisma update,
in the in-app corpus walk and in the rotation script alike, so a rotation
stamped the run time on every encrypted row: the Coach panel then listed
old conversations as recent, and every client re-pulled measurements and
mood entries whose sync cursor had moved. Both now read each row's
updatedAt and write it back, matching on it, so a row edited since the
read is left for the next run instead of being stamped. The models that
carry the column are listed beside the registry and checked against the
schema, and the updatedAt guard now covers the rotation writes.
2026-10-05 13:07:22 +02:00
Marc-André Bombeck 7d42cf7122 fix(coach): keep updatedAt through storage-only rewrites
The title encryption backfill moved each Coach conversation's title into
ciphertext with a plain Prisma update, which stamps updatedAt. The Coach
panel orders and groups conversations by that column, so threads untouched
for months showed up under Today. The memory summary refresh did the same
in the background.

Storage-only rewrites now carry the row's own updatedAt and match on it, so
a turn that lands meanwhile wins and the row is retried on the next pass:
conversation titles, practitioner contacts, measurement and mood notes
(where updatedAt is the sync cursor), inventory notes, and the mood-note
script. The summary refresh writes through raw SQL. A guard freezes both
rules: every backfill write on a model with @updatedAt names the column or
is listed with the reason the stamp is wanted, and every conversation
write outside the person's own actions keeps it.
2026-10-05 12:40:55 +02:00
Marc-André Bombeck 0d6bac8c5c Merge remote-tracking branch 'origin/feat/coach-answers' into release/v1.40.2
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
2026-10-05 07:16:55 +02:00
Marc-André Bombeck 57a889ef3c Merge remote-tracking branch 'origin/feat/coach-frame' into release/v1.40.2 2026-10-05 07:16:09 +02:00
Marc-André Bombeck a91e1bf0a7 Give each Coach answer one action row and keep its chips in its column
Under an answer: copy, read aloud, try again and the time as text on the
left; tokens and model on the right, on one line down to 390 px. The
thumbs leave the web (the feedback route stays for the native client),
and so does the "What I'm looking at" disclosure: the method line and
the tables the answer read move into the steps list, and an older
message without steps lists the areas it drew on, never a raw key.

Remember becomes a bookmark icon in the question's row and confirms in
place with a toast. Follow-up chips render inside the latest answer's
column at its text size. The answer column takes the full width below
`sm` and a fixed 80 % above, so nothing moves sideways as a reply
streams. A round button fades in when the thread is scrolled away from
the end and brings it back.
2026-10-04 20:02:48 +02:00
Marc-André Bombeck 12153fe143 feat(coach): move conversations into a collapsible right panel
The Coach page gets a conversations panel on the right of the thread. From
1280 px it docks beside the thread and slides open and shut; the reading
column keeps its width either way and only its centring moves. It is open
on a first visit and remembers the last choice per device. Below 1280 px it
is a sheet from the right. The toggle sits at the trailing edge of the top
bar, through a small page-owned slot the top bar now renders.

The panel lists every conversation grouped by recency, with a row menu to
rename in place (Enter saves, Escape cancels without closing the panel) and
delete with the existing undo toast. The undo window now survives the sheet
closing. New chat is the round button at the panel's bottom right, and
picking or starting a conversation keeps ?c= in the URL.

The gear in the panel header opens quick settings: a popover from md up, a
bottom sheet on a phone. It switches the answering provider (chain reordered
through the existing route), the model and reasoning level where the person
owns them, and shows what the Coach can see. Keys, the Codex sign-in and the
fallback chain stay in Settings > AI. The drawer's gear opens the same
overlay. The composer's + menu is gone.

Settings > Coach links to /coach?settings=data, which opens the overlay on
what the Coach can see instead of a rail that no longer exists.
2026-10-04 20:00:07 +02:00
Marc-André Bombeck 13ea849188 perf(backup): load the backup payload schema on first use
backupPayloadSchema is built when validations/backup.ts is evaluated and
holds about 12 MB of heap. The restore job, the stored-backup readers and
the export writer import that module, and they sit on the boot path, so
every server process built the schema at start whether or not it ever
touched a backup.

The schema version, the compatibility check and the per-section summary
move to validations/backup-summary.ts, which never builds the schema. The
call sites that validate a file (upload, restore, the summary and download
routes, and the streamed reader's element check) load backup.ts with a
dynamic import at that moment; everything else takes only types. The
OpenAPI registry keeps its static import, since only the spec generator
loads it.

After warm-up on a production build under --max-old-space-size=524, the
used heap drops by about 29 MB, and the schema no longer appears in a heap
snapshot. A structural test refuses a new static value import of the
module outside the registry.
2026-10-04 18:23:14 +02:00
Marc-André Bombeck 006679ed9e Merge pull request #1145 from MBombeck/perf/dashboard-client
perf: faster charts and insights, no sidebar scrollbar flash, Umami events delivered
2026-10-04 17:33:51 +02:00
Marc-André Bombeck 1b1da652d3 fix(monitoring): route Umami events to the endpoint the tracker was built with
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.

Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
2026-10-04 15:13:52 +02:00
Marc-André Bombeck 13b8920dd5 Merge remote-tracking branch 'origin/main' into feat/pulse-hourly-mean-complete 2026-10-04 14:54:53 +02:00
Marc-André Bombeck 06f210b56f Guard every pulse mean reader behind the day-mean helper
A structural test lists every file that takes a database or in-memory mean
over measurement values and names pulse, and requires each to use the shared
statistic, hand the type to a fold that does, or state why it never averages
pulse readings. A new plain mean over pulse, or a reader that drops the type,
fails it.
2026-10-04 11:39:33 +02:00
Marc-André Bombeck 24786f6549 Merge pull request #1130 from MBombeck/feat/provider-reasoning-effort
feat(ai): per-provider reasoning effort for Local and gateway models
2026-10-04 11:29:44 +02:00
Marc-André Bombeck befac59576 feat(ai): send a per-entry reasoning effort to Local and gateway providers
Reasoning models on Ollama and similar servers think by default and can
spend the whole max_tokens budget before the visible answer starts, so
the call comes back empty or times out. The Local and OpenAI-compatible
chain entries now carry an optional reasoning setting: Default sends
nothing, Off sends reasoning_effort "none", Low / Medium / High send
their own value. It is stored on the entry in aiProviderChain, so a
chain can mix a thinking model with one that does not know the field.

- Every exported resolver binds the entry's value onto the client, the
  connection test included, keyed on the instance type so the operator
  provider, OpenAI, Anthropic and Codex never carry it.
- PATCH /api/user/ai-provider accepts localReasoningEffort and
  compatReasoningEffort and GET reports them. A provider absent from
  the chain holds its value on a disabled entry; restating Default on a
  chain that was never customised writes nothing.
- PUT /api/insights/provider-chain accepts reasoningEffort on local and
  openai-compatible entries, refuses it on any other, and keeps the
  stored value when the body leaves it out. Its schema is now shared
  with the OpenAPI registry.
- A Default request body is byte-identical to the one sent before.

Refs #1126
2026-10-04 10:42:59 +02:00
Marc-André Bombeck b63fa317f3 fix(docker): hoist the canvas binary of the pinned version
Refs #1124. The store holds two @napi-rs/canvas versions, the pinned one
and an older one that pdf-parse's own pdfjs-dist pulls in. The runner
stage took the musl binary with `find | head -1`, got the old one, and
copied it into the new loader's directory, where the loader looks first.
Rendering still worked, but `loadImage` calls `Image.decode`, which the
old binary lacks, so every preview thumbnail failed with "image.decode is
not a function" on both architectures.

The builder now records the resolved canvas version and the runner hoists
that version by name, as it already does for pdfjs-dist. The build stops
when the binary it copies belongs to another version.
2026-10-04 10:37:23 +02:00
Marc-André Bombeck db9618ee7b Merge remote-tracking branch 'origin/main' into wip/v140-H 2026-10-04 00:43:10 +02:00
Marc-André Bombeck 4317eb91ca Make the rolling course anchor opt-in
The rule that keeps an earlier course's intake from anchoring a rolling
cadence applied to every caller unless one opted out, which is how a
booster reminder picked it up. It now applies only when the context
declares rollingAnchor: "courseStart"; the four medication context
builders declare it, and every other caller keeps the previous
behaviour, where the last intake always anchors. A guard enumerates
every RecurrenceContext builder in src, requires each to be listed as a
medication or a non-medication builder, and checks the declaration on
both sides.
2026-10-04 00:42:54 +02:00
Marc-André Bombeck 69bae8bdb4 Add ENCRYPTION_KEY_CHECK=warn as an escape hatch for the boot key check
At a first start there is no recorded key yet, so an operator facing a
refusal they are certain is false has no row to delete. With
ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs
its full outcome, /api/health names a mismatch as a warning field without
failing, requests are served, and no key is recorded unless every key id
passes.

The variable is on the compose whitelist, in both example files, the env
manifest, the Unraid and Portainer templates and the published-env guard.
The log block, the mismatch page and the self-hosting pages offer it as the
last resort after the three real fixes, and say that it disables a safety
check.
2026-10-03 23:46:40 +02:00
Marc-André Bombeck 4783711651 Refuse MCP and its OAuth endpoints while the encryption key does not match
apiHandler answers 503 encryption.key_mismatch before auth, but the remote
MCP endpoint and the OAuth authorize, token and register routes wrap in
withBackgroundEvent instead and never checked. An MCP write tool could seal
new rows under the wrong key, and those rows would open under it at the
next boot.

The refusal moves into one helper used by apiHandler, /mcp (same envelope)
and the OAuth routes (RFC 6749 temporarily_unavailable with the error code
as an extension member). A structural test fails when a route outside
apiHandler, or a server action, lacks the refusal in every exported handler
and is not on a short allowlist of routes that never touch the database.
2026-10-03 23:32:07 +02:00
Marc-André Bombeck 1494409209 Merge remote-tracking branch 'origin/main' into wip/v140-H
# Conflicts:
#	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/__tests__/v137-wave-zero-fixtures.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-03 22:52:49 +02:00
Marc-André Bombeck a14174fe4d Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course
rows: calendar spans, both ends inclusive, an optional encrypted note, no
schedule or dose of their own. Courses never overlap and every course but
the latest has ended, so the latest course is always the one the
medication's startsOn/endsOn project. src/lib/medications/courses.ts is
the only writer of those two columns for a medication with courses; it
writes under a per-medication advisory lock and re-projects in the same
transaction, so reminders, the next-due line and intakeActionable keep
reading the columns and stay right.

- Migration 0368 adds medication_courses and backfills one course per
  medication window (an end-only window starts on the creation day, or
  on its end day when that lies earlier); a medication without a window
  stays course-less. Idempotent, data-preserving.
- GET/POST /api/medications/{id}/courses and PATCH/DELETE
  .../courses/{courseId} (owner-only); create seeds the first course
  from a window; the medication PUT edits the latest course and refuses
  a window that would break the rules (medications.course.*).
- List, detail, create and update carry courses, courseCount,
  previousCourseEndedOn and canStartCourse, resolved on the server.
- Compliance expands over the span of all courses and drops the slots
  between them; every compliance read selects the courses. A rolling
  cadence is not anchored by an intake from a previous course.
- The card names the last course's end and the running course's number;
  the detail Lifecycle group lists the courses with start, edit and
  delete. The Coach compliance block, the MCP resource, the doctor
  report window and table, the clinician view and FHIR (one
  MedicationStatement per course with effectivePeriod and status) read
  the courses.
- Backup carries courses per medication and the restore writes them and
  re-projects; an older file derives the one course its window
  describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
2026-10-03 22:39:25 +02:00
Marc-André Bombeck ef0028c020 Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 22:16:29 +02:00
Marc-André Bombeck 9c2b57df33 Merge remote-tracking branch 'origin/main' into wip/v140-I 2026-10-03 22:00:18 +02:00
Marc-André Bombeck 8068626aa8 Merge remote-tracking branch 'origin/main' into wip/v140-K 2026-10-03 21:48:26 +02:00
Marc-André Bombeck 57a54061be Merge pull request #1112 from BernhardBuckel/fix/series-source-ladder
fix(series): follow the source priority ladder in the dense and raw series reads
2026-10-03 21:41:33 +02:00
Marc-André Bombeck dbbff339f6 test(symptoms): register the new surfaces with the export, destructive and success guards
The full-backup route tests mock the symptom definition read the payload
now makes; the destructive-control registry names the symptom hooks (an
entry or a whole symptom, confirmed by an AlertDialog); the symptom quick
entry's save toast is pinned beside the measurement and mood forms it
mirrors.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 4cb8118802 Let a person name their own medication categories
A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.

- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
  admitted at MANAGE like the cycle symptom vocabulary) and owner-only
  PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
  medication filed under the category to OTHER in the same transaction
  and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
  the create and update bodies refuse a key that is not the caller's
  (422 medications.category.unknown), and list, detail, the insights
  medication tiles and the MCP medications resource carry categoryLabel.
  A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
  built-in rows with an inline add row; the list page gains a category
  filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
  restore writes them before the medications; wipe plan, encrypted
  column registry, rotation script, sharing guards and OpenAPI follow.
2026-10-03 21:38:03 +02:00
Marc-André Bombeck 9d9e1f153c feat(insights): make each person-defined symptom its own correlation channel
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
2026-10-03 21:14:08 +02:00
Marc-André Bombeck d6571c4e9c feat(symptoms): add person-defined symptoms and timestamped occurrences
A symptom of one's own ("aura", "headache") is a definition with an
encrypted name and an icon from the cycle custom-symptom allowlist; each
occurrence carries a time, a 0-10 intensity (the pain score's scale), an
optional encrypted note and an optional link to one of the record's open
illness episodes. Both ride the illness module: every route answers
illness.disabled when it is off.

- Migration 0369 (idempotent): symptom_definitions and symptom_events,
  intensity CHECK 0-10, episode link ON DELETE SET NULL.
- /api/symptoms/definitions{,/[id]} and /api/symptoms/events{,/[id]}:
  eight active definitions per record (a hidden one frees its slot,
  showing it again re-checks), hide or purge, events create/list/edit/
  delete, a new link must name an open episode of the same record
  (422 symptoms.episode.notOpen). Error group symptoms.*.
- Sharing: reads at READ, logging an occurrence at WRITE, defining and
  editing at MANAGE; C1/C3/C4 audit details; frozen lists, the handler
  matrix and the activity verbs updated.
- Both encrypted columns registered and rotated; both models in the wipe
  plan; both carried at both backup ends with occurrences nested in their
  definition, plaintext in a portable file and ciphertext in a recovery
  file, and a link to an episode the file lacks dropped and named
  (symptomEpisodeReference).
- OpenAPI regenerated.
2026-10-03 21:13:45 +02:00
Marc-André Bombeck eda5776886 Run document summaries and filing suggestions in the background
The summary and suggest routes accept `Prefer: respond-async`: after the
same quick refusals they answer 202 with a run id, and the background
document AI worker runs the read under two new run kinds
(DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header
both routes answer synchronously as before; both paths run one shared
body, so they cannot drift.

The worker asks the capability for the record and re-checks the wire for
the provider it picks before every read. A summary that is to be stored
marks the document PENDING while queued, READY when it lands, and
UNAVAILABLE when the run fails or the write does not land. A second
request attaches to a live run only when it would produce the same thing.

The vault asks for both in the background and shows the same calm
waiting line the index uses, worded so a transient read never promises
that leaving the page keeps it. The egress guard and the route inventory
now hold every run kind to its dispatcher re-check and its capability.
2026-10-03 20:51:57 +02:00
Marc-André Bombeck 350c646463 Adopt the medication category table into the schema
The clinical category lived in a side table the app created at runtime,
after migration 0004 had dropped it as orphaned. It is now the
MedicationCategoryAssignment model. Migration 0368 creates the table
IF NOT EXISTS with the runtime DDL, so an existing table converges with
its rows untouched (verified with prisma migrate diff against a database
holding the runtime table and against a fresh one).

The helpers keep their names and run on the Prisma delegate; the runtime
CREATE TABLE and the pre-transaction ensure call in the restore are gone.
The backup plan judges the table as a schema model (backed up, two-ended,
seeded in the round trip) instead of under RAW_SQL_TABLES, which is now
empty; its guard proves its matcher on a fixture string instead.
2026-10-03 20:45:02 +02:00
BernhardBuckel bb1acfab64 fix(series): follow the source priority ladder in the dense and raw series reads
The rollup tier and sleep resolve every (type, day) to the source the person's
ladder picks before they aggregate. The dense and raw reads of
/api/measurements/series filtered on user and type only, so with a second
provider a day's value blended the two, a reading present in both showed
twice, and the configured ladder had no effect on those charts.

Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:

- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
  and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).

The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.

Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.

Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.

Refs #1108
2026-10-03 20:18:50 +02:00
Marc-André Bombeck cd3c0b7de2 Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 20:06:16 +02:00
Marc-André Bombeck 816eabb3c3 Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 19:22:56 +02:00
Marc-André Bombeck 37793298a8 Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts:
#	docs/api/openapi.yaml
2026-10-03 19:22:01 +02:00
Marc-André Bombeck 9ecdda6358 fix(dates): show a stated calendar date as that date east of UTC+12
Day keys anchored at noon UTC went through the zone-aware
dateShortSmart, which shows them as the next day in UTC+12..+14. A
dateShortSmartCalendar formatter reads the key in UTC with the same
year rule, and the predictions panel, the illness timeline, the
intraday pulse day label, the nutrient chart and the coach result
chart use it. The lab chart dates a date-only reading by its calendar
date and a timed one in the reader's zone, as the lab lists do. The
timezone formatter guard now fails on a noon anchor handed to a
zone-aware formatter.
2026-10-03 18:44:06 +02:00
Marc-André Bombeck 51e2d68a88 i18n(measurements): say how a duration entry was read
Refs #1101
2026-10-03 18:35:26 +02:00
Marc-André Bombeck b100c73a62 fix(auth): refuse a sign-in over plain http before it spends a factor (#1097)
A server that issues Secure session cookies cannot sign anyone in on a
plain http:// page: the browser drops the cookie. The login request was
still verified, the MFA ticket minted and then claimed, and the next
attempt answered "Invalid or expired challenge".

The web client now sends an optional clientTransport on the browser
sign-in requests (password login, passkey start, both MFA completions).
When the server issues Secure cookies and the page is plain http on a
non-loopback host, the request is refused with 409
auth.session.insecure_transport before the password is checked, a
ticket is minted or claimed, a code is spent, or a passkey challenge is
issued. A request without the field is never refused. The SSO start
takes client_proto/client_host and sends such a page back to the login
page instead of to the provider.

GET /api/auth/registration-status publishes sessionCookieSecure, and the
login page shows the reason with both fixes and disables the sign-in
controls before anything is sent; on localhost it shows a note only.
SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line
stays.
2026-10-03 18:21:19 +02:00
Marc-André Bombeck 0acf3da575 feat(documents): read documents and lab scans in the background
"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.

Both now run as a background run (`DocumentAiRun`, migration 0366):

- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
  answers 202 with a run id once every quick refusal has passed. Without
  the header it answers exactly as before, which the iPhone app relies on.
  A second request while a read of the same document is queued or running
  attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
  calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
  route answers with, or the failure code and status it would have given.
  Scoped to the caller; another account's run is a 404.

The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.

Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.

The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
2026-10-03 18:18:56 +02:00
Marc-André Bombeck 308470f229 feat(encryption): back up the key, and refuse to serve when it cannot open the database
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.

The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.

HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
2026-10-03 18:06:28 +02:00
Marc-André Bombeck 0f6669847c Merge remote-tracking branch 'origin/main' into fix/ai-timeout-everywhere 2026-10-02 23:59:38 +02:00
Marc-André Bombeck 6207101006 test(ai): guard that every model call runs under the response-timeout setting
Pins the four ways the setting could be skipped again: a wire client
reading params.timeoutMs itself instead of callTimeoutMs, an exported
resolver in provider.ts returning a provider it did not bind, a client
constructed outside provider.ts (the offline evaluation harness is the
named exception), and a surface ceiling not listed with its reason.
Each matcher asserts a non-zero match count.

Refs #1090
2026-10-02 23:03:45 +02:00
Marc-André Bombeck 6eb79a1d47 fix(workouts): report manual saves through the outcome module and register the delete
A repeated submit that wrote nothing reads as a neutral notice rather
than a success. The delete control is entered in the destructive-control
registry, and its 409 code in the published error catalogue.

Refs #1085
2026-10-02 21:31:25 +02:00
Marc-André Bombeck 75361d0666 fix(glucose): state band edges in the reader's glucose unit
The time-in-range labels in the doctor report, the target caption on
the glucose panel, the glucose page description and the fasting
explainer printed fixed mg/dL figures, so a reader on mmol/L saw
"Low (< 70 mg/dL)" beside values in mmol/L.

The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`,
`GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints,
the analytics engine counts its bands from the same constants, and
every sentence takes the band edges and unit as parameters through
`glucoseBandParams`, formatted with the reader's number format.

The unit-preference guard now also reads the locale bundles: a string
that names mg/dL or mmol/L fails unless it is allowlisted with a
reason.

Refs #1067
2026-10-01 22:07:07 +02:00
Marc-André Bombeck 661378bfa9 Merge remote-tracking branch 'origin/main' into fix/v1397-units2 2026-10-01 13:47:10 +02:00
Marc-André Bombeck 615f5a8070 test(units): sweep the AI and Coach producers for unit literals
The unit sweep covered the display trees and the insight library; the
prompt and snapshot builders, the text-writing jobs and the target and
digest producers were outside it, which is where a band in mg/dL, a
threshold in kg/week and a distance in metres reached the model. The
sweep now walks those trees too and matches prose units ("kg/week",
"5 metres") as well as symbols. The four allowlist entries for prompts
that stated weight in kilograms are gone with the literals; the new
entries each name why the literal is not a reader-facing unit.

A line scanner cannot see a canonical number printed with no unit at
all, so the guard also names every producer that carries a
preference-dependent figure into a prompt, a snapshot or a stored
sentence and requires each to call a reader-unit resolver. The canonical
weight signal of the day loses its kilogram label; the reader's unit is
attached where the signal is converted.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck 0028290117 fix(cycle): read basal temperature and the unit-bearing chart titles in the reader's units
The basal body temperature field, its chart and its tooltip were fixed
to degrees Celsius whatever the reader had chosen: the placeholder said
°C, the field accepted only 30-45, the chart plotted Celsius and the
tooltip printed "{temp} °C". Two chart titles carried a fixed unit as
well ("Wrist temperature (°C)", "Walking speed (km/h)") above an axis
already in the reader's unit.

The field now reads and writes in the reader's unit and stores Celsius;
a reading the person did not touch is saved back unchanged rather than
through a round trip of rounding. The chart plots, pads and labels in
the reader's unit, the two titles drop the unit the axis already
names, and the cycle contrast's fixed kg, °C and mg/dL labels, which
the transform had already replaced, leave the bundles.

Refs #1067
2026-10-01 13:23:17 +02:00
Marc-André Bombeck d731a1b8fe feat(backup): classify every User column and carry the account settings
Every column of the account row now has a backup verdict in
USER_COLUMN_BACKUP_CLASS: SETTING, CREDENTIAL, IDENTITY or OPERATIONAL. A
schema-derived guard fails on an unclassified column, and the setting columns
each get a codec in account-settings-backup.ts, so a column classified as a
setting without a way to travel does not compile.

The restore end checks each value the way the settings route that writes it
does: AI endpoints against this host's private-origin policy, languages, units
and time zones against the known vocabularies, threshold bands against their
bounds, the avatar against the upload's sniff and limits. A refused value is
left out and named; credentials, identity and the host's own cursors are never
admitted.
2026-10-01 13:04:36 +02:00