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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:32:29 +01:00
1 parent 953c64f7a4
commit d3c672cc3f
56 files changed
+3042 -1611

No files matched your search

+1 -1
View File
@@ -5,8 +5,8 @@
"entries": {
"app/api/admin/ai-settings/route.ts": 1,
"app/api/admin/drain-per-sample-cumulative/route.ts": 1,
"app/api/admin/encryption/key-backup/verify/route.ts": 1,
"app/api/admin/notifications/reminder-check/route.ts": 1,
"app/api/admin/recovery-kit/verify/route.ts": 1,
"app/api/admin/rollups/recompute/route.ts": 1,
"app/api/admin/users/[id]/reset-password/route.ts": 1,
"app/api/ai/test/route.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 86,
"total": 96631,
"total": 96611,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -67,7 +67,7 @@
"lib/jobs/coach-nudge.ts": 1084,
"lib/jobs/insight-pregenerate.ts": 1189,
"lib/jobs/medication-intake-import.ts": 819,
"lib/jobs/offhost-backup.ts": 1147,
"lib/jobs/offhost-backup.ts": 1127,
"lib/jobs/reaction-line.ts": 824,
"lib/jobs/reminder/medication-reminder-check.ts": 816,
"lib/managed-profiles/handover.ts": 986,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 348,
"total": 112838,
"count": 347,
"total": 112600,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -59,7 +59,6 @@
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
"components/admin/key-backup-card.tsx :: KeyBackupCard": 238,
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2897,
"total": 9249,
"total": 9248,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2703,7 +2703,7 @@
"lib/queries/use-medication-compliance-summary.ts": 3,
"lib/queries/use-medication-list-layout.ts": 3,
"lib/queries/use-profile-claim.ts": 4,
"lib/query-keys/admin.ts": 13,
"lib/query-keys/admin.ts": 12,
"lib/query-keys/ai-runs.ts": 1,
"lib/query-keys/auth.ts": 9,
"lib/query-keys/coach.ts": 13,
@@ -242,18 +242,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/encryption/key-backup/confirm": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/key-backup/verify": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
@@ -276,6 +264,19 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/recovery-kit": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/recovery-kit/confirm": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/recovery-kit/verify": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/recovery-readiness": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
"/api/admin/settings/assistant-flags": {