Commit Graph
6684 Commits
Author SHA1 Message Date
Marc-André Bombeck ad5d154561 Merge branch 'feat/v1462-scale' into release/v1.46.2
# Conflicts:
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-29 10:18:22 +01:00
Marc-André Bombeck a9c2e25099 fix(rollups): trail the rollup window from the caller's moment
readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
2026-10-29 10:13:22 +01:00
Marc-André Bombeck f99f87eca6 feat(jobs): tell admins when a system job keeps failing
A full rollup backfill that failed on every boot showed only on the
admin page's card of failing background jobs. From the account's side
the only symptom was empty dashboard cards, and nothing pointed the
operator at the card (#1298).

A new hourly evaluation reads the newest terminal runs of every queue
whose failure has no account-facing surface (the system audience in
subsystem-surface.ts) from pg-boss's own ledger. A failed run adds its
attempts, retries included, and the first successful run ends the
streak. Three failed attempts in a row send every admin a SYSTEM_ALERT
once, a reminder at most once a day while the queue keeps failing, and
one notice when it runs successfully again: the decision the backup
health notices use, with the same compare-and-set claim, so two workers
never send a notice twice.

The memory is one row per announced queue in system_job_alert_notices
(migration 0490). The notice names the queue, the streak and the last
error, and opens the admin status page.
2026-10-29 09:54:43 +01:00
Marc-André Bombeck d0be3ae106 Merge branch 'feat/v1462-auth' into release/v1.46.2 2026-10-29 09:45:37 +01:00
Marc-André Bombeck a571dcc47a refactor(measurements): drop a fold-source helper nothing calls
The ingest guard reads the per-source map through the folded-window rules, so the standalone predicate had no caller.
2026-10-29 09:43:37 +01:00
Marc-André Bombeck f92889ab88 refactor(auth): keep the account payload handler and chat schemas in size
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
2026-10-29 09:42:31 +01:00
Marc-André Bombeck 07cf9bba3e fix(measurements): hold the dense fold back while an account's rollups rebuild
The full rollup backfill refills each day's figures from the raw
readings, and the dense fold deletes those readings once it has their
hourly means. On a large account the backfill can run longer than the
ten minutes the fold's boot job waits, and a year it reached after the
fold had taken it would compute its days from hourly means: the day
count, minimum and maximum of every folded day would change.

The consolidation walk now asks a per-account deferUser hook before it
reads an account, and both entries of the dense fold (the per-account
boot job and the nightly drain) pass isRollupFullBackfillPending, which
looks for a queued or running full fold of the account in pg-boss. The
account is folded on a later run, once its rollup tier is rebuilt.
2026-10-29 09:28:31 +01:00
Marc-André Bombeck 919d64947c feat(mental-health): name the Dutch and Belgian crisis lines
A Dutch reader whose screener answer raised the safety path saw only
the international set, because no Dutch set existed.

The Netherlands gets 113 Zelfmoordpreventie (113, the free 0800-0113
and the chat on 113.nl), Belgium the Zelfmoordlijn (1813,
zelfmoord1813.be), both behind 112. The app's one Dutch bundle is read
in both countries and the account keeps no country, so a bare "nl"
lands on a combined set that names each line with its country, the way
the German and Portuguese sets already do; an explicit nl-NL, nl-BE or
region "be" gets its own country's set.
2026-10-29 09:22:00 +01:00
Marc-André Bombeck 6f60812132 fix(ai): accept every app language on the chat and status requests
The Coach chat, the document chat and the fenced chat declared their
locale field as de/en, so a body naming French, Korean, Brazilian
Portuguese or any other shipped language was refused with 422 before
resolveServerLocale, which carries every locale, ever saw it. The same
pair sat on the profile update, so an account could not store any other
language there either, and the stored language is what the cookie-blind
background work (the Coach nudge, notification texts) writes in.

All four now take z.enum(locales), and the published contract for the
metric, insight and biomarker status queries and the profile update
lists every UI locale instead of two.
2026-10-29 09:19:56 +01:00
Marc-André Bombeck 65fe5c34e3 fix(dashboard): welcome a new account instead of welcoming it back
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.

/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
2026-10-29 09:13:48 +01:00
Marc-André Bombeck 107db1bb55 feat(admin): let an admin delete another account through the grace window
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.

DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.

The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
2026-10-29 09:10:26 +01:00
Marc-André Bombeck ef141f457b test(i18n): lower the version-stamp baseline for the provider
The locale effect and its dated comment left the i18n provider with the
previous commit, so the ratchet records one stamp fewer.
2026-10-29 09:09:35 +01:00
haritowa 69d46f0add test(ai): pin the walk past a retired model on the default Codex list
OpenAI retired the 5.x line from Codex for ChatGPT sign-in in steps: GPT-5.2
and GPT-5.3-Codex on 2026-05-26, GPT-5.4 and GPT-5.4 mini on 2026-08-31,
and GPT-5.5 on 2026-10-14. The backend answers a retired model with 400
model_not_found and "not supported when using Codex with a ChatGPT
account".

Pin that exact refusal as a reason to try the next slug, pin that an
account refused the head of the default list walks on to the next rung
and caches it, and keep the long-retired models off the default list.
2026-10-29 09:09:02 +01:00
haritowa acf58923f7 test(ai): pin the step-up when gpt-6.1-sol refuses effort none
gpt-6.1-sol, a rung on the default list, answers reasoning "off"
(effort none) with 400 unsupported_value and lists low as its lowest
effort (seen 2026-10-24). The client must stay on the slug, retry with
low and remember it, not walk on as if the model were retired.
2026-10-29 09:08:32 +01:00
Marc-André Bombeck bf0142a626 fix(ai): give background work the High room on a model that always reasons
Background jobs ask for medium reasoning at most, and status cards ask for
none. A model that cannot be switched down (GLM behind a vLLM gateway, for
instance) ignores both, spends the whole room thinking and returns only
reasoning, so the daily briefing and the status cards fail as
reasoning_only and the chain pauses. The Coach on High works because it
leaves 12k tokens to think in.

The chain runner now marks every call of a job walk as unattended, in one
place rather than at each call site. The gateway and local clients give
such a call at least the High thinking room once its model has answered
with reasoning alone. The room has to ride on the #1267 repeat with none:
one call may send two requests, and a once-a-day job meets an expired
lesson every morning, so a room that waited for the unswitchable lesson
would never reach the first briefing of the day. It is a ceiling, not a
charge: a model that does stop at none never touches it. Calls a person is
reading, the Coach included, keep the room of their own level, and the
requested effort is unchanged.

The room calculation both clients shared is now one function.

Reported by @Tio61 in #1276.
2026-10-29 09:07:37 +01:00
Marc-André Bombeck 9eed407ccc feat(measurements): fold Google Health and Oura heart rate like the exports
The dense hourly fold only took Apple Health and Health Connect samples,
so heart rate from the provider syncs that store one row per sample
stayed raw forever. One self-hosted account held about 25 million
Google Health pulse rows after a full-history backfill, and every
reader and fold that falls back to the raw table paid for it (#1297).

The fold now walks a map of sources to the dense types each stores
sample by sample: every dense type for Apple Health and Health Connect,
heart rate for Google Health and Oura. Their daily SpO2 values are no
intraday stream and stay as they are; Fitbit does not sync intraday
heart rate, and WHOOP, Withings and Polar write dense types only once
per recovery, night or spot check. Each source folds into its own
hourly stats rows, and the DAY rollup is still taken from the raw
samples before they go.

The fold deletes the samples it averages, and the provider syncs write
first-write-wins on the externalId, so a backfill or full sync would
have put every folded sample back. Google Health would even have
re-keyed an hourly row into a sample that fell on its anchor through
the natural-key rescue. The folded_window rule now has one hour rule
per fold source, and both syncs drop the samples of an hour the fold
already took before they write. Recent samples cost no lookup.

The boot discovery of the fold probes each account and source for the
first raw sample it would take instead of grouping every such row, and
the one-time fold repair stays on the two sources it was written for.
2026-10-29 09:06:17 +01:00
Marc-André Bombeck 2490a2d218 fix(i18n): mirror the language to the account only when signed in
The provider sent PUT /api/auth/me/locale on mount and on every switch,
signed in or not. Signed out the route answers 401, the once-per-tab
marker was never set, and every signed-out page load repeated the
refused write.

The mirror moves out of the provider into LocaleServerSyncBridge, which
watches the account payload without fetching it and writes only once an
account is known. The choice made on the sign-in page therefore reaches
the account right after signing in, and the marker is keyed on the
account as well as the language, so another account signing in on the
same tab is written for too. The browser test stub no longer treats the
PUT as ambient, which is what lets its test see a stray write.
2026-10-29 09:00:04 +01:00
Marc-André Bombeck bbef4eea99 feat(auth): let a single-sign-on account confirm it is you at its provider
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).

GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.

recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.

The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
2026-10-29 08:56:51 +01:00
Marc-André Bombeck e2998183c8 fix(measurements): delete a blood-pressure reading as one
Systolic, diastolic and the cuff's pulse are three rows with no link
column, and the single DELETE tombstoned only the row it was given, so
deleting a reading's systolic left its diastolic and pulse behind as half
a reading. The bulk delete had the same gap.

A reading is the rows with the same owner, the same source and the
identical measuredAt: that is how the entry form, the MCP write, a
Withings measure group and a HealthKit correlation all store one, and how
the FHIR read pairs a diastolic onto its systolic. The looser five-minute
pairing of the chart reads is right for drawing a point and wrong for
taking rows away.

Deleting a systolic or diastolic now tombstones the rest of its reading in
the same transaction with the same deletedAt, each row with its own
syncVersion bump so paired clients see every tombstone. The restore route
brings back the rows deleted together with a named half (identical
deletedAt), so the undo, which names only the row that was tapped, puts
the whole reading back, from the web list and from any client that calls
it the same way. A pulse is still deleted on its own, and a row deleted at
another moment stays deleted. The response shapes do not change; the
OpenAPI descriptions say what the counts now include.
2026-10-29 08:56:19 +01:00
Marc-André Bombeck 1d8e2e0ee3 fix(rollups): fold the full backfill a year at a time under its own budget
On an account with tens of millions of readings the boot full backfill
never finished. It ran four aggregates over the whole five-year window,
each held to the connection's 60 s statement limit, so every attempt
was cancelled with 57014, the reading extremes added by 0417 stayed
NULL, and the readers that need them fell back to the raw table and
timed out as well (#1298).

The full backfill now folds the window one UTC calendar year per pass,
newest first, and every statement runs under its own five-minute limit,
the shape the pulse day-mean rebuild already uses. Calendar-year cuts
write exactly the buckets one pass writes: day, month and year buckets
fall inside one window and a week across New Year is folded whole by
both. Each window commits on its own, so a run that reaches its job
budget hands the remaining years to a follow-up job instead of failing
and starting over on the retry.

The boot discovery read every live reading in the window to find
uncovered days, the same scan that kept the fold from finishing. It
now walks the days per account and type off the live index, so its
cost follows the number of days with readings, and runs under the same
limit. The full backfill code moves to its own module.
2026-10-29 08:52:36 +01:00
Marc-André Bombeck d88313e929 fix(labs): move document lab values filed under their confirmation day
Before the confirm step dated an undated value by its document, a lab
value confirmed from a report that prints its collection date only in the
header was stored with takenAt set to the moment of confirmation. The fix
covers new confirms; the values already filed that way stay on the wrong
day until something moves them.

A one-shot maintenance job, sent at boot only while there is something to
repair, moves exactly those values to the document's report date (else its
filing date) at noon UTC, the same rule the confirm step now uses. A value
qualifies only when the chain is certain: written by the document confirm
path, named by exactly one approved observation fact, takenAt still within
five seconds of its own createdAt, no calendar date of its own in the
decrypted fact, and a document day that differs from the day the value is
filed under in the owner's zone. A value re-dated since, one really taken
on its confirmation day, or one whose document has no date is left alone.
A moved value no longer sits at its creation moment, so the pass converges
and a second run changes nothing.

The job definition lives in a new registry file beside maintenance.ts so
that file stays under the size ratchet.
2026-10-29 08:49:40 +01:00
Marc-André Bombeck cb80296a2a Merge pull request #1293 from haritowa/documents-fact-date-from-document 2026-10-29 08:37:56 +01:00
Marc-André Bombeck b180d09fae fix(auth): read a remembered browser's expiry on the throttle's clock
knownSignInSource compared a trusted device's expiry with new Date()
while the throttle around it times its waits with Date.now(). The test
mocks Date.now() only, so it set the device to expire 30 days after its
fixed clock, which the real date passed on 2026-10-26 at noon UTC; from
then on the remembered browser read as expired and the test failed on
every run. One clock decides both now, and the test's expiry is fixed
against it. A further case pins that an expired device gets no
exemption.
2026-10-26 15:08:06 +01:00
Marc-André Bombeck 444ff66086 refactor(charts): decide in the overlays hook where a chart's labels go
The narrow-chart rule lived in HealthChart's body and grew it past its
size baseline. The overlays hook, which already sizes the room beside
the plot, now takes the chart's width, keeps no room on a narrow chart
and marks each target zone whose label sits inside it; the x axis
helper works out its own tick layout. The tiered series unit test mocks
the lookup for readings older than the rollups and covers the head the
read puts in front of them.
2026-10-26 07:59:58 +01:00
Marc-André Bombeck 13860422cf fix(series): show a record's history older than the rollups on the All range
A range longer than a year reads its series from the week or month
rollups, which are kept for the last five years. When the rollups held
any rows the read returned them alone, so on a record that goes back
further the All range started where the rollups start: a weight history
from 2015 charted from late 2021. The readings older than the first
rollup day are folded live at the tier's grain and lead the series; the
head ends where the first tier bucket begins, so no month is counted
twice. One indexed lookup when there are none.
2026-10-26 07:46:50 +01:00
Marc-André Bombeck 467d5dcb48 fix(charts): give the plot its width back on a phone
1.46 moved the average's and the target zones' labels beside the plot,
in a right margin sized to the longest label. On a phone that margin
took about a third of the plot's width, and the legend, wrapping into
the narrower space, took more of its height: a pulse chart drew at
160 x 66 px instead of 232 x 108. Below 520 px the labels sit inside
the plot again, the average's at the top left as before and a zone's
at the top right of its band, and the plot keeps no room beside it.

The x ticks assumed an 80 px date label. A 90-day chart's month starts
sit about 78 px apart on a phone, so the month grid never fit and the
axis showed one date. The label width now comes from a date in the
reader's format (10/01/2026 draws at 58 px). A yearly grid is labelled
with the year alone, narrow enough for one tick a year, and with the
year it marks when its point falls in the December before.

The y axis shows the decimals its tick step needs: 57, 58, 59 bpm
instead of 57.0, 58.0, 59.0.
2026-10-26 07:42:27 +01:00
haritowa 58141cb48b fix(documents): date an undated lab value by its document
A lab report usually prints the collection date once, in its header. The
extraction puts that date on the document (reportDate) and leaves each
value's own date empty, and the confirm step filed every such value under
the moment it was confirmed, so an old report showed up as today's
results.

The confirm route now reads the document's report date and filing date
with the document it already loads, and an undated value takes the report
date, else the filing date, else now: the order the day view places a
document by.

A stated date, a lab value's or a condition's onset, now counts only when
it names a real calendar day. 2021-02-30 used to be stored as 2 March, and
2021-13-01 became an invalid date that failed the whole confirm.
2026-10-26 01:08:43 +00:00
Marc-André Bombeck 085864e63a fix(medications): classify a plan's cadence the same for every range
realisedFamily probed for the closest two slots up to range.to only, so
the answer moved with the range. A Saturday + Sunday plan started the
weekend before the autumn DST change, read around its Saturday dose: the
±24 h range ends at 06:00 UTC on the Sunday, whose 08:00 slot is at 07:00
UTC after the change. Cut off, the closest slots were six days apart
(weekly); the wide re-mint saw the 25 h gap (daily). The two passes
disagreed, no band survived, and the dose was filed as unscheduled, so a
second backdated dose within a minute came back as the first one (200).
Probe the same reach on both sides of the range's middle.
2026-10-26 00:47:24 +01:00
Marc-André Bombeck 5765059737 fix(medications): end today's intake list at the next local midnight
The list ended 24 hours after local midnight. On the 25-hour day of an
autumn DST change that left the last hour out, so a dose due then could
not be offered from it; on the 23-hour spring day it reached into the
next day. Take the day's bounds from getUserTodayBounds, as the dashboard
tile does. Anchor the health-status test's history to local noon: now
minus whole days is still today on the long day.
2026-10-26 00:05:21 +01:00
Marc-André Bombeck 9501d540d0 fix(backup): create a directory one level at a time
Node's recursive mkdir retries ENOENT/EEXIST forever below /proc on Linux,
inside one native call, so neither a timer nor a test timeout runs again.
A DB_BACKUP_DIR or GEOLITE2_DIR pointing there froze the process; the
database-backup integration file, which uses such a path for its
unwritable case, never finished on a Linux runner. Walk from the nearest
existing parent with plain mkdir instead.
2026-10-25 23:27:12 +01:00
Marc-André Bombeck c4b300ad78 fix(account): stop calling a delete with a grace window permanent
With the deletion grace window on, the confirm dialog still asked to
delete the account permanently and its button said so, while the body
promised fourteen days and an admin undo. The title and the button now
match the window, and the success message that names the erase date stays
long enough to read before the sign-in page.
2026-10-25 20:48:30 +01:00
Marc-André Bombeck 78de978dcd fix(recovery): check a pasted token key against its slow fingerprint
The kit's check compared keyed SHA-256 digests of the pasted value and the
configured key, which code scanning still reads as a fast hash of a
password. It now compares the scrypt fingerprints in constant time.
2026-10-25 19:58:40 +01:00
Marc-André Bombeck ae0b8d604a fix(recovery): fingerprint a chosen secret with scrypt, not a fast hash
API_TOKEN_HMAC_KEY can be a phrase, and its fingerprint is shown on the
admin page and written into dump manifests in the bucket; a fast hash
there lets a guessed phrase be checked offline. Text secrets now use a
scrypt fingerprint; the random encryption keys keep theirs. The kit's
check of a pasted token key compares keyed digests that never leave the
process.
2026-10-25 19:09:27 +01:00
Marc-André Bombeck 135d5baf28 fix(medications): return a medication's schedules in one order everywhere
The list reads ordered schedules by their window and id, but the detail,
create, update, compliance, dose-history, GLP-1 and efficacy reads took
them in whatever order Postgres found them, so two schedules created in
the same millisecond could swap places between two answers. Every read
now uses MEDICATION_SCHEDULE_ORDER, and the guard checks every
schedules include, not only the list reads.
2026-10-25 19:09:27 +01:00
Marc-André Bombeck a42b29a160 perf(ai): read the deletion lock with the usage level, not on its own
The capability gate's check for an account pending deletion added a
point read of users to every request that resolves AI capabilities, one
statement over the budget on /api/auth/me, the dashboard snapshot and the
batch ingest. The usage-level read already loads the record's row once
per request; it now carries the deletion lock too.
2026-10-25 18:54:33 +01:00
Marc-André Bombeck bcb31c80db Merge the release with the security review fixes into the data-safety review fixes 2026-10-25 18:36:54 +01:00
Marc-André Bombeck 722c6d1db5 Merge remote-tracking branch 'origin/release/v1.46.0' into fix/v146-sec-review 2026-10-25 18:19:39 +01:00
Marc-André Bombeck d2b8da7839 refactor: keep the size, cast and error-code guards where they were
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
2026-10-25 18:06:32 +01:00
Marc-André Bombeck f63d5d5819 fix(ai-runs): do not count time in the document reader against a run's deadline
A background AI run's deadline is armed in model time plus a fixed margin.
Time spent waiting in the document reader's queue, and the read itself, came
out of that margin, so a person's lab scan or document read could be timed
out by the reaper because other work held the reader. When the reader grants
the slot it now moves the run's deadline by the wait plus the most the read
may take, and gives back the unused part when the read ends, never past the
job's own expiry and only while the run is running. The interactive queue
wait stays within the margin, which covers the run until the slot is
granted.
2026-10-25 18:02:45 +01:00
Marc-André Bombeck 42940c3a98 fix(jobs): the encryption backfill still runs for an account pending deletion
The per-account free-text encryption backfill seals what is already stored
and changes nothing the record holds, which is why its module is exempt from
the frozen-account rule; the new pending-deletion gate dropped its per-user
jobs anyway. A queue can now say it acts for pending accounts
(actsForPendingAccounts), and this one does.

Also types the ingest route test's token fixture to the row it now reads,
and drops an unused variable from the deletion grace test.
2026-10-25 17:51:13 +01:00
Marc-André Bombeck 698312863d test(labs): check the labs gate per read, and only the labs module's
The guard asked whether a file that reads the lab table mentions the gate
anywhere, so one ungated read in a file with a gated one passed, and
whenModuleOn for any module counted as the labs gate. It now walks the syntax
tree: each read must sit in a function that asks the labs module, or in a
private helper whose every caller in the file does, or be listed by file and
function with its reason. whenModuleOn counts only with "labs".
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 70df2009d9 fix(deploy-webhook): keep DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP's meaning, add a signature-only flag
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP had been changed to mean signed requests
only, so an operator who set it for a sender that adds a timestamp but cannot
sign would have had every deploy notification refused after the upgrade. It
means the secret header plus a fresh timestamp again. Signature-only, the
setting that stops a replay, is DEPLOY_WEBHOOK_REQUIRE_SIGNATURE, on the
compose whitelist, in the env manifest and examples, and in the deploy
guide.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d87718407e fix(http): let operators name their proxy, and survive a bad TRUST_PROXY_HOPS
Forwarding headers were believed from any connection on a private address.
Behind Docker Desktop, OrbStack and docker-proxy every connection to a
published port arrives from the gateway, a private address, so a caller
reaching the port directly could again choose the client address that rate
limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS
(comma-separated CIDRs) now names the proxies: when set, only those peers may
report a client address. The private-range default stays when it is unset,
the boot log states the rule in force in one line, and the reverse-proxy
guide says what to set for a proxy on the same host, on a Docker network, or
on a public address (which, unlisted, puts all clients in one bucket).

An invalid TRUST_PROXY_HOPS threw on every request that resolved the client
address, so every route answered 500, the health probe included. It now
resolves no address, warns once, and is named at boot.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 0b830e6b02 fix(http): exempt only a Bearer request without a session cookie from the cross-site check
The cross-site write check let through any request that carried an
Authorization header, while the caller is resolved from the session cookie
before any header. A browser attaches Basic or Negotiate credentials on its
own once a proxy or host asked for them, so a cookie-authenticated write from
a sibling subdomain could pass. Only Authorization: Bearer is exempt now, and
only when no session cookie rides along; a request carrying both is checked
as the cookie request it resolves to.

The share-link passphrase POST under /api/c/ is exempt by path: its
credential is the token in the path and the passphrase in the body, and from
the shared view's no-referrer page a browser without Sec-Fetch-Site sends
Origin: null, which was refused.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck 5a875d2840 fix(auth): issue a Secure session cookie under the __Host- prefix
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.

Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d3257a1b6b fix(documents): tell people when the document reader is busy, and attribute its work
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck 4d970561fa fix(documents): decode thumbnails in the reader child, share it fairly, judge exits by cause
Three defects in the bounded document reader.

Image uploads were decoded into their thumbnails in the server process. A
header check is all that stood between a decoder and an allocation the file
chooses, and the GIF check showed how that fails. The image is now decoded in
the same child process PDFs run in, under its memory ceiling and time limit;
the header check stays in front of it.

The reader's one slot went to whoever asked first, and a waiting item waited
without end, so a few heavy PDFs from one account held everyone's lab scans
behind them. The slot now goes to a read a person is waiting on before
background work, to accounts in turn, to a few waiting items per account, and
an item that waits past its lane's deadline is answered busy, which callers
treat as retryable.

Any child that ended without an answer counted as a document past its limits,
so a kernel kill caused by the container's overall memory marked a good PDF
as permanently failed. Only a bound the reader measured (the sampled memory,
the time limit, the child's heap cap) is final now; any other end is run once
more and then answered interrupted, which the thumbnail job retries within its
ordinary attempts.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck e6c709876d fix(auth): the ingest and idempotency Bearer resolvers refuse a pending account
Both resolve a Bearer token outside resolveBearerToken and checked only
revoked and expiry. The deletion revokes every token in its own transaction,
so only a token issued in that same instant could get through, but the rule
should hold everywhere a token is resolved: both now refuse an account
pending deletion.
2026-10-25 17:33:16 +01:00
Marc-André Bombeck 69c5c65376 fix(data-wipe): say what undoing a wipe does not bring back
A restore of the copy a data wipe took replaces the record with that copy,
so anything added after the wipe is gone, and the wipe ended integration
connections, sharing in both directions, Guardian roles and share links,
none of which are in a copy. Neither the person's confirmation nor the
admin's restore dialog said so. Both do now, in every locale.
2026-10-25 17:32:33 +01:00
Marc-André Bombeck 2b79c01952 fix(offhost): judge the bucket's rules for database/ against this instance's folder
The dumps now live under database/<host of APP_URL>/, so the rule check reads
a sample key in that folder: a rule scoped to this instance's folder counts,
one scoped to another instance's does not.
2026-10-25 17:30:58 +01:00