Commit Graph
264 Commits
Author SHA1 Message Date
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 b4577f5c31 test: give every scanning guard a floor on what it found
A guard that compares a sweep against an allowlist, or against "none of
these may exist", passes when the sweep comes back empty. walkSourceFiles
already refuses a short walk; this adds the same refusal for what a guard
does with the walk.

- requireFloor(label, found, floor) throws below the floor and refuses a
  floor of zero.
- scanSourceMatches(root, pattern, { fileFloor, matchFloor }) walks, matches
  and floors both the files read and the hits found.

Floors added, pinned at the current counts:

- coach_usage owner columns: the clauses it actually examined (6).
- measurement reference index: the foreign keys it extracted (2).
- job outcome fact inventory: job files (130, floor 120) and fact keys
  (399, floor 350).
- tone harness: assessment prompt modules (11).
- coach locale default: turn pipeline modules (15, floor 12).
- locale integrity: message bundles (7).
- settings card header coverage and settings anatomy: the walked cards.
- coach metric scope inventory: insight route sources.
- side-effect drift guard: both enums populated, since two empty keysets
  are equal.

The pill-mapper guard walked with globSync, which skips dot-prefixed
directories; it now walks through walkSourceFiles like the rest.
2026-10-01 10:52:48 +02:00
Marc-André Bombeck d00e106ce1 fix(clinician): print every shared statistic with its unit
The clinician view printed bare numbers for every measurement group and
the per-context glucose rows in raw mg/dL, so a recipient read glucose
without a unit and in the wrong one for an account on mmol/L. The PDF's
measurement table had the same gap for the raw glucose series.

Both artefacts now take the unit and the displayed number from one helper
(doctor-report/stat-display.ts): SI for mass, length and temperature as
before, hours for sleep, and the record owner's unit for glucose.

Refs #1067
2026-10-01 08:46:39 +02:00
Marc-André Bombeck 7d05f68f23 fix(http): say when proxy headers go unread, and trust X-Real-IP only at one hop
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every
client address resolves to nothing and all anonymous sign-ins share one
rate-limit bucket, with nothing in the log to say why. The first request
that carries a forwarding header the setting does not let HealthLog read
now logs one warning naming the header and the setting to change.

X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a
single address, so it is now read only with TRUST_PROXY_HOPS=1; with two
or more hops the chain the count describes is X-Forwarded-For.

The upload, import and native batch routes that bypass the proxy now carry
Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every
other response, and so do the proxy's early exits.

The env examples described an unset TRUST_PROXY_HOPS as right for a
directly exposed host; unset means one hop, and that host wants 0.
2026-09-26 22:45:51 +02:00
Marc-André Bombeck 0692017f34 fix(auth): adding a credential beside a second factor needs that factor
On an account with a second factor, TOTP setup, security-key registration
and passkey registration no longer accept the password, a password re-proof
or a session younger than five minutes. A remembered browser skips the
second factor at sign-in and still creates a fresh session, so the password
alone could add a key that then satisfies every gate the factor protected.
Only a TOTP code, a security key, a passkey or a completed second factor
within five minutes opens enrollment there; the Bearer arm of TOTP setup and
security-key registration asks the same of its elevation. Accounts without
a second factor keep the previous rules.

The refusal names the proofs the account can give, and the enrollment
dialogs offer exactly those. A proof that verifies now gives its attempt
back to the shared re-proof budget, including the current password on a
password change.
2026-09-26 22:16:44 +02:00
Marc-André Bombeck 66b1aef40c Merge branch 'fix/encrypt-free-text-and-local-ai' into release/v1.39.3
# Conflicts:
#	src/app/api/import/csv/route.ts
2026-09-26 21:50:05 +02:00
Marc-André Bombeck c86f23c2c0 fix(security): keep ALLOW_LOCAL_AI_PRIVATE_HOSTS=true for the operator's own AI, deprecated
Making `=true` grant nothing would stop a single-user self-host's local
model on a patch update. It is deprecated for one release cycle instead,
in a narrowed and safe form: it still grants a private host, but only to
the operator's own configurations (the instance-wide admin provider and
AI settings saved on an admin account), and only through the pinned
operator-approved dispatcher, so redirects stay forbidden and metadata,
link-local and unspecified addresses stay refused. A non-admin account's
own base URL needs an exact origin in AI_PRIVATE_ORIGINS.

