Merge branch 'feat/v1463-restore' into release/v1.46.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:46:23 +01:00
commit 00d129ad42
28 files changed
+2504 -154

No files matched your search

+82 -20
View File
@@ -25090,10 +25090,52 @@ paths:
same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the
backup schema, was written by a newer schema version than this server reads, needs an encryption key this server
does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account
that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens
cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists
here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>`
for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy,
with no password and no email (an admin sets its first password with the existing password reset). The file's
owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like
any other; the erased account's id is not stored. A target is refused before the file is read when the account
does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does
not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three
uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A
browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last
five minutes (with a second factor, on an account that has one). Without it the answer is 401
`auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
parameters:
- in: query
name: into
schema:
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
which has to exist."
type: string
enum:
- existing
- new
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
which has to exist."
- in: query
name: userId
schema:
description: "With `into=existing`: the account's id."
type: string
maxLength: 64
description: "With `into=existing`: the account's id."
- in: query
name: confirm
schema:
description: "With `into=existing`: the account's username, typed by the operator."
type: string
maxLength: 64
description: "With `into=existing`: the account's username, typed by the operator."
- in: query
name: username
schema:
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
type: string
minLength: 3
maxLength: 30
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
requestBody:
required: true
content:
@@ -25139,6 +25181,22 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403": *a25
"404":
description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: "The chosen target cannot take the copy, and nothing was stored: a managed profile
(`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an
account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username
already taken (`backup.target.username_taken`), or a file whose own account is still here
(`backup.owner.exists`)."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"413":
description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this
server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` =
@@ -25150,8 +25208,9 @@ paths:
"422":
description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as
gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist
here."
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here
(`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose
`confirm` is not the account's username (`backup.target.unconfirmed`)."
content:
application/json:
schema:
@@ -38654,20 +38713,23 @@ components:
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired,
consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich —
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing,
backup.payload.undecryptable, backup.restore.active, backup.section.missing,
backup.target.holds_records, backup.target.managed_profile, backup.target.not_found,
backup.target.pending_deletion, backup.target.unconfirmed, backup.target.username_taken,
backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach —
coach.budget.exceeded, coach.conversation.invalidTitle, coach.conversation.notFound,
coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound,
coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable,
coach.fenced.invalid, coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json,
coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle —
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
data_wipe — data_wipe.copy_failed; dawarich — dawarich.credentials_rejected,
dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, dawarich.rate_limited,
dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
+43 -5
View File
@@ -2,9 +2,10 @@
HealthLog keeps copies of each account so that one account can be rolled back
on this server. These copies are per account. They cannot rebuild a lost
server: a restore writes into an account that already exists here under the
same id, and refuses a copy whose account is gone. To recover a server, you
need a database dump and the encryption keys. That procedure is in
server: a restore writes into an account on this server, the one the copy
belongs to, or, when that account has been erased, one you choose (see
"Restoring an erased account"). To recover a server, you need a database dump
and the encryption keys. That procedure is in
[disaster-recovery.md](disaster-recovery.md).
## What exists
@@ -81,13 +82,50 @@ pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-<id>.json.enc /tmp/restor
```
Then upload `/tmp/restored.json` in Admin → Backups (up to 512 MB after
compression) and restore it from its row. The account `<id>` must exist on this
server.
compression) and restore it from its row. When the account `<id>` no longer
exists on this server, the upload offers to put the copy into another account
(next section).
Every restore, preview and upload checks first that this server holds every
application key the copy's content was written under, and refuses with the key
id named if not. Nothing is changed by a refused restore.
### Restoring an erased account
Once an account has been erased (its deletion window ended and the hourly purge
ran), its copies on this server and in the bucket are gone, but a file you kept
still holds the record: a disaster-recovery download, an off-host object you
fetched before it expired, or the person's own full backup. Uploading it in
Admin → Backups is refused at first, because the account it names no longer
exists, and the console then asks where the records should go:
- **A new account.** Type a username. The account is created without a
password or email, as an ordinary account with the user role, and nobody
can sign into it yet. Before handing it over, set its first password with
**Reset password** in Admin → Users; the person can change it, add an email
and enrol a passkey from their settings.
- **An existing account**, for someone who has signed up again. Pick the
account and type its username to confirm. The account must hold no records
yet: a restore replaces an account's records and does not merge them, so an
account with even one record of any kind a copy carries is refused, with the
kinds named. Managed profiles and accounts pending deletion are not offered.
The file is then uploaded a second time, and its owner is changed to the chosen
account as it is read. Nothing else in the file changes. The stored copy is
then that account's copy in every respect: its preview, its download and its
restore are the ordinary ones, and **Restore** on its row brings everything
back under the chosen account. The erased account's id is not stored, not in
the copy and not in the audit trail.
A copy whose own account still exists on this server can only go back into
that account; choosing a target for it is refused. The copy does not carry
what a restore never touches (below), so a returning person signs in fresh,
and the links of a managed profile to its guardians do not come back: a
restored managed profile is an ordinary account.
Once stored, the copy restores like any other: a restore run later, after the
account has gained records, replaces those too.
### What a restore does not touch
API tokens, trusted devices, step-up elevations, known devices, clinician share