4188 Commits
Author SHA1 Message Date
Marc-André Bombeck b2a8edf65f Merge fix/backup-timeout
# Conflicts:
#	CHANGELOG.md
#	tests/integration/backup-round-trip.test.ts
2026-09-03 03:23:41 +02:00
Marc-André Bombeck ee4afc7b9e Merge fix/guarded-twins
# Conflicts:
#	CHANGELOG.md
2026-09-03 03:23:40 +02:00
Marc-André Bombeck fe8ab36e12 Merge fix/env-whitelist
# Conflicts:
#	CHANGELOG.md
2026-09-03 03:23:39 +02:00
Marc-André Bombeck 5cac883c17 Merge fix/settings-that-do-nothing
# Conflicts:
#	CHANGELOG.md
2026-09-03 03:23:38 +02:00
Marc-André Bombeck b04d07923d Merge test/guard-coverage-gaps 2026-09-03 03:23:38 +02:00
Marc-André Bombeck 98324b0eff Merge origin/main 2026-09-03 03:23:08 +02:00
Marc-André Bombeck 83b1400e5b refactor(labs): drop the fail-soft lab-note decrypt, which has no caller left
The export was its only user, and the export now reads through a helper that
marks an undecryptable note instead of blanking it. Nothing else wanted the
silent null: the list DTO never carries the note at all, only `hasNote`, and
the single-resource GET has always used the throwing decrypt so a key gap
surfaces there.

Deleted rather than kept behind a dead-code exemption. A fail-soft twin
sitting unused next to a strict one is the exact shape this branch has been
clearing out — the next person writing an export reaches for whatever is
nearest, and the whole point was that the nearest thing should not silently
swallow a row. It had no test of its own, so none was removed; the three
comments that pointed at it are rewritten to say what is true now.
2026-09-03 02:29:37 +02:00
Marc-André Bombeck 154c93b5e1 feat: surface a stalled or failed backup schedule
The weekly pass can stop and leave no trace where anyone looks. The
handler's own error never runs when the job is killed rather than thrown;
`recordError()` increments an unnamed counter; and the admin status page's
failing-queue panel looks back 72 hours, which for a weekly queue means a
permanent failure is on screen three days in seven. The backups page
itself showed an ordinary row with an ordinary timestamp, because a copy
made six weeks ago reads exactly like Sunday's until somebody subtracts
the dates.

The listing now carries two facts that fail at different times: the age of
the newest scheduled copy, which never leaves any retention window, and
the terminal state of the last scheduled run, which is the only place the
reason is written down. `readLastQueueRun` takes the newest run rather
than a window, so a weekly queue's outcome stays readable until the next
one replaces it, and it unwraps the value pg-boss stores a thrown error
under, so the page says "job timed out" instead of raw JSON. The
failing-queue reader gets the same unwrap.

Manual uploads are excluded from the cadence judgement by the shape of the
input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh
instance is not called overdue for having no copy yet.

The listing also stops reading every blob into the web process to measure
it; `octet_length` answers that in the database, which on an instance with
large records was its own way of running out of memory.
2026-09-03 02:22:37 +02:00
Marc-André Bombeck d8196d5208 fix: compress the stored backup before encrypting it
The weekly pass built the whole account in memory, serialised it whole,
encrypted the string whole, and handed the result to the database whole.
That is four full copies of the record at the same moment — the object
graph, the JSON, the base64 ciphertext at 1.33x the JSON, and the copy the
driver makes of that parameter. Measured against a seeded account of
445 000 readings, the JSON alone is 242 MB and the run dies of heap
exhaustion; under a modest heap it never reaches the insert. The pass was
not slow, it could not finish.

A health record's JSON is extremely repetitive, so gzip takes that 242 MB
to 14 MB and every copy after it shrinks with it. The same run completes
in about twenty seconds with a peak heap of 434 MB and a stored row of
25 MB instead of 322 MB — which also matters because the copy lives inside
the database it would be needed to restore. Both backup jobs now hand the
serialised form back from a frame of its own, so the payload graph is
released before the compress step allocates anything.

The marker sits inside the ciphertext, so the stored column keeps the one
shape `decrypt()` already understands and rows written before this still
restore. The off-host object gets the same treatment through the version
byte its envelope always carried; version 1 objects still read back, which
both the restore drill and the restore script depend on.