Provider resolution passes the owning account's role to the client
(`operatorTrusted`); the save route and the connection test apply the
same rule to the account being configured. The boot summary now says the
setting is deprecated and names the replacement, and the admin AI
settings page shows the exact AI_PRIVATE_ORIGINS line for the private
origins saved on the instance (every account's Local and gateway URL and
the admin base URL, scheme, host and port only), so the operator can copy
it and drop the old setting.
2026-09-26 20:59:10 +02:00
Marc-André Bombeck 03ccb21227 Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
2026-09-26 20:40:59 +02:00
Marc-André Bombeck ba70e19cf9 fix(logging): scrub every credential prefix the app mints
The log and error redactor missed the registration invite token (hlv_) and
the native sign-in handoff code (hlh_). The filter that keeps secrets out
of the idempotency replay cache and out of relayed upstream error bodies
missed the handoff code and the three MCP OAuth artifacts (hlac_, hlrt_,
hlc_). Both now cover every prefix the app issues: hlk_, hlr_, hls_, hle_,
hlv_, hlh_, hlac_, hlrt_ and hlc_.
2026-09-26 20:10:57 +02:00
Marc-André Bombeck bc69dec8e6 fix(http): trust X-Real-IP only behind a declared proxy
The client-IP resolver read X-Real-IP on every path: with
TRUST_PROXY_HOPS=0, which means no proxy in front and no forwarding header
to believe, and on the path where the X-Forwarded-For chain was shorter
than the declared hops, which means the proxies were bypassed. A caller
sending a new X-Real-IP with each request got a new per-IP rate-limit
bucket each time.

X-Real-IP now gets the trust X-Forwarded-For gets: it is read only when at
least one proxy hop is declared and the request carries no X-Forwarded-For.
A short chain resolves to no address. The reverse-proxy guide no longer
suggests TRUST_PROXY_HOPS=0 for a proxy that sets X-Real-IP; such a proxy
takes 1.
2026-09-26 20:10:12 +02:00
Marc-André Bombeck 0fc83cb0a1 fix(auth): ask for a fresh proof before the record or a credential to it leaves
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.

These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.

On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.

Key rotation keeps its stricter second-factor gate.
2026-09-26 19:54:38 +02:00
Marc-André Bombeck 1a674cf4b0 fix(security): grant private AI endpoints by exact origin, dialled pinned
ALLOW_LOCAL_AI_PRIVATE_HOSTS=true let any user of the instance point the
Local provider or the OpenAI-compatible gateway at any private address
the server could reach, the metadata range included, and a granted host
was dialled with no connect-time pin at all.

AI_PRIVATE_ORIGINS grants a private AI endpoint with the exact-origin
grammar NOTIFICATION_PRIVATE_ORIGINS and NIGHTSCOUT_PRIVATE_ORIGINS use.
A granted call goes through the pinned operator-approved dispatcher:
redirects forbidden, and metadata, link-local and unspecified answers
dropped at dial time even for a granted name. Everything else stays on
the public pin. The Local client (buffered and streaming) and the
gateway and admin-key tags of the OpenAI client all take their policy
from one function.

The host-list form of ALLOW_LOCAL_AI_PRIVATE_HOSTS keeps working with the
same pinning; a never-grantable literal or a host:port entry is refused
with a one-time warning. Its "true" form grants nothing any more and is
reported in the boot readiness summary with the replacement.

A failed lab-report scan no longer puts the endpoint's response body into
the wide event; status and model stay. The response to the caller never
carried it.
2026-09-26 19:46:11 +02:00
Marc-André Bombeck 42d47e0877 Merge branch 'fix/auth-takeover-hardening' into release/v1.39.3
# 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
2026-09-26 19:14:05 +02:00
Marc-André Bombeck 903f0a9a22 refactor(medications): drop the legacy today walker and its own zone solver
expandTodayIntakes had no caller left once the today projection moved onto
the recurrence engine and localHmAsUtc; it carried the last private
wall-clock solver in the medication code. The recurrence engine now takes
its offset and UTC-day helpers from the tz module instead of local copies.
2026-09-26 17:07:02 +02:00
Marc-André Bombeck 026e8fc1ed Merge branch 'fix/compose-env-and-claims' into release/v1.39.3 2026-09-26 17:00:46 +02:00
Marc-André Bombeck 2139b49c47 fix(http): stop reading request bodies of up to 512 MB before authentication
Next reads the body of every non-GET request the proxy matches into
memory before the route runs. The ceiling was 512 MB so the Apple Health
importer could receive whole archives, which let any anonymous POST to any
API path make the server hold that much.

