Commit Graph
3088 Commits
Author SHA1 Message Date
Marc-André Bombeck 1bde3fc618 chore: drop exports nothing imports
knip 6.44 follows the members a dynamic import reads, and the Insights
page reads two of the derived barrel's: the barrel now exports those two,
whose building blocks are imported from their own modules. BAND_VAR had
no reader, and the drawer re-exported useResettableValue for a test that
only imports nextResettableValue. The size and version-stamp baselines
take the lower numbers.
2026-11-02 21:22:16 +01:00
Marc-André Bombeck 49a0eb92ff fix(labs): word a value without a unit the same on every remaining surface
The briefing's lab input passed the stored "1" to the model, the unit
mismatch refusal read "is in 1", and the trend card left a gap where a
language puts the unit before a particle. get_labs says that a value
without a unit has an empty unit, and a lab link id is scrubbed like its
label. The review row is back at its baseline length.
2026-11-02 20:52:18 +01:00
Marc-André Bombeck 6a8ac98613 fix(labs): let a unit typed key by key pass through "1"
The no-unit state was read from the value, so the first keystroke of
10^9/L or 1/min ticked the box and locked the field. Only the box makes
the choice now; a typed 1 stays text.
2026-11-02 20:52:17 +01:00
Marc-André Bombeck 2c6fb82326 Merge branch 'feat/unitless' into release/v1.46.6 2026-11-02 20:30:41 +01:00
Marc-André Bombeck f9431da64f feat(labs): show a value without a unit as the number alone
Every place a lab unit is printed beside a value now goes through one
helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the
list, history and detail, the chart and its tooltips, the reference bar
and range notes, the trend card, the timeline, the day view, the
clinician page, the doctor-report PDF and the changes card. Where the
unit stands alone (a marker's subtitle, a picker row, the merge preview)
it reads "No unit".

The Coach's lab block, the get_labs history and the visit-prep summary
hand the model an empty unit for such a value, so it never quotes the
"1" either.

Refs #1313
2026-11-02 20:28:34 +01:00
Marc-André Bombeck 79ac5670c7 feat(labs): offer an explicit no-unit choice for ratios and indices
A ratio (cholesterol/HDL, albumin/globulin) or an index such as INR has
no unit, and every lab write path refuses a numeric value without one.
That refusal stays: an empty unit usually means the extraction dropped
it. Instead the value is stored as the UCUM unity "1", chosen on purpose.

The unit field when a marker is defined, on a scanned report's row and
in the document review edit now carries a "No unit" checkbox that sets
"1". The CSV import takes 1 in the unit column, as the template and the
import sheet say; an empty cell is still refused, and other spellings
such as "ratio" or "-" stay the literal unit they always were.

"1" compares as a unit of its own: it matches only itself, so it is
refused under a marker kept in mg/dL and the other way round.

Refs #1313
2026-11-02 20:28:23 +01:00
johnnyrepo026 e3aae483b0 fix(documents): offer the edit control on review rows whose save was refused
A confident lab value that Finish review refused (no unit, or a unit that disagrees with the marker) showed the reason but no way to act on it, so it could only be discarded. The Check control now also opens on a row with a commit failure, not only on a low-confidence one.

FactRow grows by one line (the data-slot the new test finds the control by), so its size baseline moves from 262 to 263.

Refs #1313
2026-11-02 12:21:37 -06:00
Marc-André Bombeck 1c4bcce6c7 fix(ui): name the account by its display name
The dashboard greeting, the Insights greeting, the top bar and the
sidebar read the username, so a display name set on the profile card
reached the reports but none of the surfaces that address the account.
They now read one helper that returns the display name when set and
falls back to the username otherwise.

Refs #1309
2026-11-01 00:56:24 +01:00
Marc-André Bombeck 3674c1757d fix(timeline): leave hidden lab markers out of the values menu
Hiding a marker takes it out of the labs catalogue and the entry
pickers, but the timeline still offered it among the lab values.

The timeline now offers only markers that are not hidden. A hidden
marker that was chosen before keeps drawing its line and stays in the
menu as a ticked entry, named from that line, so it can be unticked,
the way the entry form keeps a hidden marker that is already selected.
Once unticked it is no longer offered.
2026-10-29 22:01:43 +01:00
Marc-André Bombeck c920266f32 fix(backup): explain a second restore of one file into another account
Restored rows keep the ids they had in the file. Once an erased
account's copy had gone into one account, the same file uploaded into a
second account restored into a primary-key conflict, reported only as
the generic "the database rejected a record" failure.

For a copy put into a chosen account, a primary-key conflict now ends
the restore with backup.restore.rows_exist_elsewhere and a sentence that
says the file was most likely restored into another account already.
The transaction still rolls back, so neither account changes. The code
is in the catalogue, the console names it, and the runbook says a file
goes into one account only.
2026-10-29 21:50:34 +01:00
Marc-André Bombeck 16d1b542a5 fix(backup): keep an erased account's copy out of admin accounts
A restore read the account settings under the operator's AI endpoint
policy whenever the receiving account was an admin. For a copy put into
a chosen account, the settings were written by the erased account's
owner, not by the operator, yet an admin account could be chosen as the
target, and an ordinary target promoted later got the same trust.

Admin accounts are now refused as a target, in the check and the
picker, with backup.target.admin_account. A copy uploaded into a chosen
account is stored as RETARGETED_UPLOAD_<ms>, and its restore never reads
its settings as the operator's, whatever the account's role is by then.
2026-10-29 21:36:33 +01:00
Marc-André Bombeck 21f6f5796b fix(timeline): cap lab markers instead of refusing the request
A values list naming more than 24 lab markers answered 422. The list is a
device preference, so once a 25th marker was ticked every later visit
failed: the page rendered its error card, and the values menu that could
untick it only renders on success.

The server now keeps the first 24 markers and leaves the rest out, as it
already does for a marker the record no longer has; a malformed key still
answers 422. The page applies the same cap before it asks, and the values
menu holds further unchosen markers back at 24 with a line saying why,
while a chosen one can always be removed.
2026-10-29 21:06:52 +01:00
Marc-André Bombeck 00d129ad42 Merge branch 'feat/v1463-restore' into release/v1.46.3 2026-10-29 20:46:23 +01:00
Marc-André Bombeck 77a76a9e33 refactor(backup): keep the upload flow owned by the backups section
The section that renders the recent-proof dialog is the one that hands
the proof to each gated flow, which the recent-proof callers test reads
from its source. The upload hook goes back there and the upload
component takes the flow it returns.
2026-10-29 20:41:58 +01:00
Marc-André Bombeck 46350b6461 refactor(backup): one success line for a stored upload
Both upload paths now announce a stored copy through one function, so
the upload hook keeps the single success affordance it is pinned at,
and the upload card and target dialog are wired in their own component
rather than in the backups section, which stays under its size line.
2026-10-29 20:37:11 +01:00
Marc-André Bombeck b8a983bdec feat(timeline): draw lab markers as lines under the values
With the server naming lab markers as value lines, the page offers them:
the values menu lists the markers the record has numeric readings for,
under the groups the labs list sorts them into, and a choice is stored
and requested like any measurement line. The phone toolbar gains the same
menu as an icon button, since the chronicle shows the chosen lines too.

A lab row sits under the value lines in their rhythm, on its own scale.
Each reading is its own point on the day of the draw, joined in time
order by a thin stroke and never averaged. The reference range is painted
behind the points as the labs page paints it, a faint band with dashed
edges, and the scale holds it so the band is always in view. A reading
outside its range is a triangle pointing the way it left and is worded as
outside wherever it is written out, so colour is never the only sign.

A point reads on its own by pointer, keyboard and tap: the day, the value
with every digit it was stored with, the verdict, the range and the
report it was read from (a link once pinned). The selection bar lists
the bucket's readings with their report, a lab day's row in the phone
chronicle lists that day's readings, a screen-reader table lists them
all, and a marker with readings in a unit that does not convert says how
many were left out. Nothing else on the timeline changes.

To make room without growing the large timeline files, the legend, the
screen-reader tables and the value-line geometry move into modules of
their own, and the chart, the chronicle, the point cards and the page
each hand a part to a helper; the size baselines are written back lower.

Refs #1299
2026-10-29 20:31:36 +01:00
Marc-André Bombeck 42b2c72e28 feat(timeline): serve lab markers as value lines
The timeline showed a lab report as an event on the axis, but the lines
under the lanes could only be measurements. A lab marker can now be named
in `values` as `lab:<biomarkerId>`, beside the measurement types and
`MOOD`, so one choice, one request and one stored list carry every line.

`GET /api/timeline` answers with `labSeries`, one entry per named marker
with its readings in the window as single points (lab values come a few a
year, so they are never averaged into buckets), each dated by the record's
local day, judged against its own window and linked to the report it was
read from when the caller may see documents. `availableLabs` lists every
marker the record has a numeric reading for, so a client offers only
those.

A reading stored in another unit than its marker's is converted through
the unit-conversion registry where the pair is known, and otherwise left
out and counted in `excluded`, never drawn on a scale it is not in. A
marker id that is not in the record yields no line instead of a 422, so a
stored choice survives a deleted marker; ids are capped at 24 a request.

Refs #1299
2026-10-29 20:31:27 +01:00
Marc-André Bombeck a40bd53d45 feat(backup): offer an account when an upload's owner is gone
An upload refused with backup.owner.missing no longer ends in an error
toast. The backups console keeps the file and opens a dialog: a new
account by default, which cannot hold anything yet, or an existing one,
which the server checks as soon as it is picked and which the admin
confirms by typing its username. Managed profiles and accounts pending
deletion are not offered. The file is then sent again with that target,
and a refusal stays in the dialog beside the choice it refuses.

The new-account path says the account has no password yet and where to
set its first one. Copy in every app language.
2026-10-29 20:20:28 +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 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 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 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 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
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 bcb31c80db Merge the release with the security review fixes into the data-safety review fixes 2026-10-25 18:36:54 +01:00
Marc-André Bombeck d2b8da7839 refactor: keep the size, cast and error-code guards where they were
The fixes above grew six files over 800 lines and three long functions, added
casts in tests and one error code the catalogue did not list. The claimable
profile check moves into its own module, the guardian count is reused by the
revoke path, the lifecycle fallback lives beside the lifecycle rules, the
key-needs list is its own component, the coach sweep wraps its filters with
ownedByActiveAccount, comments are tightened, and the test fixtures are typed
instead of cast. The enumeration guard reads a write keyed by one handed id
(id: runId, id: job.data.jobId) as scoped, so those need no marker.
account.deletion.no_guardian joins the catalogue; two baselines go down.
2026-10-25 18:06:32 +01:00
Marc-André Bombeck d3257a1b6b fix(documents): tell people when the document reader is busy, and attribute its work
A read a person started now runs as interactive work of their account, and
the document jobs as background work of theirs, so the reader's queue can
order and cap them. When the reader is busy or stopped before it answered,
a lab scan, label read or document read fails with aiRuns.readerBusy (503,
nothing read or charged) instead of a misleading unreadable-file error, and
the index and summary jobs throw so the queue runs them again rather than
recording an outcome about the document.
2026-10-25 17:38:25 +01:00
Marc-André Bombeck 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 6f83e7e685 fix(db-backup): age pre-migration dumps out with the nightly ones
Pre-migration dumps were kept by count alone, so an install that updates
rarely kept its last two, and every account deleted since, indefinitely,
while the docs and the backups card stated the nightly retention as the
retention of deleted accounts. A pre-migration dump older than the oldest
nightly dump kept now goes, except the newest one, the way back from the
latest update, which stays until the next update. The card, the disaster
recovery guide and its variable table say so.
2026-10-25 17:21:52 +01:00
Marc-André Bombeck 26a9efdf52 fix(recovery): the database dumps count in the keys the backups still need
The recovery kit, "Keys the backups still need" and safeToDropRetiredKeys
read only the stored copies and the off-host account objects. After a
rotation the page called the old key safe to drop while every dump taken
before it, the pre-migration dumps that are the way back from an update
among them, could only be restored with that key: db-restore refuses a dump
whose active key is missing.

A dump now counts for the key its rows were written under, the manifest's
active key: a dump on this server while it is kept (read from the manifests),
an off-host one until the bucket's rule for database/ expires it, or
indefinitely when that rule cannot be read. The nightly run records the key
on its ledger row (migration 0489, database_backup_runs.active_key_id). The
encryption view and the kit list the dumps per key, in every locale, and the
rotation and backup runbooks say so.
2026-10-25 17:20:16 +01:00
Marc-André Bombeck 664b46d2c6 fix(db-backup): read the off-host leg of the dump on its own
A run that kept its dump on the volume and failed the upload is the newest
good dump and the newest failure at the same instant. Compared with ">", the
card showed no failure and readiness said fresh, night after night, while no
dump left the host.

The health read now carries the off-host leg separately: when the newest dump
reached the bucket, and the newest one that did not. One pure reading of it
(databaseDumpProblems) feeds both the backups card and the readiness model.
A configured bucket that missed the newest dump is attention (offhost_failed)
with the upload's error, and failing once the newest dump there is older than
36 hours (offhost_stale) or none ever arrived (offhost_never). The card shows
the refused upload and when the bucket last got a dump. A failure at the
instant of the newest dump for another reason, retention throwing, is now
shown too.
2026-10-25 16:53:24 +01:00
Marc-André Bombeck 66e92f36fe feat(recovery): judge the bucket's rule for database dumps in readiness
Only the backups page said when the bucket's lifecycle rules left
database/ out, so every dump, and every deleted account inside it, stayed
in the bucket forever without anyone being told. Readiness gains a
dump_retention signal that reads the same cached lifecycle probe: attention
when dumps go off-host and no rule covers database/, ok with the expiry
when one does, quiet when the bucket will not say, inactive without a
bucket or without off-host dumps. The hourly evaluation pushes it to the
admins like every other signal.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 351e7d1fe0 fix(backup): keep a dump locally only where it survives the container
The image creates DB_BACKUP_DIR owned by the app user, so an install that
runs it without the backups volume could write there, and every nightly and
pre-migration dump landed in the container's own layer. The next update
threw them away while the admin page, the boot line and the readiness card
called them kept on this server.

A dump directory now counts as kept only when it survives the container:
inside a container (Docker, Podman, Kubernetes, a container cgroup or an
overlay root) the directory must sit on a mount of its own that is not a
tmpfs, read from /proc/self/mountinfo with a device-number fallback;
outside one, a writable directory is a disk. Otherwise no dump is written
there: the nightly run uploads off-host only and records not_persistent,
the pre-migration dump is skipped with a warning, the boot line says the
directory is inside the container, the backups page stops promising local
dumps and ignores local-only ledger rows, and readiness fails with no
bucket and notes the bucket-only setup otherwise.

The Unraid template gains a path for the dumps, the Unraid and TrueNAS
pages say why it is needed, and the restore drill first runs the stack
without the volume and checks that nothing is kept or claimed.
2026-10-25 16:00:34 +01:00
Marc-André Bombeck 9db5e755e6 Merge the release into the deletion grace work
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
2026-10-25 15:18:46 +01:00
Marc-André Bombeck e88dcd2c53 Merge the release into the backup health work and read the real dump health
The readiness model now takes the database dump's state from
getDatabaseBackupHealth, and the dump test reaches the off-host key id
where it moved.
2026-10-25 14:40:31 +01:00
Marc-André Bombeck 694befd635 fix(admin): say what actually ages a stored account copy out
The backups page stated "Retention: 90 days", from a constant whose
comment said it matched a backup-prune job. There is no such job: nothing
deletes a stored copy by age. The weekly copy is replaced in place, one
per account; the copy taken before "Delete all data" is deleted by the
account-deletion purge when the grace window ends; an uploaded copy stays
until its account is erased.

The list now carries preWipeCopyDays (the grace window, 0 when no such
copy is taken) instead of retentionDays, and the page says exactly the
three facts above, in every language.

Also drops a re-export of the JSON import row limit nothing imports.
2026-10-25 14:28:07 +01:00
Marc-André Bombeck 0c5f29303b docs(backup): say what account copies and the JSON import can do
Several surfaces promised more than the per-account copies deliver:

- The full-backup export card said it had the same shape as the weekly
  copy. It is a portable export with documents as metadata only; it now
  says so, and that it cannot recreate an account on another server.
- The export page offered to "import from a backup". The JSON import adds
  measurements and mood entries; it is now named that way, and its help
  states the 16 MB and 10,000-entries-per-list limits from the constants
  the route enforces.
- The weekly copy is labelled an account rollback on this server, and the
  backups page says a copy cannot rebuild a lost server. The upload says
  the account must exist here. Two restore refusals pointed to a
  "disaster-recovery" copy; they now name the copies that exist.
- The instance-settings restore option was described as being for
  rebuilding a host.

docs/ops/backup-restore.md is rewritten short and accurate: what each
copy is, the deletion grace window, that database dumps keep a deleted
account until they age out, how to restore one account, and a link to
the disaster-recovery procedure for a lost server.
2026-10-25 13:51:15 +01:00
Marc-André Bombeck 63574b98a6 feat(account): show the deletion grace window and its undo
The deletion dialogs, the managed-profile delete dialog and the Data &
Privacy page read the window from the privacy summary and say what
happens: the account is locked at once, erased after the stated number
of days, and an admin of the server can undo it until then; a wipe keeps
a copy an admin can restore for the same time. Copies in the server's
backups age out after their retention period, and the text says that
too. With the window set to 0 they keep the immediate wording.

The sign-in page names the erasure date when a sign-in, a passkey or the
single sign-on callback is refused for an account pending deletion. The
admin users list shows a pending account with its date and an "Undo
deletion" action, and the backups list labels the copy taken before a
wipe. Every new string is translated in all nine languages.
2026-10-25 13:51:01 +01:00
Marc-André Bombeck adc96fde3e Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-dump 2026-10-25 13:49:51 +01:00
Marc-André Bombeck 726a73fcad refactor(admin): drop an unused kit type re-export 2026-10-25 13:49:22 +01:00
Marc-André Bombeck e825a4c4d3 feat(admin): say how long dumps keep deleted accounts, and check the bucket rule for database/
A database dump is the whole database at one moment, so an account deleted
since stays in the dumps taken before until they age out. The database row
on the backups card now says so with the numbers that decide it: how many
nightly dumps this server keeps, and after how many days the bucket's
lifecycle rule removes the off-host copies.

The lifecycle probe used to answer with the shortest expiry of any enabled
rule, whatever its prefix, so a rule scoped to the account copies read as
covering the dumps too. It now also answers for `database/` alone, counting
only rules whose filter takes in a dump's key (prefix, legacy prefix, an
And filter; a tag filter never matches untagged objects), and the card
warns when no rule covers that prefix: dumps there, and the deleted accounts
in them, would never be removed. The reading moved into its own module.
2026-10-25 13:43:23 +01:00
Marc-André Bombeck 7dab9cc5e9 feat(admin): judge every backup signal and push changes to admins
Every backup failure was a pull, on a page nobody opens until the day it
matters: the weekly copy stopped for 46 days and showed an ordinary row,
a failed monthly drill was on the failing-jobs card three days in thirty
(job-failures.ts reads 72 hours), and an off-host bucket that was never
set up showed only on the backups page.