The obvious saving — dropping soft-deleted readings, four rows in five on
a real record — is not taken, and the round-trip test now seeds a
tombstone and asserts it makes the journey. A tombstone inside the
retention window is what tells a synced client the row is gone and what
makes a re-sent sample a no-op; a restore without them brings back every
measurement the account ever deleted on the next device sync.
2026-09-03 02:22:21 +02:00
Marc-André Bombeck b5dd9372a7 fix: give the weekly backup a run window of its own
The `data-backup` queue was registered without send options, so every job
it minted took pg-boss's queue defaults: a fifteen-minute expiration and
two redeliveries. A snapshot that serialises every account's whole record
does not fit in fifteen minutes on a large one, so the pass was killed
part-way through, redelivered twice against the same record, and reached
`failed` with "job timed out" about three quarters of an hour after it
started — having never once completed.

Two hours, on the schedule rather than on the queue: `create_queue()` ends
in ON CONFLICT DO NOTHING, so a queue-level default would apply to a fresh
database and be inert on every instance that already has the queue. The
admin run-now route sends with the same options, or an ad-hoc snapshot
would silently keep the default the cron was rescued from.

The default two retries stay. The old redeliveries were pointless because
the first attempt could not succeed either; a transient failure genuinely
benefits from a second go, and the alternative is losing the week.
2026-09-03 02:22:07 +02:00
Marc-André Bombeck ee14b9d564 test: gate every env read against the compose whitelist
Derives both sets mechanically — the whitelist from docker-compose.yml, the
read set from the source — and fails when a variable is read without being
forwarded. Written red against the tree before the fix, where it named 35.

A search for process.env. alone is not the read set: four modules resolve the
name through a helper and index process.env dynamically, which hides every
OIDC_* variable and ALLOW_LOCAL_AI_PRIVATE_HOSTS. Each is registered with its
own call-shape matcher and its own non-zero assertion, so a rename fails the
file loudly instead of quietly shrinking the set. The walk and the matchers
both assert a floor, because an empty match set agrees with any allowlist.

Thirteen deliberate exceptions each carry a written reason, and the list is
kept honest from both ends: an entry that stops being read, or that later
appears in the whitelist, fails too.
2026-09-03 02:13:02 +02:00
Marc-André Bombeck 98597232f2 fix(notifications): drop the unreachable WEB_PUSH_* VAPID aliases
Six WEB_PUSH_* names and NEXT_PUBLIC_VAPID_PUBLIC_KEY sat behind the three
real VAPID_* variables in the env fallback. None was ever on the compose
environment: whitelist and none appeared in .env.example, .env.production.example
or the env manifest, so under the bundled stack a value set under any of them
could not reach the process. Remove them and the self-hosting note that
advertised them.
2026-09-03 02:12:44 +02:00
Marc-André Bombeck ae3525a88e refactor(fhir): skip an adherence observation with no expectation rather than write 0
`adherenceRatePercent` returns null exactly when nothing was expected, so
callers render a placeholder instead of a misleading 0 or 100. The FHIR
exporter coalesced that null to 0, which would put a clinical claim of zero
adherence into an exported bundle. The guard above it means the branch
cannot be reached today; skipping rather than coalescing keeps it
unreachable if that guard ever moves.
2026-09-03 02:08:10 +02:00
Marc-André Bombeck 156f7e1523 fix(insights): skip tombstoned rows in the correlation channel and the cache gate
A deleted reading is kept with a `deletedAt` stamp, and the schema says in
as many words that normal reads filter it. Three did not.

The custom-metric correlation channel filtered the metric's own tombstone
but not its entries', so a reading the person deleted kept shaping the
correlation they were shown. The status input hash — which decides whether a
cached AI assessment is still current — filtered its measurement arm but not
its mood aggregate and not its custom-metric count or newest-entry probe. A
deletion left that fingerprint unchanged, so the gate re-dated yesterday's
text and served it as today's over data that was gone.

Pinned in the existing soft-delete guard, whose matchers were checked by
removing each filter again.
2026-09-03 02:08:09 +02:00
Marc-André Bombeck 4d98a273b1 fix(export): carry an undecryptable note as a marker instead of as nothing
A lab note or a biomarker context written under a key this instance no
longer holds came out of a portable export as `null` — byte-identical to a
column that was never written. The restore importer then wrote nothing, so
the text was gone and nothing said it had ever been there. The whole loss
fit inside a fail-soft `catch` written for a list view, where returning null
is the right answer.

Both now export a short marker naming what happened, and the marker survives
a restore. The Coach transcript already did this with a placeholder sentence
and the doctor report with a rendered flag; the marker is now one constant
the transcript shares, and its comment says why an empty string will not do.