The ceiling is now 1 MB (proxyClientMaxBodySize). The fifteen routes whose
real bodies are larger are left out of the proxy matcher and read their
own body after authenticating; apiHandler gives them the proxy's refusals
(worker-only, demo mode), security headers and a CSP. safeJson now counts
bytes while it reads and stops at its cap instead of reading everything
first, the CSV and dose-history imports do the same, and a multipart
backup upload is counted while it is read. A test compiles the matcher the way
Next does and checks it skips exactly the listed routes and every route
with a larger body is listed.

Local lab OCR runs its WebAssembly engine in a worker served from
/tesseract/. Those responses, and the /labs and /documents pages that start
the OCR, now carry 'wasm-unsafe-eval' in script-src; no other route does.
2026-09-26 16:31:03 +02:00
Marc-André Bombeck 6819ce70c6 fix(ops): read an empty TRUST_PROXY_HOPS as unset
The shipped compose file passes TRUST_PROXY_HOPS as an empty string when
the operator never set it, and the parser rejected the empty string as an
invalid value, throwing wherever the client address was read. Empty now
means the default of one trusted hop.
2026-09-26 16:17:16 +02:00
Marc-André Bombeck 62cef974bf fix(medications): scope a snooze to its slot and key report adherence by id
A snooze lives on the medication row, and the reminder tick applied it to
every slot of that medication. A Telegram "skip" of the morning dose
snoozes to the end of the day, so the evening dose of the same medication
was neither reminded nor marked missed by the reminder path. A snooze now
holds only slots whose reminder window had opened when it was set, dated by
the row's updatedAt that the snooze write stamps (the same reading the skip
and pin suppression already uses). A later edit to the row can only widen
the hold back to the old behaviour.

The doctor report kept adherence in a map keyed by medication name, so two
medications with the same name (an ended course and a new one, two
strengths) collapsed into one row and the second overwrote the first. The
map is keyed by medication id now and carries the name as the label; the
report's medication list carries the id. The PDF, the FHIR adherence
observations, the MCP visit summary, the clinician view and the GLP-1
section read it that way.
2026-09-26 15:38:20 +02:00
Marc-André Bombeck 3c3e5ceab7 fix(config): treat an empty environment variable as unset
docker-compose.yml forwards its whitelist as "${NAME:-}", so a variable
the operator never set reaches the process as the empty string. Reads
written as a nullish fallback kept that string: the weather client built
an empty base URL and every geocode failed with ERR_INVALID_URL, and the
IP geo lookup requested a relative path.

Reads with a fallback now go through src/lib/env.ts (envValue, envOr,
envFlag), which treats empty and whitespace as unset. IP_GEO_LOOKUP_DISABLED
accepts 1, true, yes and on instead of only "1". A structural test refuses
a ?? fallback written directly against process.env anywhere in src/, and
the compose whitelist guard counts the helper's call shape as a read.
2026-09-26 15:20:42 +02:00
Marc-André Bombeck 14f0fc4d4f feat(body-sites): record a body site on conditions and add a body-site view
A condition (illness episode) can carry where on the body it sits, in the
person's own words and encrypted at rest, plus an optional side (LEFT,
RIGHT, BOTH). Same enum, codec and reasoning as the procedure's site.

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

Refs #1025
2026-09-25 21:55:14 +02:00
Marc-André Bombeck 94ec9ab932 Merge remote-tracking branch 'origin/release/v1.39.1' into feat/medication-record-only 2026-09-25 09:20:15 +02:00
Marc-André Bombeck b4c2706ec9 feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every
existing and new row). With it off the dose, dates and schedule stay stored,
but nothing derives a due dose from them: the reminder worker, the manual
reminder check, the today projector, the auto-miss pass, the low-stock pass,
the doses card, the today list, the outstanding-dose badge, compliance and
its heatmap, the doctor report, the Coach, the BP-status gate, insights,
targets, achievements, the efficacy view and the MCP schedule tool all skip
it.

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