One readiness model (src/lib/recovery/readiness-model.ts) judges six
signals as ok, attention or failing, with a reason code, the time it is
about and where to fix it:

- database dump: failing once the newest good dump is older than 36 h
  while dumps are on, or when it can be kept nowhere; read through a
  provider that reports "not available" until the dump lands;
- weekly account copies: worst account decides, attention past 9 days,
  failing past 14; an account without a copy ages from its creation;
- off-host copies: attention when any account is two nights behind;
- restore drill: failing until a later drill passes, with no time
  window; the drill now keeps its verdict in restore_drill_outcomes;
- off-host setup: attention 7 days after the first admin exists;
- recovery kit: attention when a needed key is not confirmed, by id,
  7 days after setup or as soon as a confirmed kit is out of date.

A "Recovery readiness" card shows them on the admin overview and the
backups page. An hourly job (recovery-readiness, :33) sends each change
to every admin through the localised admin path, under a new
admin-only BACKUP_HEALTH event that the channel preferences route: once
when a signal turns to attention or failing, once a day while it stays
failing, once when it recovers. Each notice is claimed with a
compare-and-set before it is sent, so two workers never send the same
one, and nothing is claimed while no admin exists.

The backups page calls the weekly copy stale at the same instant as the
model (past nine days rather than past ten).
2026-10-25 13:32:43 +01:00
Marc-André Bombeck d3c672cc3f feat(admin): replace the key backup step with a recovery kit
The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

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

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

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

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

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
2026-10-25 13:32:29 +01:00
Marc-André Bombeck 3646502a86 feat(admin): show the newest database dump on the backups page
GET /api/admin/backups now carries `database`: the newest good dump (when,
how big, kept on the server, off-host or both), the newest failed run,
whether this server can write its backup directory, and whether off-host
copies are configured. The backups card leads with one row built from it,
with a notice when the last run failed and when dumps can only go
off-host or cannot be taken at all. The route stays an unpublished admin
console route, so the OpenAPI document does not change.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 72d90282d5 fix(settings): open the docs pages themselves, not their old addresses
The integration runbooks, the Garmin note, the guardian note on a managed
record's connections, the backups admin link and the README's self-hosting
link all pointed at retired docs paths that only resolve through redirects
(or not at all, on a host without them). They now go to the final guide
pages, and the settings tests assert the new addresses.
2026-10-25 12:12:15 +01:00
Marc-André Bombeck 856eb036f8 fix(documents): point the on-device scan hint at the Labs switch
The hint shown when a text-only provider needs on-device text recognition
sent people to the AI settings, where no such switch exists. It now names
the switch by its label under Settings → Modules → Labs in every locale and
links straight to that card. Inside somebody else's record the link is
withheld, since Settings there is the caller's own and closed; the sentence
stays.
2026-10-25 12:12:14 +01:00
Marc-André Bombeck b597627a39 test(mental-health): press an answer once the step's question holds the focus
The keys followed each other without waiting for the step to move the focus,
so on a slow run Tab started from the previous answer and the number chose
nothing.
2026-10-25 09:24:19 +01:00