The unit test that required `note: null` here pinned the loss. It keeps the
half that matters — one bad row must never abort the export — and asserts
the marker instead. The round trip is proven end to end against a real
restore.
2026-09-03 02:08:09 +02:00
Marc-André Bombeck c2c018aa08 fix(insights): exclude unlogged days from the medication-continuity pairing
The blood-pressure medication continuity series joined every day that had a
systolic reading against `takenByDay.get(day) ?? 0`, so a day with no intake
record at all entered the scatter and the coefficient as a measured 0 %
continuity. A week away from the app read as a week off the medication.

Only days carrying an intake record are paired now. A logged skip is a real
zero and stays; a day with no record is absent, and absence is not zero.

The timezone-parity fixture in the route test recorded three logged days out
of five and relied on the other two arriving as zeros, which pinned the
defect rather than the timezone question it asks. Its two remaining days are
recorded as skips, which keeps five paired days and keeps every one of them
something the person actually logged.
2026-09-03 02:07:52 +02:00
Marc-André Bombeck 04361a9fe7 fix(medications): drop a medication with no schedule from every adherence surface
`calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication
that expects no dose. Arithmetically honest, and a lie on a surface that
reads the number as adherence — it also inflated the averages the AI prompts
are built from. The doctor report has excluded both the PRN arm and the
empty-schedule arm since it was written, with the reason beside it. The
insights envelope, the blood-pressure gate and the medication-compliance
snapshot filtered only PRN, and the empty schedule is reachable:
`POST /api/medications` accepts `schedules ?? []` on a scheduled medication.

Move both arms into one predicate, `expectsDoses`, and route all four
surfaces through it so they cannot drift apart again.
2026-09-03 02:07:43 +02:00
Marc-André Bombeck f72e236b8a fix(insights): gate the mood correlation cards on significance
The mood page called the plain `pearsonCorrelation` — strength banded from
|r| alone above a floor of five pairs, no significance test — so five days
of noise sloping the right way arrived at the reader as a "Strong" badge,
with n and r behind an info icon. The guarded twin was written for exactly
that failure and names it in its own comment, and the weight, blood-pressure
and mood-status surfaces had already moved onto it. The mood page had not.

Route the page's five cross-metric channels through
`significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason
a pairing was refused, so the card can distinguish "still building" from
"twenty days and nothing in them" instead of showing the same blank for
both. New copy in all seven locales.

Add a structural guard that fails when a new caller of the unguarded helper
appears outside a written allowlist. Two callers remain on it, each named as
open work rather than as a blessing: the /insights envelope and the AI
feature payload both still band at n >= 5, and moving them changes payload
shapes their own tests pin.
2026-09-03 02:07:29 +02:00
Marc-André Bombeck f39f2f32ce fix(insights): read the dismissed evidence hash in the resurface gate
Dismissing a correlation pattern stores `dismissedEvidenceHash` alongside
the effect size and sample size, and the gate that decides whether to raise
the finding again read the two numbers and never the hash. The hash is the
only part of the snapshot that covers the whole evidence tuple, and a
snapshot that carries it without the two numbers — what a partial restore
leaves behind — could not be released by anything: the gate had no value to
compare and fell through to `false` on every future run.

Move the decision into `shouldReleaseDismissal`, which reads the hash twice
and for two different reasons. Evidence byte-identical to what was
dismissed settles immediately, without evaluating a threshold. Evidence
whose hash no longer matches goes to the existing thresholds when the
numbers are there, and releases the dismissal when they are not, because a
changed hash is still proof the evidence moved.

The thresholds themselves are untouched. A dismissal still has to survive
ordinary sampling noise, which is what they are for; a hash that changes on
every recomputation would otherwise undo a deliberate dismissal on the next
nightly run.
2026-09-03 02:02:30 +02:00
Marc-André Bombeck 4dbad48d89 fix(admin): persist the assistant flags sent to the settings route
`adminSettingsSchema` validates five `assistant*` booleans and the strict
schema refuses anything else, but the route's `booleanFields` list never
carried them. Sending one alone answered 422 "No valid fields"; sending one
alongside a real field answered 200 and dropped it. The schema comment says
the entry exists so an operator scripting their settings over the single
route can carry the values, so the route is brought up to the contract
rather than the contract cut down to the route.

