mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'feat/v1463-restore' into release/v1.46.3
This commit is contained in:
commit
00d129ad42
28 files changed
+2504
-154
No files matched your search
+82
-20
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user