A structural guard freezes the set of schedule readers: each one filters on
the flag, derives from dueSchedules, shapes the wire with scheduleWireFields,
or is allowlisted with the reason it derives nothing due. The flag travels
through backup, restore and the medication CSV export.
2026-09-25 08:57:10 +02:00
Marc-André Bombeck 0b28498d83 feat(doctor-report): add a surgical history section
New structured leaf SURGICAL_HISTORY in the history group and in the
standard template: every procedure that took place, oldest first, with
its date, what was done (the visit's reason), the body site with its
side and the outcome. Lifetime reference data like the immunization
record, so it ignores the report window. The PDF prints it as a table
and the clinician share page renders it; saved selections do not widen.

The FHIR bundle carries no Procedure resource, on the same terms as
visits: no REST route or capability entry exists for it.

Refs #1025
2026-09-25 08:11:42 +02:00
Marc-André Bombeck 06251d7219 fix(ai): read AI switches, modules and consent afresh inside background jobs
A background job opens one wide event for its whole run, and the per-request
memo cached the operator switches, module availability, module preferences,
provider presence and consent kinds on it. The nightly status passes loop
over every candidate inside that one event, so a switch turned off mid-run
did not stop the remaining users, and the wire re-check read the same cached
answer.

memoizePerRequest takes a freshInBackground option; every input that decides
whether AI work may run sets it, so a background caller reads them on every
call while a request still reads them once.
2026-09-24 12:08:39 +02:00
Marc-André Bombeck 7c757520f1 feat(ai): capability loader, gates and refusal envelope
loadAiCapabilityInputs reads the switches, module access, provider
presence and active consent kinds for one record in parallel, memoised
per request, and fails closed to check_failed. requireAiCapability,
getAiCapability and aiCapabilityForJob resolve for the request's
record, masked to the active grant like moduleAccess, or for a job's
record under the worker's authority.

apiHandler renders AiUnavailableError as one envelope with errorCode,
capability, reason and module. Existing codes stay: the operator
family keeps assistant.disabled.<switch>, modules keep module.disabled,
consent keeps consent.ai.required. New codes are ai.record.notPermitted,
ai.provider.none and ai.unavailable.
2026-09-24 07:46:12 +02:00
Marc-André Bombeck 15f0e499f9 refactor: share the secret-shape matcher from one leaf
The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged.
2026-09-13 13:56:58 +02:00
Marc-André Bombeck 6ef139c7b2 Remove the anonymous account-discovery endpoint
`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.

Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
2026-09-11 13:56:49 +02:00
Marc-André Bombeck 94bed12edc fix(egress): keep loopback grantable under an exact operator origin
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.

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

Refs #947
2026-09-10 19:08:04 +02:00
Marc-André Bombeck 9c306f04e7 fix(notifications): name the never-grantable refusal and redact the grant log
A loopback, link-local or metadata target answered
private_origin_not_approved with a message telling the user to have the
operator list it, and the parser refuses such an entry forever: a dead
end the route test even pinned. Those targets now answer
private_origin_not_grantable (catalogued, on all four routes, translated
in seven locales) with a message that says no grant can open them and
to use the relay's LAN address. Both senders carry the reason into the
outcome so the test button forwards the same code.

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

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

Refs #947
2026-09-10 18:45:57 +02:00
Marc-André Bombeck 984a75d036 fix(api): name the private-origin refusal on webhook and ntfy routes
The save routes evaluate the same policy the senders do. A target on a
private network the operator has not listed is a 422 with
meta.errorCode = private_origin_not_approved and a message that names
NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a
listed private address saves although the plain public floor would
have refused it. The settings schemas take the target predicate as a
parameter so the validator module never reads the environment; the
exported constants keep the public floor for the OpenAPI contract.

The test routes forward the sender's refusal as the same 422 and code
instead of a bare 500, so the card can say why the test failed rather
than sending the operator to the wide-event log. A delivery fault is
still a 500. Both test routes and the dispatcher reach the same sender
function, and a structural test holds them there.

The OpenAPI descriptions name the code on all four routes; the test
button's error map gains the code in every locale.

Refs #947
2026-09-10 16:46:13 +02:00
Marc-André Bombeck 6c75c4f39f feat(notifications): honour NOTIFICATION_PRIVATE_ORIGINS on send
A self-hoster's Gotify or ntfy on a private network was refused at
dial time with no operator override: both senders pinned the public
resolver, and the two grants that exist for other surfaces were never
read here. Add the third grant, NOTIFICATION_PRIVATE_ORIGINS, a comma-
separated list of exact origins read from the server environment.

A listed origin runs under the operator-approved pin: still resolved
and pinned inside the connector, redirects forbidden, loopback and
metadata answers refused. An origin not listed keeps the public pin it
always had. A malformed entry is logged once and grants nothing, and
the valid entries beside it keep working, so a typo in one relay's
origin cannot take every public webhook down with it. There is no
`=true`, CIDR, wildcard or suffix form.

One function, evaluateNotificationTarget, gives the verdict for both
senders, so the test button and the scheduled reminder cannot disagree.
A refusal now carries its own reason (`*_private_origin_refused`) and
an errorCode instead of collapsing into a network error, and a send
that used the grant marks the wide event with
`notification.egress.private_origin` and the channel plus origin.

The pin inventory keeps both senders in the conditional shape and
refuses a bare true or false in either.

Refs #947
2026-09-10 16:39:24 +02:00
Marc-André Bombeck ea3a8deed2 fix(egress): share the origin-grant grammar and floor its ranges
Lift the Nightscout allowlist parser and verdict into
src/lib/private-origin-policy.ts so a second operator grant can reuse
one grammar instead of a second parser. The Nightscout validator keeps
its exported names and behaviour; its tests are unchanged.

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

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

Refs #947
2026-09-10 16:34:04 +02:00
Marc-André Bombeck 6a2282fef8 Merge Retry-After on every refused request, stable list pagination and the corrected error contract 2026-09-10 05:10:05 +02:00
Marc-André Bombeck dd8a724039 fix(api): floor Retry-After at one second on a refusal
`resetAt` is written by Postgres as `NOW() + interval` and subtracted from the
app process's clock, so a row that still refuses can read as already expired
here. The old floor let that render as `Retry-After: 0`, which tells a client
to retry immediately into the ceiling that just turned it away — a contradiction
of the status it rides on, and a hot loop for anything that honours the header.

One second is the honest minimum on a 429. The rounding-up rule and the reason
for it are unchanged; only the floor moves.
2026-09-10 04:55:47 +02:00
Marc-André Bombeck dd3844a7a2 fix(api): dress only the 429s a limiter actually refused
The request-scoped capture kept the last verdict of any kind and fell back
to it when nothing had refused. That fallback fires in exactly one state —
no limiter refused this request — which is the definition of "this 429 came
from somewhere else". So a daily AI budget that is spent until midnight, an
hourly generation quota, and a provider-side 429 relayed onward all went out
carrying our own bucket's numbers: a remainder that says there is room left
and a Retry-After measured against an unrelated window. On the AI paths the
captured window can also expire while the provider call runs, so the dressed
delay could land at zero and tell the client to retry immediately into the
ceiling that had just refused it.

The cell now keeps refusals only. A request every bucket let through reports
nothing, and a 429 from another cause leaves undressed, which is correct:
there is no bucket to describe. The two-limiter rule is unchanged — the last
refusal wins, and a refusal still outranks a pass.

The unit case that pinned the fallback is turned round, and both directions
now run through the real wrapper: a limiter refusal gets the headers, a 429
raised without one gets none. The integration file gains the two shapes it
was missing — a route that consults two buckets must report the one that
refused, and a route whose 429 comes from the daily budget must carry no
rate-limit headers at all.
2026-09-10 04:55:19 +02:00
Marc-André Bombeck 21dd7c7e48 fix(api): give the generic auth refusals a stable errorCode
`requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa` and
`requireBearerAuth` all refused with a bare prose string and no `meta`.
So the single most important branch a native client makes — refresh the
token and retry, drop the session and show the login screen, or stop
retrying because this credential will never work — could only be made by
string-matching the English sentence "Token expired". Any rewording of
that string, or a localisation pass, silently turns a recoverable
refresh into a forced logout for every already-shipped build.

The mechanism was already here and these gates were the ones skipped:
the assistant, consent, step-up, module and sharing refusals all carry
`meta.errorCode`, and `/api/auth/refresh` emits `auth.refresh.*`. So
`HttpError` takes an optional code and the wrapper puts it in the same
field. Five values, dot.case like the majority: `auth.missing`,
`auth.token.expired`, `auth.token.invalid`, `auth.scope.insufficient`,
`auth.admin.required`. A revoked token reads invalid, not expired —
refreshing a revoked token loops forever.

Additive: an HttpError thrown without a code serialises byte-identically
to before, which is most of them. The published 401 description now
enumerates the codes and says what a client should do with each.
2026-09-10 04:24:34 +02:00
Marc-André Bombeck 08100b6207 fix(api): put Retry-After and the rate-limit triple on every 429
A refused client had nothing to back off against. `rateLimitHeaders()`
emitted `X-RateLimit-Remaining` and an ISO `X-RateLimit-Reset`, no
`Retry-After` and no cap, and only about 70 of the 199 route files that
consult a limiter attached even those two. The rest answered with a bare
prose envelope, including every endpoint an offline replay queue hits:
the four batch ingests, `/api/sync/changes` and `/api/auth/refresh`. A
first-run Apple Health import trips the 60-per-minute batch ceiling
reliably, and the only strategy left to the client was a blind backoff.

Threading headers through 129 handlers is 129 edits the next new route
forgets, so the verdict is carried instead: `checkRateLimit` records it
in a request-scoped cell and `apiHandler` dresses any 429 that leaves
without headers. A route that already built its own is left alone —
`Retry-After` is the presence test, and it comes from the same builder.

`Retry-After` is whole seconds rounding UP (RFC 9110 §10.2.3), floored
at zero. `X-RateLimit-Limit` is new and needs the cap, so the limiter
result carries it. `X-RateLimit-Reset` keeps its ISO form: it is the
wrong convention for the name, but it is on the wire today and changing
it is a decision of its own.

The cell lives in its own leaf module because 177 test files replace
`@/lib/rate-limit` wholesale with a mock factory, and `apiHandler` must
not depend on anything those factories stand in for.
2026-09-10 03:55:46 +02:00
Marc-André Bombeck 9a2b1f0955 feat(glucose): name the bucket for readings with no meal-time context
`glucose_context` is nullable, but every surface that fanned out over the
four enum members treated the named list as exhaustive. A meter synced
through Apple Health writes no HealthKit meal-time metadata at all, so an
account can hold nothing but rows the list cannot match.

Add one shared notion of the untagged bucket — the name the Coach's glucose
block already used — with a resolver that files NULL, blank and unrecognised
values under it, and a grouper that walks the named contexts first and the
untagged bucket last. The untagged bucket borrows the RANDOM threshold: an
untagged reading is a spot reading, and a fifth threshold would give the user
a second editor row judging the same readings by a different number.

Labels in all seven locales.

Refs #943
2026-09-09 23:03:13 +02:00
Marc-André Bombeck 076c2f9c5d fix(auth): sign out the other devices, lose a refresh to a revocation, key the replay cache by authority
"Sign out everywhere" revoked every other refresh token and nothing else.
A native login is two rows, and the Bearer check consults only the
paired access token, so the other phones kept working until that token
expired, up to a day on the default policy. The route also handed
destroyOtherSessions a session-kind credential for a Bearer caller, whose
session.id is the ApiToken row id; the exception matched nothing and the
calling phone revoked its own refresh token. destroyOtherSessions now
finds the paired access tokens through the refresh rows and revokes them
inside the same transaction, reporting the count, and the route names a
Bearer caller by its access-token hash. Programmatic tokens are untouched.

rotateRefreshToken marked the consumed row under usedAt: null alone, so a
revocation landing between the read and the write was overtaken and a
pair minted after the family ended stayed live. The guard now includes
revokedAt: null; on a lost race the minted pair is retired and the reason
distinguishes a revoked family from a double consume.

withIdempotency consulted the grant only at replay time, and only for its
existence; a narrow-scoped token was checked only on the delegated arm.
Both move into the cell key: a delegated cell carries the grant's id,
level and scope fingerprint, a narrow token's cells carry the token, and
a request with no live grant neither reads nor claims a cell. Cookie and
wildcard own-record cells are keyed byte-for-byte as before.

Tests: integration for the paired access tokens (cookie and Bearer
caller), a deterministic revoke-mid-rotation race against Postgres, unit
coverage for the new cell keys, and the structural guard moved to the
new invariant.
2026-09-08 20:23:30 +02:00
Marc-André Bombeck 031600d69d fix(format): carry the date-order preference to every numeric date
The per-user date order reached the entry form and nothing else. A
reporter switched away from MM/DD/YYYY, the date picker followed, and
the dashboard chart axes and the measurements list kept the old order.

`makeFormatters` took `timeFormat` and `dateFormat` as optional
parameters defaulting to "AUTO", so six of the seven constructions in
the tree silently rendered the locale default and nothing anywhere
said the setting had been dropped. Drop both defaults. The compiler
then names every caller: the chart bucket labels, the legacy
`@/lib/format` helpers the measurements list renders through, the
medication compliance and efficacy charts, the doctor-report PDF and
the clinician share view.

Thread the preference:

- `makeBucketLabelFormatters` takes the order; the health and mood
  charts read `useDateFormatPreference()`. The UTC pin decides which
  day the label names, the preference decides how it is spelled, and
  the two are separate questions.
- `format.ts` reads `readStoredDateFormat()` beside the timezone and
  hour-cycle mirrors it already read.
- The PDF takes `dateFormat` as a required render option, alongside
  `timeFormat`, which was optional for the same reason.
- The clinician share view spells the OWNER's dates the owner's way.
  It also picked up the owner's hour cycle, which it had been ignoring
  while the PDF route beside it already honoured it. Both resolve
  through the new `resolveUserFormatPreferences`, the counterpart to
  `resolveUserTimezone`.

Textual months stay on the UI locale. The preference pins numeric
field order by rendering through de-DE / en-US / en-CA, and a textual
month through those would change the month name's language.

Add `date-order-explicit-formatter-guard`: a raw numeric date renderer
must take its locale from the preference or carry a written reason,
and a formatter construction may not hard-code "AUTO" for the order.
Its allowlist holds thirteen calendar-day key builders, each with the
reason it has no order to get wrong. The sibling timezone guard passes
every one of them, because naming a calendar says nothing about
spelling a date.

Refs #922
2026-09-05 17:06:34 +02:00
Marc-André Bombeck 5556478691 Make uploads and every other write work over plain HTTP
crypto.randomUUID() is [SecureContext] in the Web Cryptography IDL, so a
page served over plain HTTP never gets it. Measured in Chromium against
http://<lan-ip>: isSecureContext false, randomUUID and subtle undefined,
getRandomValues still a function.

The document vault minted its queue id and Idempotency-Key that way, inside
the file-input change handler. On a LAN self-host the call threw before a
queue row existed, so picking a file produced no upload, no error and no
trace — the picker simply looked inert, and the only way through was to
reach the app over HTTPS. The client transport has minted a default
Idempotency-Key the same way since v1.37.18, which put every apiPost and
apiPatch on the same footing: on such a host nothing wrote at all.

Add randomId(), which prefers the platform call and otherwise assembles a
v4 UUID from getRandomValues — the same CSPRNG, the same 122 bits, no
secure context required — and route every browser-side call site through
it. Nothing in an upload needs one: the bytes travel over multipart XHR and
every cryptographic operation happens on the server. Passkeys, web push and
home-screen install still need HTTPS; browsers withhold those outright.

A structural guard fails a direct crypto.randomUUID() anywhere outside the
two server-side files that may keep it, so the class cannot return one call
site at a time.

Also point the CSV and JSON import cards straight at the import guide. They
resolved through a redirect under the integrations tree, which is the wrong
place for a guide that is not an integration runbook. The suite that
covered those links pinned the old spelling rather than the guarantee; it
now asserts an absolute docs-site URL, no internal path, and new-tab
hygiene.
2026-09-05 16:22:39 +02:00
Marc-André Bombeck d6bc858899 fix: refuse an unrecognised request field where the caller can act on it
A `z.object()` without `.strict()` strips an unknown key and parses
clean, so a caller that misspells a field name gets a 200 and loses the
value with nothing to tell it. The worst of them was the Coach
question-dismiss endpoint, where an omitted `question` means "dismiss
all": a typo in that key wiped every pending question and answered as
though one had been dismissed.

Twenty-four inline schemas across twenty-two endpoints are strict now —
the settings toggles, the restore and bulk-delete endpoints, message
feedback, the admin user update, the onboarding acknowledgements, the
provider chain, the chart overlay preferences, the medication extractor
and the two client-side reporters.

The strip stays where it carries weight. Sync ingest, device
registration and the layout PUTs are built out of fields that shipped as
additive and optional, on the promise that a server which does not know
one ignores it; an app running ahead of the install it talks to is
routine here rather than exceptional, and refusing would turn a dropped
field into a refused batch — a year of history lost to one row, a
dashboard that will not save, a phone that quietly stops registering for
push. A read filter discards nothing when a stray query parameter rides
along, the OAuth endpoints are required by RFC 6749 and RFC 7591 to
ignore parameters they do not recognise, and an import file comes from
another version or another tool by definition. Each is registered with
its reason in a new inventory test, so a schema cannot join them without
someone making the call. That test parses the tree rather than grepping
it: fourteen of these schemas are written `z\n  .object({…})` and a
`z.object(` sweep never saw them.

`sanitiseZodIssues` renders `unrecognized_keys` instead of passing it
through. Zod leaves `path` empty and puts the names in `issue.keys`, so
the sanitised issue named nothing at all and the caller was told only
that its body had failed. The names now ride a bounded `keys` field and
the message is rebuilt from them, capped at ten keys of 64 characters
and restricted to identifier characters, so a caller-supplied key cannot
echo back verbatim through the error path or into the audit ledger. The
names survive `stripValuesFromMessage` because a field name is
structure, not user-typed content.
2026-09-03 05:49: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 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 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
Antiheld86 cba7a0fce5 Add a self-service measurements:write API token scope
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.

Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.

The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.

A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.

Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.

Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.

Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
2026-08-31 08:46:16 +00:00
Marc-André Bombeck 511ddf5de1 Send the offline-geo admin notice once per configuration state
The sent-latch was a module-level boolean, so every worker boot re-sent
the same notice while the host's configuration never changed (#851).

Anchor the notice in the notification_events ledger instead, through the
same claimNotificationEvent helper the reminder schedulers use. The
anchor is unwindowed: entering the unconfigured state sends once per
admin, staying in it is silent across any number of restarts, and the
anchor is released when the state exits — databases configured, or
IP_GEO_LOOKUP_DISABLED set — so a genuine re-entry notifies again.

Three consequences worth naming:

- A host with lookups disabled is no longer notified at all. With no
  egress there is nothing the notice warns about, and the disabled state
  counts as an exit, matching the notice's own remediation advice.
- The evaluation now keys on offlineGeoReady() rather than the ASN
  reader, so a host with only the ASN database mounted (city lookups
  still egress) hears about it, where before it stayed silent.
- The retention prune excludes the new ADMIN_CONFIG_NOTICE event type:
  a state-scoped anchor has a reader for as long as the state holds, and
  the 90-day horizon delete would have re-opened the repeat on a slower
  clock.

Refs #851
2026-08-29 19:20:33 +02:00
Marc-André Bombeck 6ff81b2717 Merge branch 'pr839tmp' into feat/korean-locale
# Conflicts:
#	CHANGELOG.md
2026-08-28 21:59:36 +02:00
Marc-André Bombeck 7aecae863d Teach the pin inventory the conditional openai-client shape
The SSRF pin inventory asserted a literal unconditional pin in the
openai-client; the previous commit made that pin conditional for the
two person-typed base-URL tags. The guard now asserts the exact
ternary — allowlist helper for gateway/admin-key, literal true as the
floor — so neither the floor nor the condition can drift silently.
2026-08-27 08:56:53 +02:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
2026-08-26 16:42:45 +09:00