The flags are echoed in the GET and PUT payloads as raw column values — the
master-over-sub resolution stays with `/settings/assistant-flags`, which
owns that shape and was never affected.
2026-09-03 02:02:22 +02:00
Marc-André Bombeck de0876b215 fix(settings): refuse enabling a channel the operator switched off
With the switches now enforced at dispatch, a user could still flip a
channel on, see the toggle stick, and get nothing. Refuse the enable
instead, with 403 and a message that names the reason: the ntfy and
Telegram config endpoints on both accepted body shapes, and the Web Push
subscribe endpoint, which is the enable step for that channel because it
creates the channel row.

Telegram is checked before the webhook registration, so a refused enable
makes no outbound call and stores nothing. Disabling a channel keeps
working while the switch is off — a user must always be able to turn a
channel off, whatever the operator has done.

The three refusals are published in the contract; `/api/settings/global-services`
already exposes the switches so a client can hide the control instead of
provoking the refusal.
2026-09-03 02:02:15 +02:00
Marc-André Bombeck 8d2753f942 fix(notifications): apply the global channel switches at dispatch
`AppSettings.telegramGlobal`, `.ntfyGlobal` and `.webPushGlobal` were read
in two places, and neither of them was the one that matters: the setup-card
visibility on Settings -> Integrations and an echo in the preferences
payload. The dispatcher never looked at them. An operator who switched a
channel off instance-wide lost the setup card and kept every existing
delivery on that channel, indefinitely.

Gate each of the three in the dispatcher, in the shape
`isApiGloballyEnabled()` already uses for the twelve API routes. The read
sits above the per-user channel row and below the per-event preference, it
happens at most once per dispatch, and only when a switchable channel is
actually in the cascade. A skip writes a `globally_disabled` row to the
push-attempt ledger so the admin diagnostics name the cause, and does not
count as an attempt, so `dispatched` keeps telling the truth. The account's
own channel row is left enabled: the switch is the operator's, and flipping
it back has to restore delivery without every user re-enabling anything.

APNs, the generic webhook and email carry no instance-wide switch and are
not gated here; they are turned off at their source instead.

The unit test drives the real dispatcher over a stubbed settings row; the
integration test writes the actual singleton and reads the ledger back out
of Postgres, which is the layer that proves the column the admin route
writes is the column the dispatcher reads.
2026-09-03 02:02:04 +02:00
Marc-André Bombeck 83b6ce4b15 test: pin the dashboard summary read shapes against a plan regression
Neither read is a bug a behavioural test can see: both shapes return the
right rows, and only the plan differs. So the guards split by what each
can actually answer.

The unit guard asserts on the SQL the readers build, with comments
stripped so it matches the query text and never the prose describing it:
no DISTINCT ON on this path, a LATERAL driven from an unnested type list
with a LIMIT on the per-type branch, a bound type predicate on the rollup
read sitting alongside the user and granularity filters, and the route
still calling the shared readers rather than growing a second copy. Four
of its five cases fail against the pre-change SQL.

The integration companion runs the shipped readers and the pre-change SQL
side by side over one fixture on the real Postgres and requires identical
rows. The fixture carries the cases the rewrite could have broken: a type
whose only rows are tombstoned, a type with no rows, a type whose newest
row is a tombstone over a surviving one, and coarser rollup granularities
the DAY read must ignore. Dropping the soft-delete filter or shortening
the type list makes both cases fail.

Plan assertions are deliberately not in the integration file: a
testcontainer fixture is too small for the planner to choose the access
paths a real account produces, so an EXPLAIN there would pin nothing.
2026-09-03 01:47:52 +02:00
Marc-André Bombeck f2e98e3885 perf(dashboard): drive the per-type summary reads from the type list
Two reads behind the metric tiles scanned far more than the rows they
return, because PostgreSQL 16 has no index skip scan: a query that wants
one row per measurement type but never names a type cannot be served by
any index keyed on (user_id, type, ...).

The latest-reading read used DISTINCT ON (type), so it produced every
live row of the account in (type, measured_at DESC) order before keeping
one row per group. On a multi-year account that is a sequential scan plus
a sort that spills to disk once it outgrows work_mem. It is now a LATERAL
join over the type list, one lookup per type against the live-row partial
index from migration 0183, each stopping at the first row. CROSS JOIN
LATERAL is an inner join, so a type with no live reading drops out as
DISTINCT ON dropped it, and ORDER BY reproduces the same enum ordering.

The sparkline read filtered measurement_rollups on user_id and
granularity only, skipping the second column of the table's key, so on a
single-account host neither the primary key nor the user/type/
granularity/bucket index could drive it and every stored granularity got
scanned. It now names the full canonical enum, which filters nothing out
and restores the equality the key needs; the existing index serves it.

Measured on a fixture matching a real account (454k measurements, 79%
tombstoned, 75.5k rollup rows): the first read goes from a seq scan plus
a 3.2 MB external-merge sort at 112 ms to a nested loop at 0.7 ms, and
the second from a seq scan discarding 72k rows at 6.9 ms to a bitmap
index scan at 1.6 ms. Same rows, same order, same types.

Both reads move out of the route into src/lib/dashboard/summary-reads.ts
so the integration suite can exercise the SQL itself rather than a copy
of it, the same seam buildMedsTodayBlock already uses. No schema change:
adding an index on (user_id, granularity, bucket_start) would have landed
in the same range while leaving the existing index unused and adding a
fourth write target to a table the rollup workers upsert into constantly.
2026-09-03 01:47:41 +02:00
Marc-André Bombeck 3989d9e9f9 test(modules): derive the route-gate inventory from the module registry
The inventory walked a flat, hand-written array of route trees, so its
coverage grew only when somebody remembered to grow it. It had stopped
growing: the coach, environment, mental-health, inbound-document and MCP
trees were all outside it, and so was the doctor-report export route the
header already named as a doctorReport surface. Every route in the first
four turned out to be gated, so nothing leaked — but that was the authors'
care rather than the inventory's, and a green run claimed more than it
covered.

The trees are declared per module key as an exhaustive Record<ModuleKey, …>:
a new registry key does not compile without an entry, one test asserts every
key is declared, another asserts every declared tree exists, and a third
discovers an API directory named after a module key on disk and fails when
it is not declared. An empty array is now a claim with a reason beside it
(glucose and recovery ride the measurement engine), not a silence.

Walking the newly covered trees classifies every route: coach, environment,
mental-health, the document vault and /api/export/health-record all gate
directly. The seven /api/mcp routes are the credential surface behind the
remote endpoint and go on the exempt list with the reason written out — the
endpoint itself lives outside /api and carries the gate, so a credential
minted while the module is off is inert. That refusal is added to the
behavioural list so deleting its proof fails here.

/api/export/health-record joins /api/fhir as a key-pinned tree: both serve
the whole-record aggregate, so doctorReport is the only correct key.
2026-09-03 01:46:01 +02:00
Marc-André Bombeck 20812b54f5 fix(webhooks): rate-limit every verb that checks the shared secret
The Withings entrypoints, the Telegram bot webhook and the Coolify deploy
hook limited POST and left the verification verbs unlimited. GET and HEAD
compare the same secret and answer 200 against 401, which is an oracle for
guessing it and a free load channel besides; timingSafeEqual protects the
timing channel, not the status code. The project's rule is that the
per-source limit runs before secret verification, and it was being applied
per handler rather than per file.

Each verification verb now runs the same limit its POST sibling used, on the
same bucket. Telegram and the deploy hook grow the small local helper the
Withings routes already share. HEAD re-shapes the limiter's JSON envelope
into a bodyless 429 and keeps the rate-limit headers.

The new guard walks every webhook route from the filesystem, resolves which
local helpers reach a comparison and which reach the limiter, and fails when
an exported verb reaches a comparison without the limiter in front of it. It
asserts a non-zero match count so an empty match set cannot read as green.
WHOOP already passed: it exports POST only.
2026-09-03 01:42:56 +02:00
Marc-André Bombeck 84437d5f95 test: read every spelling of a dependency override pin
The lockstep guard compared only the pins both matchers happened to
recognise, and neither matcher said so when it recognised fewer.

Workspace side: the key had to be quoted. Every entry in the file carries
an `@<selector>` suffix and therefore needs quoting, so the matcher looked
complete — but `mysql2: "^3.22.0"` is a valid pnpm override with no
selector and no quotes, and it parsed to nothing. Unquoting that key and
pointing the Dockerfile at `^1.0.0` left all six tests green.

Dockerfile side: only `npm pkg set 'overrides.X=Y'` was recognised, so
re-quoting the same line as `npm pkg set overrides.X=Y` removed the pin
from the comparison without changing what the image installs.

Both sides now parse line by line: quoted or bare keys and values on the
workspace side, any shell quoting of the same assignment on the Dockerfile
side, plus a raw JSON `"overrides"` block. What neither table can read is
collected and asserted empty, so a future spelling fails loudly instead of
shrinking the compared set in silence.
2026-09-03 01:42:16 +02:00
Marc-André Bombeck 0635db898a Merge origin/main into chore/scanning-sweep 2026-09-02 16:42:17 +02:00
Marc-André Bombeck b34e911c8e docs(admin): say which providers the server-key base URL accepts
The operator card offered a free-text base URL with an OpenAI placeholder
and no word about what else it takes, so an Anthropic endpoint looked
unsupported even though the field accepts that host. One line beside the
existing hints, in all seven locales, plus the changelog entry.
2026-09-02 16:17:17 +02:00
Marc-André Bombeck f4c90dc134 fix(ai): pick the admin client from the configured base URL
The operator's global provider always built an OpenAI client, so an admin
who saved an Anthropic key and https://api.anthropic.com/v1 (a host the
settings allowlist accepts) had every AI call fail: an OpenAI-shaped body
posted at Anthropic's Messages API, with nothing in the error pointing at
the mismatch.

Route by hostname instead. api.anthropic.com and any subdomain of
anthropic.com get the same AnthropicClient the per-user ANTHROPIC arm
builds, with the same Claude model default; every other host keeps the
OpenAI client, which is what an OpenAI-compatible endpoint needs. The
match parses the URL rather than searching the string, so a lookalike
host cannot claim the Anthropic wire, and an unparseable value keeps the
OpenAI path it had before.

The chain tag stays admin-openai: it names the slot, not the wire, and it
is persisted in user chains and pinned by the OpenAPI enum. The wire is
already visible in the instance type the wide events read.
2026-09-02 16:17:10 +02:00
Marc-André Bombeck c9f8aee476 build(docker): mirror the hono and valibot pins into /opt/prisma-cli
The image carries two installs. pnpm builds the app tree from
pnpm-workspace.yaml; npm builds /opt/prisma-cli and reads only its own
overrides field. The scan reported @hono/node-server@1.19.11
(GHSA-frvp-7c67-39w9, CVE-2026-39406) and valibot@1.2.0
(CVE-2026-59952) under that path while both packages were pinned and
clean in the app tree. Same shape as the deepmerge-ts finding, same
cause.

Rebuilt this exact install both ways to confirm rather than assume.
Without the two fields npm resolves @hono/node-server 1.19.11 and
valibot 1.2.0, which are the two versions the scan named. With them it
resolves 1.19.17 and 1.4.2, and prisma --version still reports 7.8.0.
Both arrive through prisma -> @prisma/dev.

The lockstep guard could not have caught this. Its workspace matcher
started a package name at [^"@], so every scoped key was invisible to
it: @hono/node-server was in the workspace file and absent from the
Dockerfile, the shared set never contained it, and the guard stayed
green. Scoped keys parse now, a test pins the reduction itself so the
blind spot cannot return quietly, and a second test asserts that the
three packages the image scan has actually flagged under /opt/prisma-cli
keep their npm-side mirror. Both fail when the pin is removed or moved
out of step.
2026-09-02 15:47:13 +02:00
Marc-André Bombeck 4918e9fd63 Merge remote-tracking branch 'origin/main' into fix/source-priority-labels 2026-09-02 12:09:58 +02:00
Marc-André Bombeck 7ee8bc8134 Fail the suite when a source enum member has no label
The four raw values in the sources ladder shipped because nothing checks
the catalogues against the enum they render. The new guard reads
MeasurementSource and IntakeSource out of prisma/schema.prisma, not out
of a copy, and fails naming every member no catalogue labels; it also
asserts the key each entry names resolves in all seven bundles, since a
key that resolves nowhere renders no better than the enum value. Run red
against the tree before the labels landed, it reported seven unlabelled
measurement sources per catalogue and one unlabelled intake source.

The schema reader now carries each enum's declared members so the guard
can read them.
2026-09-02 12:08:38 +02:00
Marc-André Bombeck 0548c0265e Label every ingest source instead of leaking its enum value
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.

The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
2026-09-02 12:08:31 +02:00
Marc-André Bombeck df5dd1c160 docs: publish the Bearer arm on the two erasure routes
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.

The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.

Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
2026-09-02 11:51:19 +02:00
Marc-André Bombeck 5856b2a547 auth: let a token prove a second factor for erasure
Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.

Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.

The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.

The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
2026-09-02 11:51:09 +02:00
Marc-André Bombeck a55ecc5a48 Merge origin/main into fix/briefing-locale 2026-09-02 10:51:56 +02:00
Marc-André Bombeck f003b6f413 Resolve job-side locale through the operator default
Every background path that resolved a user row's locale collapsed a
NULL column to English: the nightly briefing warm, the status crons,
the period-narrative warm, reaction lines, the morning refresh, the
briefing push, coach nudges, the reminder workers, and the document and
workout summaries each carried their own copy of
`locales.includes(x) ? x : defaultLocale` or called
`normalizeLocale(user.locale)`. None consulted the instance's configured
default language, so an account that never touched the language picker
got English prose and English pushes on a German-language instance.

Add resolveJobLocale (src/lib/i18n/job-locale.ts): stored user locale
when valid, else the operator's app_settings.default_locale when valid,
else English. The settings read lives beside the other app-settings
accessors. Every job and daily path resolves through it once, where it
has the row; the template helpers keep coerceLocale for a value that is
already resolved, and queue payloads keep normalizeLocale.

The briefing push hands its resolved locale down to the digest, and the
digest to the snapshot read, so the push copy and the digest lines are
in the same language instead of two different fallbacks.

A structural test walks src/lib/jobs and src/lib/daily and fails on a
hand-rolled coercion, a validator applied to a row column, or a row
reader that does not import the helper. It was red against the previous
tree on all three counts.
2026-09-02 10:44:04 +02:00
Marc-André Bombeck ba4e34f09e Tag the briefing cache with its language and refuse a foreign one
The comprehensive-briefing cache (User.insightsCachedText) is one slot
per account with no record of the language it was written in. Every
reader served whatever was there, so a German-language account whose
nightly warm had written English kept seeing English on the dashboard
lead and the Today briefing. The request path only replaced it once the
row was a day old and the provider answered, which with a provider at
its usage limit was never.

Add User.insightsCachedLocale (migration 0335). Both writers store the
locale they generated in; the timestamp-only refresh on an unchanged
snapshot tags the row too, since the fingerprint covers the generation
locale. Every reset that nulls the text nulls the tag.

The snapshot briefing lift and GET /api/insights/generate treat a tag
that differs from the reader's locale as an empty cache: no prose, no
timestamp, the preparing / no-provider state, and the out-of-band warm
enqueued in the reader's locale through the existing path. The
generator's own short-circuit and the POST short-circuit refuse to call
a foreign-language row fresh. A NULL tag (rows written before this
change) is served as before until the next write tags it. The Today
digest derives from the snapshot and follows.

Covered by unit tests on the lift, the route and the generator, and an
integration test that writes the row through Prisma and reads the
snapshot for a reader in another language.
2026-09-02 10:44:03 +02:00
Marc-André Bombeck 7d93af9d09 fix(ai): recognise Claude 5 and Fable models as vision-capable
The Anthropic vision check listed the Claude 3 and 4 families by prefix,
so a key pinned to claude-sonnet-5, claude-opus-5 or a Fable model was
told the provider cannot read images, and lab and document OCR refused
the upload even though the model handles image and PDF blocks.

Replace the per-release prefix list with a rule: any claude-<tier>-<major>
slug from the 4 family upward, plus every claude-fable-* model, counts as
vision-capable. Claude 3 handling is unchanged. The Settings preset list
gains claude-sonnet-5 and claude-opus-5 alongside the existing entries.
2026-09-02 10:32:50 +02:00
Marc-André Bombeck a463206774 fix(ai): stop prefilling the assistant turn on Anthropic JSON calls
The Anthropic client steered JSON replies by appending an assistant turn
containing an opening brace and gluing the brace back onto the reply.
Current Claude models (the 4.6 / 4.7 / 4.8 family, Opus 5, Sonnet 5,
Fable 5 and 5.1) reject an assistant prefill with HTTP 400, so every
JSON surface on the provider (briefing, status cards, document and lab
extraction) failed for a self-hoster on a current model while plain chat
kept working.

Drop the prefill on every model. The request now carries a plain
instruction on the last user turn to reply with a single JSON object and
nothing else, and the reply is narrowed to the outermost object body via
a shared extractJsonObject helper, so a fenced or prefixed answer still
parses. The insights stripJsonFences helper delegates to the same
function rather than keeping a second copy. The tool-use path is
unchanged.

Reported independently by @sweidinger.
2026-09-02 10:28:05 +02:00
Marc-André Bombeck 05d3736d75 Resolve OAuth callback URLs on the server for integration cards
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.

The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.

Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
2026-09-01 21:00:12 +02:00
Marc-André Bombeck 8016d375c6 Merge pull request #880 from TimonBed/lab-visual-improvement
Improve lab range and trend visual alignment
2026-09-01 19:06:09 +02:00
Marc-André Bombeck 60939475b4 Give an impossible reference window no verdict at all
A floor at or above its ceiling fell through to the one-sided branch and
was read as "minimum only", which paints a high reading green. That is
the wrong direction for a transcription error to fail in: the colour
answers a question nobody could answer from that data.

Each one-sided branch now requires the OTHER bound to be absent, so a
present-but-impossible pair lands on the neutral dot instead. It matches
lab-reference-range-bar.tsx, which renders nothing for the same input —
the two components sitting next to each other should not disagree about
what a nonsense range means.

The file had no test. It has one now, covering all five window shapes
plus the zero-width band; with the two conditions relaxed again, exactly
the two new cases fail and the four existing ones stay green.
2026-09-01 18:51:07 +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 7c23b14053 Teach the arrival-wiring mock about isScopedCredential
That file stubs @/lib/api-handler wholesale, so it lists the exports it
knew about when it was written. /api/measurements now reaches for
isScopedCredential to decide source attribution and whether the sync
checkpoint may move, the mock does not provide it, and every test in the
file dies on import rather than on an assertion.

`false` is the honest stub: those cases resolve as a cookie session,
which is exactly what unscoped means, so the route takes its ordinary
path through both rules.

Checked the rest of the tree rather than fixing only the file that went
red — this is the only suite mocking that module for a route which
reaches the new export. The batch route's tests mock at a different
seam and are unaffected.

Not verified here: this environment cannot generate the Prisma client,
so that file is one of the suites which fail to import for an unrelated
reason and the fix could not be run.
2026-08-31 12:14:51 +00:00
Antiheld86 56d6fb180a Make the measurement-token mint cookie-only
The mint used requireAuth() with no scope, which refuses a narrow token
but still admits a cookie-equivalent one. The reach is not the problem —
a wildcard token can already do everything this endpoint does. The
lifetimes are: a native access token lives a day, and what it mints here
lives a year. So a credential that leaked for an afternoon could leave
behind one that outlives revoking it, and the revocation would look like
it had worked.

requireCookieAuth() closes that. It is the helper the passkey
registration and trusted-device routes already use for the same reason,
and the sharing guard recognises it as cookie-only.

The prose said more than the code did, in three places: the route header
and the OpenAPI description both claimed the endpoint "cannot mint
another token", which held only for narrow tokens, and the 403 arm
described a scope refusal that a Bearer never reaches. All three now say
what is true — no Bearer credential reaches this endpoint at any scope,
and a Bearer is refused 401 before the operator switch is read.

Tests follow the status change: the integration case asserting a scoped
token cannot mint now expects 401 rather than 403, since the refusal is
authentication and not scope, and a companion case pins the wildcard —
the credential class this change is actually about — and asserts no
token row is created.
2026-08-31 11:38:16 +00:00
Timon_bed 68a3b57597 Improve lab range and trend visual alignment 2026-08-31 11:56:54 +02:00
Antiheld86 8f61e0bc6a Plainer copy on the measurement token card
The card read like API documentation sitting in a settings page: it named
the Authorization header, spelled out both endpoint paths, and listed the
scope's limits as four clauses. None of that is what someone deciding
whether to mint a token needs, and the endpoint paths were already in the
API endpoints card immediately above it.

Rewrite to the shape the MCP connector card next door uses — a short
title, one line on what the token is for, one line saying it is shown
once, and one sentence on what it can and cannot do. Drop the endpoints
note entirely rather than rephrase it, since the card above owns that.

Say the useful consequence instead of the mechanism: readings arrive as
if entered by hand, so they can be corrected. That is the thing a person
will actually notice.

Translations were revised across all seven locales — the previous German
in particular read as translated English rather than as the rest of the
app, which uses short informal sentences.
2026-08-31 09:50:47 +00:00
Antiheld86 e79709379b Fix duplicate status keys in the new OpenAPI response blocks
`stdResponses` already carries 401, 422 and 429. The three response
blocks added with the measurements:write scope each declared their own
422 (and, on the mint route, a 429) BEFORE spreading it, so the generic
wording overwrote the specific wording and TypeScript refused the
duplicate key. The build failed at auth.ts with "'422' is specified more
than once".

Move the explicit keys after the spread, which is what the MCP mint next
door already does and the only order in which a per-route description
survives.

Worth stating because the failure mode is quiet: had TypeScript allowed
it, the published contract would simply have carried "Request validation
failed" everywhere instead of naming
measurement.create.source_not_permitted and its siblings.
2026-08-31 09:22:38 +00:00