From 308470f22949e94ab4c0a214a895857b4a4a90f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:06:28 +0200 Subject: [PATCH 1/7] feat(encryption): back up the key, and refuse to serve when it cannot open the database Every configured key id now has a canary in encryption_key_canaries, checked at boot. A key that cannot open its canary, or existing data written before the canary existed, puts the process in a refusal state: every API route answers 503 encryption.key_mismatch, /api/health reports reason encryption_key_mismatch, the pages render one explanation, and neither the queue producer nor the worker starts. The process stays up so the reason is visible instead of hiding in a crash loop. The admin Encryption page gains a "Back up your encryption key" step: key id and fingerprint (never the key), where the key lives per platform, a check of a pasted copy that is compared and discarded, and a confirmation bound to the active key id and fingerprint. The overview gets a tile and admins get a dashboard banner while it is due. HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the TrueNAS self-hosting page and key backup sections in the docs. --- .env.example | 5 + .env.production.example | 10 + docker-compose.yml | 3 + docs/api/openapi.yaml | 22 +- docs/ops/deploy.md | 8 + docs/self-hosting/getting-started.md | 34 ++ docs/self-hosting/portainer/templates.json | 6 + docs/self-hosting/truenas.md | 87 +++++ docs/self-hosting/unraid.md | 8 + docs/self-hosting/unraid/healthlog.xml | 1 + .../migration.sql | 20 ++ prisma/schema.prisma | 31 ++ scripts/env-manifest.json | 5 + .../openapi-route-coverage-guard.test.ts | 12 + ...ublished-env-reaches-the-container.test.ts | 5 + .../encryption/key-backup/confirm/route.ts | 73 +++++ .../api/admin/encryption/key-backup/route.ts | 22 ++ .../encryption/key-backup/verify/route.ts | 64 ++++ src/app/api/admin/encryption/status/route.ts | 4 + src/app/api/health/__tests__/route.test.ts | 23 ++ src/app/api/health/route.ts | 13 +- src/app/key-mismatch-page.tsx | 43 +++ src/app/layout.tsx | 24 +- src/app/page-client.tsx | 2 + .../admin/__tests__/key-backup-card.test.tsx | 98 ++++++ src/components/admin/encryption-section.tsx | 45 +-- src/components/admin/key-backup-card.tsx | 309 ++++++++++++++++++ .../admin/system-status-summary.tsx | 25 +- src/components/admin/use-key-backup-status.ts | 36 ++ .../dashboard/key-backup-banner.tsx | 40 +++ src/instrumentation.ts | 13 + .../api-handler-key-mismatch.test.ts | 63 ++++ src/lib/api-handler.ts | 23 +- src/lib/boot/key-check.ts | 42 +++ src/lib/boot/key-mismatch-state.ts | 52 +++ src/lib/crypto.ts | 51 +++ src/lib/crypto/__tests__/canary.test.ts | 178 ++++++++++ .../encrypted-column-writers.test.ts | 16 +- src/lib/crypto/__tests__/key-backup.test.ts | 50 +++ src/lib/crypto/canary.ts | 211 ++++++++++++ src/lib/crypto/key-backup.ts | 142 ++++++++ src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/observability/redact-payload.ts | 5 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/health.ts | 15 +- src/lib/query-keys/admin.ts | 3 + .../integration/encryption-key-check.test.ts | 290 ++++++++++++++++ 47 files changed, 2193 insertions(+), 42 deletions(-) create mode 100644 docs/self-hosting/truenas.md create mode 100644 prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql create mode 100644 src/app/api/admin/encryption/key-backup/confirm/route.ts create mode 100644 src/app/api/admin/encryption/key-backup/route.ts create mode 100644 src/app/api/admin/encryption/key-backup/verify/route.ts create mode 100644 src/app/key-mismatch-page.tsx create mode 100644 src/components/admin/__tests__/key-backup-card.test.tsx create mode 100644 src/components/admin/key-backup-card.tsx create mode 100644 src/components/admin/use-key-backup-status.ts create mode 100644 src/components/dashboard/key-backup-banner.tsx create mode 100644 src/lib/__tests__/api-handler-key-mismatch.test.ts create mode 100644 src/lib/boot/key-check.ts create mode 100644 src/lib/boot/key-mismatch-state.ts create mode 100644 src/lib/crypto/__tests__/canary.test.ts create mode 100644 src/lib/crypto/__tests__/key-backup.test.ts create mode 100644 src/lib/crypto/canary.ts create mode 100644 src/lib/crypto/key-backup.ts create mode 100644 tests/integration/encryption-key-check.test.ts diff --git a/.env.example b/.env.example index 10bc7095e..efe6a7a8d 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,11 @@ NODE_ENV="development" # internet; the session cookie crosses the wire in cleartext. # SESSION_COOKIE_SECURE="false" +# Where this server's environment is configured: compose, truenas, coolify, +# unraid or portainer. Only picks which instructions the admin's "Back up +# your encryption key" step shows first; unset means compose. +# HEALTHLOG_PLATFORM="compose" + # ----------------------------------------------------------------------------- # Required Secrets — generate with `openssl rand -hex 32` diff --git a/.env.production.example b/.env.production.example index af58314bd..e913ade7a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -257,6 +257,16 @@ API_TOKEN_HMAC_KEY="" # SESSION_COOKIE_SECURE="false" +# ----------------------------------------------------------------------------- +# Platform hint for the encryption key backup step +# ----------------------------------------------------------------------------- +# Where this server's environment lives: compose, truenas, coolify, unraid or +# portainer. The admin's "Back up your encryption key" step opens on the +# instructions for that platform. Unset means compose. Catalog templates set +# it for you. +# HEALTHLOG_PLATFORM="compose" + + # ----------------------------------------------------------------------------- # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 28b1b9ad1..c9216b398 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,9 @@ x-healthlog-env: &healthlog_env # all = bundle web + worker in this container (default, single-host) # web = serve HTTP only; pair with a separate app-worker service HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}" + # compose | truenas | coolify | unraid | portainer. Only chooses which + # instructions the admin's "Back up your encryption key" step shows first. + HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}" BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}" BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f66443ff6..20ca5766a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31744,10 +31744,11 @@ components: documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, - encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment - — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; - export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; - feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, + encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption + — encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.invalid, + environment.no_home, environment.range_too_large, environment.travel.not_found; export — + export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — + feedback.body.invalid; health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy, @@ -74129,6 +74130,8 @@ components: properties: status: $ref: "#/components/schemas/HealthStatus" + reason: + $ref: "#/components/schemas/HealthReason" timestamp: type: string format: date-time @@ -74169,11 +74172,22 @@ components: description: "`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing." + HealthReason: + description: "Present only when the process refuses to serve for a reason a person has to fix. + `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at + boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs + are not started. Fix: restore the original key, point the server at the matching database, or start with an + empty one; then restart." + type: string + enum: + - encryption_key_mismatch HealthPublicResponse: type: object properties: status: $ref: "#/components/schemas/HealthStatus" + reason: + $ref: "#/components/schemas/HealthReason" required: - status additionalProperties: false diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index f716516d4..4cfe63778 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -79,6 +79,14 @@ dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. +## Catalog templates + +A new required environment variable, or a change to the compose shape, needs a +matching pull request to the TrueNAS apps catalog in the same release cycle +(and an update to `docs/self-hosting/unraid/healthlog.xml` and +`docs/self-hosting/portainer/templates.json`). See +`docs/self-hosting/truenas.md` for what the catalog template carries. + ## Verify the served version The queued-deploy status is **not** the source of truth — the version diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index af157eb85..fe1c87d1b 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -226,6 +226,40 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds `pull_policy: always` on the `app` service, and verify the running build with `GET /api/version` (it returns `version` + `buildSha`). +## Back up your encryption key + +`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database. +Without it, the encrypted parts of the database and every in-database backup +cannot be read. Keep a copy outside the server, then confirm it under Admin, +Encryption: the step shows the key id and a fingerprint (the first 12 hex +characters of SHA-256 over the key) and can check a pasted copy without +storing it. Admins see a dashboard reminder until the active key is confirmed. + +| Platform | Where the key is set | +| --------- | --------------------------------------------------------------- | +| Compose | `.env` next to `docker-compose.yml` | +| TrueNAS | Apps, HealthLog, Edit, Environment | +| Coolify | the application's Environment Variables | +| Unraid | the container template (Docker tab, Edit) or the Compose `.env` | +| Portainer | the stack's Environment variables | + +Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`, +`portainer`) to open the step on your platform's instructions. + +To compute the fingerprint of your copy: + +```bash +echo -n "" | xxd -r -p | sha256sum | cut -c1-12 +``` + +**HealthLog refuses to serve: encryption key does not match this database.** +At start the app checks that its key opens the stored data. If it does not, +every API request answers `503` with `meta.errorCode` +`encryption.key_mismatch`, `/api/health` answers `503` with +`reason: "encryption_key_mismatch"`, and background jobs are not started. +Restore the original key, point `DATABASE_URL` at the matching database, or +start with an empty database; then restart. + ## Backup and restore Your database is the only stateful piece — back it up, and back up the diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json index 985e01c44..6b2c51f83 100644 --- a/docs/self-hosting/portainer/templates.json +++ b/docs/self-hosting/portainer/templates.json @@ -30,6 +30,12 @@ "label": "API token HMAC key", "description": "Generate with: openssl rand -hex 32" }, + { + "name": "HEALTHLOG_PLATFORM", + "label": "Platform hint", + "description": "Opens the admin's encryption key backup step on the Portainer instructions.", + "default": "portainer" + }, { "name": "SESSION_COOKIE_SECURE", "label": "Session cookie Secure flag", diff --git a/docs/self-hosting/truenas.md b/docs/self-hosting/truenas.md new file mode 100644 index 000000000..8bebe7441 --- /dev/null +++ b/docs/self-hosting/truenas.md @@ -0,0 +1,87 @@ +# HealthLog on TrueNAS SCALE + +HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from +`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container +is stateless; everything you care about lives in the database dataset and in +one value you must keep yourself: the encryption key. + +## What the app needs + +| Setting | Value | +| ------------ | ---------------------------------------------------------------------------------------- | +| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) | +| Port | `3000` (HTTP) | +| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine | +| Health check | `GET /api/health`, `200` when ready | +| Start period | at least 60 s, with more retries than the image's 3 × 30 s: migrations run at every boot | +| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions | +| Volumes | none for the app; the database gets its own dataset | + +## Environment + +| Variable | Required | Notes | +| ----------------------- | -------- | ------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | yes | `postgresql://:@:5432/` | +| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). | +| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. | +| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` | +| `APP_URL` | yes | the same address | +| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy | +| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions | +| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS | + +`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app +and runs the background jobs. + +## Plain HTTP on the LAN + +Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false` +for that. If it is left at its default, the browser drops the sign-in cookie +and the login page says so before you type a password: "This server only sets +its sign-in cookie over https://". + +## Back up your encryption key + +HealthLog encrypts tokens, notes, documents and in-database backups with +`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted +and reinstalled with a new key over the old database dataset, the data is +still there but cannot be read. + +1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit, + Environment) into your password manager. +2. Sign in as the admin and open Admin, Encryption. The step shows the key id + and a fingerprint, the first 12 hex characters of SHA-256 over the key. + Compare it with your copy: + + ```bash + echo -n "" | xxd -r -p | sha256sum | cut -c1-12 + ``` + + or paste the copy into "Check my copy" (it is compared and dropped, never + stored or logged). + +3. Confirm the backup. Until you do, admins see a reminder on the dashboard. + After a key rotation the step comes back for the new key. + +## When the key does not match the database + +At every start HealthLog checks that its key opens the data already in the +database. If it does not, it refuses to serve instead of failing on every +encrypted value: pages show "HealthLog cannot open its data", API requests +answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health` +answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs +run. TrueNAS shows the app as unhealthy rather than crashed, so the reason +stays visible. + +Fix it one of three ways, then restart the app: + +1. Put the original `ENCRYPTION_KEY` back. +2. Point `DATABASE_URL` at the database that belongs to this key. +3. Start fresh with an empty database. The old data and its backups need the + old key. + +## Updates + +Pick the new tag, take a snapshot of the database dataset first, and update. +Migrations run on start; the first start of a new install applies all of them, +which is why the start period above is generous. diff --git a/docs/self-hosting/unraid.md b/docs/self-hosting/unraid.md index 91e5c89d6..77abd18f7 100644 --- a/docs/self-hosting/unraid.md +++ b/docs/self-hosting/unraid.md @@ -121,6 +121,14 @@ Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL` and `APP_URL` at the public `https://` URL. +## Back up your encryption key + +Copy `ENCRYPTION_KEY` from the template into your password manager, then +confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step +opens on the Unraid instructions). A reinstall with a new key over the old +database makes the data unreadable; HealthLog then refuses to serve and says +so. See [Getting started](getting-started.md#back-up-your-encryption-key). + ## Updates Pin a release tag (`ghcr.io/mbombeck/healthlog:vX.Y.Z`) and bump deliberately, diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml index 3ad71c0f5..26ee42ee6 100644 --- a/docs/self-hosting/unraid/healthlog.xml +++ b/docs/self-hosting/unraid/healthlog.xml @@ -53,6 +53,7 @@ false + unraid diff --git a/prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql b/prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql new file mode 100644 index 000000000..986150e0f --- /dev/null +++ b/prisma/migrations/0365_encryption_key_canaries_and_key_backup/migration.sql @@ -0,0 +1,20 @@ +-- The encryption key backup step and the boot check that the configured key +-- opens this database. +-- +-- Four nullable columns on the instance settings row record which key the +-- operator confirmed a backup of (id and fingerprint, never the key). The +-- canary table holds one known plaintext per key id, sealed under that key; a +-- process whose key cannot open it refuses to serve. Both are written at +-- runtime, so the migration only creates the shapes. +ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_at" TIMESTAMP(3); +ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_key_id" TEXT; +ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_fingerprint" TEXT; +ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "encryption_key_backup_confirmed_by_user_id" TEXT; + +CREATE TABLE IF NOT EXISTS "encryption_key_canaries" ( + "key_id" TEXT NOT NULL, + "ciphertext" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "encryption_key_canaries_pkey" PRIMARY KEY ("key_id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11b59cb98..0e5b877f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4876,9 +4876,40 @@ model AppSettings { documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes") documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes") + // The operator's confirmation that the encryption key is backed up, bound to + // the key it was given for: the active key id and its fingerprint (the first + // twelve hex characters of SHA-256 over the raw key bytes). A rotation or a + // re-keyed install changes either one, and the step is due again. Never the + // key itself. Written by `POST /api/admin/encryption/key-backup/confirm`; + // consumer: `readKeyBackupStatus` (the admin step, the overview tile and the + // dashboard banner for admins). + encryptionKeyBackupConfirmedAt DateTime? @map("encryption_key_backup_confirmed_at") + encryptionKeyBackupConfirmedKeyId String? @map("encryption_key_backup_confirmed_key_id") + encryptionKeyBackupConfirmedFingerprint String? @map("encryption_key_backup_confirmed_fingerprint") + /// Plain id, not a relation: the instance row must not become user-scoped, + /// and a deleted admin leaves the confirmation standing. Consumer: the admin + /// step names who confirmed when the account still exists. + encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id") + @@map("app_settings") } +/// One known plaintext per configured encryption key id, sealed under that +/// key. At boot every configured id is checked against its row: a row that no +/// longer opens means the key in the environment is not the key this database +/// was written with, and the process refuses to serve instead of answering +/// with random errors. A missing row is written (`ON CONFLICT DO NOTHING`). +/// Deliberately outside key rotation: a canary belongs to its key id, and +/// re-sealing it under the active key would make every id's check test the +/// active key. Written and read only by `src/lib/crypto/canary.ts`. +model EncryptionKeyCanary { + keyId String @id @map("key_id") + ciphertext String + createdAt DateTime @default(now()) @map("created_at") + + @@map("encryption_key_canaries") +} + // ─── Audit Log ───────────────────────────────────────────── model AuditLog { diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 3cec8225f..9639d8b0a 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -322,6 +322,11 @@ "purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.", "example": "false" }, + { + "name": "HEALTHLOG_PLATFORM", + "purpose": "Where this server's environment is configured (compose, truenas, coolify, unraid, portainer). Shown back to the admin: the \"Back up your encryption key\" step opens on that platform's instructions. Unset means compose.", + "example": "compose" + }, { "name": "TRUST_PROXY_HOPS", "purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.", diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 4e7c47b74..d46bb5b8c 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -239,6 +239,18 @@ const UNPUBLISHED: Readonly> = { 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"] }, diff --git a/src/__tests__/published-env-reaches-the-container.test.ts b/src/__tests__/published-env-reaches-the-container.test.ts index 3847c4a0f..2e1b54b21 100644 --- a/src/__tests__/published-env-reaches-the-container.test.ts +++ b/src/__tests__/published-env-reaches-the-container.test.ts @@ -71,6 +71,11 @@ const MANIFEST_NAMES: string[] = ( * entry a lie rather than an inconvenience. */ const PUBLISHED_TO_USERS = [ + { + name: "HEALTHLOG_PLATFORM", + shownBy: + "the admin's encryption key backup step, which opens on that platform", + }, { name: "AUDIT_LOG_RETENTION_DAYS", shownBy: "the shared-access activity window and the revoke dialog", diff --git a/src/app/api/admin/encryption/key-backup/confirm/route.ts b/src/app/api/admin/encryption/key-backup/confirm/route.ts new file mode 100644 index 000000000..b82977fd4 --- /dev/null +++ b/src/app/api/admin/encryption/key-backup/confirm/route.ts @@ -0,0 +1,73 @@ +/** + * POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint } + * + * The admin states that the active encryption key is backed up. The body + * names the key the admin was shown; when that is no longer the active key + * (rotated, or the server restarted with another key since the page loaded) + * the answer is 409 `encryption.keyBackup.stale` and nothing is recorded. + * No step-up: confirming is a low-risk write that reveals nothing. + */ +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { + confirmKeyBackup, + KEY_BACKUP_STALE_CODE, +} from "@/lib/crypto/key-backup"; + +export const dynamic = "force-dynamic"; + +const confirmSchema = z + .object({ + keyId: z.string().min(1).max(32), + fingerprint: z.string().regex(/^[0-9a-f]{12}$/), + }) + .strict(); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.encryption.keyBackup.confirm" } }); + + const body = await safeJson(request, { maxBytes: 1024 }); + if (body.error) return body.error; + const parsed = confirmSchema.safeParse(body.data); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + const result = await confirmKeyBackup({ + keyId: parsed.data.keyId, + fingerprint: parsed.data.fingerprint, + userId: user.id, + }); + if (!result.ok) { + annotate({ meta: { key_backup_stale: true } }); + return apiError( + "The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.", + 409, + { + errorCode: KEY_BACKUP_STALE_CODE, + activeKeyId: result.activeKeyId, + fingerprint: result.fingerprint, + }, + ); + } + + await auditLog("encryption.keyBackup.confirmed", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + keyId: result.status.activeKeyId, + fingerprint: result.status.fingerprint, + }, + }); + return apiSuccess(result.status); +}); diff --git a/src/app/api/admin/encryption/key-backup/route.ts b/src/app/api/admin/encryption/key-backup/route.ts new file mode 100644 index 000000000..a41afa6bc --- /dev/null +++ b/src/app/api/admin/encryption/key-backup/route.ts @@ -0,0 +1,22 @@ +/** + * GET /api/admin/encryption/key-backup + * + * Whether the "Back up your encryption key" step is due, for the admin step, + * the overview tile and the dashboard banner. A light read (one settings row), + * separate from `/status`, which walks the whole encrypted corpus. Key id and + * fingerprint only, never key material. Cookie-only admin. + */ +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { readKeyBackupStatus } from "@/lib/crypto/key-backup"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.encryption.keyBackup.status" } }); + const status = await readKeyBackupStatus(); + annotate({ meta: { key_backup_due: status.due } }); + return apiSuccess(status); +}); diff --git a/src/app/api/admin/encryption/key-backup/verify/route.ts b/src/app/api/admin/encryption/key-backup/verify/route.ts new file mode 100644 index 000000000..dbbbb732b --- /dev/null +++ b/src/app/api/admin/encryption/key-backup/verify/route.ts @@ -0,0 +1,64 @@ +/** + * POST /api/admin/encryption/key-backup/verify { encryptionKey } + * + * "Check my copy": the admin pastes the backed-up key and learns whether it + * is the active key. The value is compared in memory (constant time on the + * decoded bytes) and dropped. It is never stored, never logged, never echoed: + * the Zod issue list is not returned for this body (a validation failure is a + * plain 422), and the field name is on the observability denylist. + * + * A boolean answer is no oracle against a 256-bit key; the 5 per minute limit + * per admin is there to keep the route from being anyone's busy loop. Over + * plain HTTP the value crosses the network in clear, as the admin's password + * does; the client says so before sending. + */ +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto"; + +export const dynamic = "force-dynamic"; + +const KEY_BACKUP_VERIFY_LIMIT = 5; +const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000; + +const verifySchema = z + .object({ encryptionKey: z.string().min(1).max(256) }) + .strict(); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.encryption.keyBackup.verify" } }); + + const limit = await checkRateLimit( + `key-backup-verify:${user.id}`, + KEY_BACKUP_VERIFY_LIMIT, + KEY_BACKUP_VERIFY_WINDOW_MS, + ); + if (!limit.allowed) { + return apiError("Too many checks. Try again in a minute.", 429); + } + + const body = await safeJson(request, { maxBytes: 1024 }); + if (body.error) return body.error; + const parsed = verifySchema.safeParse(body.data); + if (!parsed.success) { + // Deliberately not `returnAllZodIssues`: nothing derived from this body + // goes back over the wire. + return apiError("Paste the key as 64 hex characters.", 422); + } + + const activeKeyId = getActiveKeyId(); + const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId); + annotate({ meta: { key_backup_verify_matches: matches } }); + await auditLog("encryption.keyBackup.verified", { + userId: user.id, + details: { keyId: activeKeyId, matches }, + }); + return apiSuccess({ matches, keyId: activeKeyId }); +}); diff --git a/src/app/api/admin/encryption/status/route.ts b/src/app/api/admin/encryption/status/route.ts index 4bda28af8..2ad234a82 100644 --- a/src/app/api/admin/encryption/status/route.ts +++ b/src/app/api/admin/encryption/status/route.ts @@ -17,6 +17,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; +import { readKeyBackupStatus } from "@/lib/crypto/key-backup"; import { annotate } from "@/lib/logging/context"; import { getConfiguredKeyIds } from "@/lib/crypto"; import { @@ -213,6 +214,7 @@ export const GET = apiHandler(async () => { const rotation = await readRotationState(); const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date()); + const keyBackup = await readKeyBackupStatus(); annotate({ meta: { @@ -233,6 +235,8 @@ export const GET = apiHandler(async () => { columns: scan.columns, rotation, backups, + // The "Back up your encryption key" step, bound to the active key. + keyBackup, // Every row rotated AND no backup still needs a retired key: the one // state in which dropping an old key loses nothing. Copies that recorded // no key ids count against it, because they may hold any key that diff --git a/src/app/api/health/__tests__/route.test.ts b/src/app/api/health/__tests__/route.test.ts index 60d87ca9a..691c9c6fe 100644 --- a/src/app/api/health/__tests__/route.test.ts +++ b/src/app/api/health/__tests__/route.test.ts @@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => { expect(ready.body).toEqual({ status: "ok" }); }); }); + +describe("GET /api/health while the encryption key does not match", () => { + it("answers 503 with the reason, for everyone, even with every component up", async () => { + const { setKeyMismatchState } = + await import("@/lib/boot/key-mismatch-state"); + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + getGlobalBoss.mockReturnValue({}); + setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" }); + try { + const { response, body } = await health(); + expect(response.status).toBe(503); + expect(body).toEqual({ + status: "degraded", + reason: "encryption_key_mismatch", + }); + } finally { + setKeyMismatchState(null); + } + const { response, body } = await health(); + expect(response.status).toBe(200); + expect(body).toEqual({ status: "ok" }); + }); +}); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 3524d61c0..2b27876a6 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -6,6 +6,10 @@ import { getSession } from "@/lib/auth/session"; import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { + isKeyMismatch, + KEY_MISMATCH_HEALTH_REASON, +} from "@/lib/boot/key-mismatch-state"; export const dynamic = "force-dynamic"; @@ -22,7 +26,13 @@ export const GET = apiHandler(async () => { const worker = getWorkerStatus(); const producerReady = getGlobalBoss() !== null; + // A process whose encryption key cannot open the database refuses every + // other route; the probe says so in a word every caller may see, because + // it is the one place a catalog install's status page reads. + const keyMismatch = isKeyMismatch(); + const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined; const status = + !keyMismatch && dbOk && (!shouldRunWeb() || producerReady) && (!shouldRunWorker() || worker.running) @@ -40,6 +50,7 @@ export const GET = apiHandler(async () => { return NextResponse.json( { status, + ...(reason ? { reason } : {}), timestamp: new Date().toISOString(), database: dbOk ? "connected" : "disconnected", worker: worker.running ? "running" : "stopped", @@ -52,7 +63,7 @@ export const GET = apiHandler(async () => { } return NextResponse.json( - { status }, + { status, ...(reason ? { reason } : {}) }, { status: statusCode, headers: cacheHeaders }, ); }); diff --git a/src/app/key-mismatch-page.tsx b/src/app/key-mismatch-page.tsx new file mode 100644 index 000000000..1d95eaf24 --- /dev/null +++ b/src/app/key-mismatch-page.tsx @@ -0,0 +1,43 @@ +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { Locale } from "@/lib/i18n/config"; + +/** + * What every page renders while the boot key check has found that the + * configured encryption key cannot open this database (see + * `src/lib/crypto/canary.ts`). A static server-rendered page with no client + * code and no data reads: the app behind it would only fail on every value. + * The API answers 503 with `encryption.key_mismatch` at the same time, and + * `/api/health` names the reason. + */ +export function KeyMismatchPage({ locale }: { locale: Locale }) { + const { t } = getServerTranslator(locale); + return ( +
+
+

+ {t("admin.keyMismatch.title")} +

+

{t("admin.keyMismatch.body")}

+
+

+ {t("admin.keyMismatch.fixTitle")} +

+
    +
  1. {t("admin.keyMismatch.fixRestore")}
  2. +
  3. {t("admin.keyMismatch.fixDatabase")}
  4. +
  5. {t("admin.keyMismatch.fixFresh")}
  6. +
+
+

+ {t("admin.keyMismatch.restartNote")} +

+

+ {t("admin.keyMismatch.codes", { + code: "encryption.key_mismatch", + reason: "encryption_key_mismatch", + })} +

+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 39d19e2fd..70e93c9dd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,8 @@ import { AuthShell } from "@/components/layout/auth-shell"; import { MonitoringBootstrap } from "@/components/monitoring/bootstrap"; import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter"; import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; +import { isKeyMismatch } from "@/lib/boot/key-mismatch-state"; +import { KeyMismatchPage } from "./key-mismatch-page"; const inter = Inter({ variable: "--font-sans", @@ -131,17 +133,23 @@ export default async function RootLayout({ /> - - - - {/* `DEMO_MODE` is a server-only env var; the proxy uses it to + {isKeyMismatch() ? ( + // The boot key check refused this process: no app shell, no data + // reads, one page that names the fix. + + ) : ( + + + + {/* `DEMO_MODE` is a server-only env var; the proxy uses it to block mutations. Resolve it here (server component) and thread the boolean into the client shell so the demo banner can render without a client-side detection path. */} - - {children} - - + + {children} + + + )} ); diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index d4384b607..cede7a6fc 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -50,6 +50,7 @@ import { useDashboardChartReveal, } from "@/components/dashboard/chart-reveal"; import { DashboardHeader } from "@/components/dashboard/dashboard-header"; +import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner"; import { TodayHero } from "@/components/daily/today-hero"; import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton"; import { @@ -913,6 +914,7 @@ export default function DashboardPageClient({
+ {/* Records other people share with you are reached from the account switcher in the top bar and the sidebar, not from a card here. The diff --git a/src/components/admin/__tests__/key-backup-card.test.tsx b/src/components/admin/__tests__/key-backup-card.test.tsx new file mode 100644 index 000000000..fc5163141 --- /dev/null +++ b/src/components/admin/__tests__/key-backup-card.test.tsx @@ -0,0 +1,98 @@ +/** + * The key backup step and its dashboard banner. + * + * 1. the card shows the key id and fingerprint and never anything shaped + * like key material; the platform hint picks the open tab; + * 2. a due step offers the confirmation, a done step does not; + * 3. the banner renders only for an admin and only while the step is due; + * 4. the copy resolves in German too. + * + * Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red; + * render the confirm button unconditionally and case 2 goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { KeyBackupCard } from "@/components/admin/key-backup-card"; +import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner"; +import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/encryption", +})); + +const DUE: KeyBackupStatus = { + due: true, + activeKeyId: "v1", + fingerprint: "a1b2c3d4e5f6", + confirmedAt: null, + confirmedKeyId: null, + confirmedFingerprint: null, + confirmedBy: null, + platformHint: "truenas", +}; + +const DONE: KeyBackupStatus = { + ...DUE, + due: false, + confirmedAt: "2026-10-03T10:00:00.000Z", + confirmedKeyId: "v1", + confirmedFingerprint: "a1b2c3d4e5f6", + confirmedBy: { id: "u1", email: "admin@example.test" }, +}; + +function render( + node: React.ReactNode, + status: KeyBackupStatus | null, + locale: "en" | "de" = "en", +): string { + const client = new QueryClient(); + if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe("KeyBackupCard", () => { + it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => { + const html = render(, DUE); + expect(html).toContain("a1b2c3d4e5f6"); + expect(html).toContain(">v1<"); + expect(html).toContain("Apps, select HealthLog"); + expect(html).not.toMatch(/[0-9a-f]{64}/); + }); + + it("offers the confirmation only while the step is due", () => { + expect(render(, DUE)).toContain( + "I have backed up this key", + ); + const done = render(, DONE); + expect(done).not.toContain("I have backed up this key"); + expect(done).toContain("admin@example.test"); + }); + + it("speaks German", () => { + expect(render(, DUE, "de")).toContain( + "Sichere deinen Verschlüsselungsschlüssel", + ); + }); +}); + +describe("KeyBackupBanner", () => { + it("renders for an admin while the step is due", () => { + expect(render(, DUE)).toContain( + 'data-testid="key-backup-banner"', + ); + }); + + it("renders nothing for a non-admin, or once the step is done", () => { + expect(render(, DUE)).toBe(""); + expect(render(, DONE)).toBe(""); + }); +}); diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index dda5efb26..0d8edc724 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -42,6 +42,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { getApiErrorMessage } from "./_shared"; +import { KeyBackupCard } from "./key-backup-card"; interface ColumnScan { model: string; @@ -130,27 +131,30 @@ export function EncryptionSection() { // the fix carried across. if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) { return ( - - -

- {t("admin.section.encryption.coverageDetail")} -

- - {statusQuery.isLoading ? ( -
- - {t("admin.section.encryption.loading")} -
- ) : ( -

- {t("admin.section.encryption.loadError")} +

+ + + +

+ {t("admin.section.encryption.coverageDetail")}

- )} -
+ + {statusQuery.isLoading ? ( +
+ + {t("admin.section.encryption.loading")} +
+ ) : ( +

+ {t("admin.section.encryption.loadError")} +

+ )} + +
); } @@ -162,6 +166,7 @@ export function EncryptionSection() { return (
+ {/* ── Coverage summary ───────────────────────────────────────── */} ` — the "Back up your encryption key" step on the admin + * Encryption page. + * + * Shows the active key id and its fingerprint (never the key), where the key + * lives on each platform, what is lost without it, a "Check my copy" field + * and the confirmation. The confirmation is bound to the key id and + * fingerprint shown here: if the server's key changed since the page loaded, + * the server answers 409 and the card reloads. + */ + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch"; +import { + useKeyBackupStatus, + type KeyBackupPlatform, + type KeyBackupStatus, +} from "./use-key-backup-status"; + +const PLATFORMS: KeyBackupPlatform[] = [ + "compose", + "truenas", + "coolify", + "unraid", + "portainer", +]; + +/** Literal keys per platform, so the i18n coverage guard sees every one. */ +function platformCopy( + t: (key: string) => string, + p: KeyBackupPlatform, +): { label: string; where: string } { + switch (p) { + case "truenas": + return { + label: t("admin.keyBackup.platform.truenasLabel"), + where: t("admin.keyBackup.platform.truenasWhere"), + }; + case "coolify": + return { + label: t("admin.keyBackup.platform.coolifyLabel"), + where: t("admin.keyBackup.platform.coolifyWhere"), + }; + case "unraid": + return { + label: t("admin.keyBackup.platform.unraidLabel"), + where: t("admin.keyBackup.platform.unraidWhere"), + }; + case "portainer": + return { + label: t("admin.keyBackup.platform.portainerLabel"), + where: t("admin.keyBackup.platform.portainerWhere"), + }; + case "compose": + return { + label: t("admin.keyBackup.platform.composeLabel"), + where: t("admin.keyBackup.platform.composeWhere"), + }; + } +} + +export function KeyBackupCard() { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + const status = useKeyBackupStatus(); + const [candidate, setCandidate] = useState(""); + const [checkResult, setCheckResult] = useState(null); + + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.adminEncryptionKeyBackup(), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.adminEncryptionStatus(), + }), + ]); + + const confirm = useMutation({ + mutationFn: (s: KeyBackupStatus) => + apiPost( + "/api/admin/encryption/key-backup/confirm", + { keyId: s.activeKeyId, fingerprint: s.fingerprint }, + { credentials: "include" }, + ), + onSuccess: async () => { + toast(t("admin.keyBackup.confirmed")); + await invalidate(); + }, + onError: async (err: Error) => { + if (err instanceof ApiError && err.status === 409) { + toast.error(t("admin.keyBackup.stale")); + await invalidate(); + return; + } + toast.error(err.message || t("admin.keyBackup.confirmFailed")); + }, + }); + + const verify = useMutation({ + // Not `apiPost`: no idempotency key, so no cached response row exists + // for a request whose body was a key. + mutationFn: (encryptionKey: string) => + apiFetch<{ matches: boolean }>( + "/api/admin/encryption/key-backup/verify", + { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ encryptionKey }), + }, + ), + onSuccess: (data) => { + setCheckResult(data.matches); + setCandidate(""); + }, + onError: (err: Error) => { + setCheckResult(null); + toast.error(err.message || t("admin.keyBackup.checkFailed")); + }, + }); + + const insecure = + typeof window !== "undefined" && window.isSecureContext === false; + + if (!status.data) { + return ( + + + {status.isError ? ( + void status.refetch()} + /> + ) : ( +
+ + {t("admin.keyBackup.loading")} +
+ )} +
+ ); + } + + const s = status.data; + return ( + + + {t("admin.keyBackup.badgeDue")} + + ) : ( + {t("admin.keyBackup.badgeDone")} + ) + } + /> + +
+
+
+ {t("admin.keyBackup.keyId")} +
+
{s.activeKeyId}
+
+
+
+ {t("admin.keyBackup.fingerprint")} +
+
+ {s.fingerprint} +
+
+
+ +

{t("admin.keyBackup.whatIsLost")}

+ + + + {PLATFORMS.map((p) => ( + + {platformCopy(t, p).label} + + ))} + + {PLATFORMS.map((p) => ( + + {platformCopy(t, p).where} + + ))} + + +

+ {t("admin.keyBackup.fingerprintHint")} +

+ +
{ + e.preventDefault(); + if (candidate.trim()) verify.mutate(candidate); + }} + > + +
+ { + setCandidate(e.target.value); + setCheckResult(null); + }} + className="font-mono" + /> + +
+

+ {insecure + ? t("admin.keyBackup.checkInsecureNote") + : t("admin.keyBackup.checkNote")} +

+ {checkResult !== null && ( +

+ {checkResult + ? t("admin.keyBackup.checkMatches") + : t("admin.keyBackup.checkNoMatch")} +

+ )} +
+ + {!s.due && s.confirmedAt && ( +

+ {s.confirmedBy?.email + ? t("admin.keyBackup.confirmedAtBy", { + date: fmt.dateTime(new Date(s.confirmedAt)), + who: s.confirmedBy.email, + }) + : t("admin.keyBackup.confirmedAt", { + date: fmt.dateTime(new Date(s.confirmedAt)), + })} +

+ )} + {s.due && s.confirmedKeyId && ( +

+ {t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })} +

+ )} + + {s.due && ( + + + + )} +
+ ); +} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index f51a1c7a7..0eb76b6c9 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -12,18 +12,28 @@ * don't re-fetch. */ -import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react"; +import { + Clock, + Cog, + Database, + Globe, + KeyRound, + Loader2, + Server, +} from "lucide-react"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; +import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { const { t } = useTranslations(); const { data: status, isError, refetch } = useSystemStatus(); const { data: version } = usePublicVersion(); + const { data: keyBackup } = useKeyBackupStatus(); return ( + {/* v1.40.0 — the encryption key backup step, warning while due. */} + {keyBackup && ( + + )} {/* v1.4.27 R5 — surface the offline-geo state so the maintainer spots the missing MAXMIND_LICENSE_KEY without crawling logs. The field is undefined on legacy responses; the row only diff --git a/src/components/admin/use-key-backup-status.ts b/src/components/admin/use-key-backup-status.ts new file mode 100644 index 000000000..6f4304924 --- /dev/null +++ b/src/components/admin/use-key-backup-status.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +export type KeyBackupPlatform = + "compose" | "truenas" | "coolify" | "unraid" | "portainer"; + +/** `GET /api/admin/encryption/key-backup`; key id and fingerprint only. */ +export interface KeyBackupStatus { + due: boolean; + activeKeyId: string; + fingerprint: string; + confirmedAt: string | null; + confirmedKeyId: string | null; + confirmedFingerprint: string | null; + confirmedBy: { id: string; email: string | null } | null; + platformHint: KeyBackupPlatform; +} + +/** + * The key backup step's state. `enabled` lets the dashboard banner ask only + * for an admin; the route itself is admin-only and cookie-only. + */ +export function useKeyBackupStatus(enabled = true) { + return useQuery({ + queryKey: queryKeys.adminEncryptionKeyBackup(), + queryFn: () => + apiGet("/api/admin/encryption/key-backup", { + credentials: "include", + }), + enabled, + staleTime: 60_000, + }); +} diff --git a/src/components/dashboard/key-backup-banner.tsx b/src/components/dashboard/key-backup-banner.tsx new file mode 100644 index 000000000..e9d409e13 --- /dev/null +++ b/src/components/dashboard/key-backup-banner.tsx @@ -0,0 +1,40 @@ +"use client"; + +/** + * Dashboard banner for an admin while the encryption key backup step is due. + * Not dismissable by design: it goes away when the step is done, and comes + * back when the key changes. Renders nothing for anyone who is not an admin, + * and asks the server nothing for them either. + */ + +import Link from "next/link"; +import { KeyRound } from "lucide-react"; +import { useTranslations } from "@/lib/i18n/context"; +import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status"; + +export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) { + const { t } = useTranslations(); + const { data } = useKeyBackupStatus(isAdmin); + if (!isAdmin || !data?.due) return null; + return ( +
+
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 6860753f9..9a3e2cb25 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -15,6 +15,19 @@ export async function register() { // A readiness summary must never break boot. } + // Does the configured encryption key open this database? A process whose + // key does not refuses every API request with one clear 503 and starts + // neither the producer nor the worker (a wrong key must never write). + try { + const [{ runBootKeyCheck }, { prisma }] = await Promise.all([ + import("@/lib/boot/key-check"), + import("@/lib/db"), + ]); + if (await runBootKeyCheck(prisma)) return; + } catch (err) { + console.error("[instrumentation] Encryption key check crashed:", err); + } + // Every Node process watches its own event loop — web-only // containers included; a stall report from the process serving // requests is the whole point. diff --git a/src/lib/__tests__/api-handler-key-mismatch.test.ts b/src/lib/__tests__/api-handler-key-mismatch.test.ts new file mode 100644 index 000000000..b4bbf5a63 --- /dev/null +++ b/src/lib/__tests__/api-handler-key-mismatch.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { setKeyMismatchState } from "@/lib/boot/key-mismatch-state"; + +const handler = vi.fn(async (_request: NextRequest) => + Response.json({ data: "ran", error: null }), +); +const route = apiHandler(handler); + +function call(path: string) { + return route(new NextRequest(`http://localhost${path}`)); +} + +afterEach(() => { + setKeyMismatchState(null); + handler.mockClear(); +}); + +describe("apiHandler while the encryption key does not match", () => { + it("refuses every route with 503 encryption.key_mismatch before the handler runs", async () => { + setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" }); + const response = await call("/api/measurements"); + expect(response.status).toBe(503); + const body = await response.json(); + expect(body.data).toBeNull(); + expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" }); + expect(response.headers.get("Retry-After")).toBe("300"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("still serves /api/health and /api/version", async () => { + setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" }); + for (const path of ["/api/health", "/api/version"]) { + const response = await call(path); + expect(response.status).toBe(200); + } + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("runs the handler when the key check passed", async () => { + const response = await call("/api/measurements"); + expect(response.status).toBe(200); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index cf464dea2..a47b66ab2 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -53,6 +53,11 @@ import { assertRecordSessionFence, attachRecordContextEcho, } from "./sharing/record-session-fence"; +import { + isKeyMismatch, + KEY_MISMATCH_ERROR_CODE, + KEY_MISMATCH_EXEMPT_PATHS, +} from "./boot/key-mismatch-state"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -258,7 +263,23 @@ export function apiHandler Promise>( eventStorage.run(evt, async () => { let response: Response | undefined; try { - response = await handler(...args); + // The boot key check found that the configured encryption key + // cannot open this database. Refuse before auth, before the + // handler: nothing here may read or write encrypted data. + if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) { + annotate({ action: { name: "encryption.key_mismatch.refused" } }); + response = NextResponse.json( + { + data: null, + error: + "The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.", + meta: { errorCode: KEY_MISMATCH_ERROR_CODE }, + }, + { status: 503, headers: { "Retry-After": "300" } }, + ); + } else { + response = await handler(...args); + } } catch (error) { if (error instanceof AiUnavailableError) { // An AI capability is unavailable for this record. One envelope diff --git a/src/lib/boot/key-check.ts b/src/lib/boot/key-check.ts new file mode 100644 index 000000000..0c77f820c --- /dev/null +++ b/src/lib/boot/key-check.ts @@ -0,0 +1,42 @@ +/** + * Runs the encryption key check once per process at boot and records the + * verdict for the request path (`./key-mismatch-state.ts`). + * + * Returns true when the process must refuse: the caller then starts neither + * the queue producer nor the worker, because a process holding the wrong key + * must never write. The process keeps running so `/api/health` and the + * explanation page can say why; a crash loop would hide the reason in a log + * that the people most likely to hit this (a NAS app catalog install) never + * open. + */ +import { + checkEncryptionKeyCanaries, + keyMismatchLogBlock, + type CanaryClient, +} from "@/lib/crypto/canary"; +import { setKeyMismatchState } from "./key-mismatch-state"; + +export async function runBootKeyCheck(client: CanaryClient): Promise { + const outcome = await checkEncryptionKeyCanaries(client); + if (outcome.state === "mismatch") { + setKeyMismatchState({ + keyIds: outcome.keyIds, + detectedAt: new Date().toISOString(), + }); + console.error(keyMismatchLogBlock(outcome.keyIds)); + return true; + } + setKeyMismatchState(null); + if (outcome.state === "error") { + // The check could not run. The loaders and the database each have their + // own loud signal for that; serving stays on. + console.warn( + `[boot] Encryption key check skipped: ${outcome.message.slice(0, 300)}`, + ); + } else if (outcome.written.length > 0) { + console.info( + `[boot] Encryption key check: recorded key id(s) ${outcome.written.join(", ")}`, + ); + } + return false; +} diff --git a/src/lib/boot/key-mismatch-state.ts b/src/lib/boot/key-mismatch-state.ts new file mode 100644 index 000000000..4f9864009 --- /dev/null +++ b/src/lib/boot/key-mismatch-state.ts @@ -0,0 +1,52 @@ +/** + * Process-wide record of the boot key check's verdict. + * + * The check runs once in `instrumentation.ts`; the readers are `apiHandler` + * (every API route), `/api/health` and the root layout. Those live in + * different bundles of the same Node process, so module state is not shared + * between them and the verdict is pinned on `globalThis` under a registry + * symbol instead. This module imports nothing, so pulling it into the request + * path costs nothing. + * + * Only a positive mismatch is recorded. A check that could not run (database + * unreachable, keys not configured) leaves the process serving: those failures + * already have their own loud signals, and refusing on them would turn a + * transient database hiccup at boot into an outage. + */ + +export interface KeyMismatchState { + /** Configured key ids whose canary did not open under the configured key. */ + keyIds: string[]; + /** When the check found it (ISO). */ + detectedAt: string; +} + +const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch"); + +type GlobalWithState = typeof globalThis & { + [STATE_SYMBOL]?: KeyMismatchState | null; +}; + +/** The paths that keep answering while the process refuses everything else. */ +export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet = new Set([ + "/api/health", + "/api/version", +]); + +/** Wire code for the refusal; catalogued in `src/lib/openapi/error-codes.ts`. */ +export const KEY_MISMATCH_ERROR_CODE = "encryption.key_mismatch"; + +/** The `/api/health` reason while the process refuses. */ +export const KEY_MISMATCH_HEALTH_REASON = "encryption_key_mismatch"; + +export function getKeyMismatchState(): KeyMismatchState | null { + return (globalThis as GlobalWithState)[STATE_SYMBOL] ?? null; +} + +export function isKeyMismatch(): boolean { + return getKeyMismatchState() !== null; +} + +export function setKeyMismatchState(state: KeyMismatchState | null): void { + (globalThis as GlobalWithState)[STATE_SYMBOL] = state; +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index d46a06fe2..10273774a 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -22,6 +22,7 @@ import { createHash, hkdfSync, randomBytes, + timingSafeEqual, } from "node:crypto"; import { Buffer } from "node:buffer"; import { getEvent } from "@/lib/logging/context"; @@ -195,6 +196,56 @@ export function getConfiguredKeyIds(): string[] { return [...keys.keys()]; } +/** + * A short, public fingerprint of one configured key: the first twelve hex + * characters of SHA-256 over the raw key bytes. Lets an operator tell which key + * a confirmation or a copy belongs to without the key ever leaving the process + * (48 bits of a hash of a 256-bit key; nothing to invert). Null for an id that + * is not configured. + */ +export function getKeyFingerprint(id: string): string | null { + const key = getKeyById(id); + return key ? fingerprintKeyBytes(key) : null; +} + +/** The fingerprint rule itself, for a key that is already decoded. */ +export function fingerprintKeyBytes(key: Buffer): string { + return createHash("sha256").update(key).digest("hex").slice(0, 12); +} + +/** + * Whether `candidate` (as an operator would paste it: 64 hex characters or a + * base64 32-byte value) decodes to the configured key `id`. Constant-time on + * the decoded bytes. A value that does not decode is simply not a match; the + * decoder's error text is never surfaced, because it can quote the input's + * length. + */ +export function candidateMatchesKey(candidate: string, id: string): boolean { + const key = getKeyById(id); + if (!key) return false; + let decoded: Buffer; + try { + decoded = decodeKey(candidate.trim(), id); + } catch { + return false; + } + return decoded.length === key.length && timingSafeEqual(decoded, key); +} + +/** + * Seal `plaintext` under a SPECIFIC configured key id rather than the active + * one. Exists for one caller, the boot key check (`src/lib/crypto/canary.ts`), + * which keeps one known value per key id; everything else writes with + * `encrypt()`. Throws for an id that is not configured. + */ +export function encryptUnderKeyId(plaintext: string, id: string): string { + const key = getKeyById(id); + if (!key) { + throw new Error(`Encryption key id '${id}' is not configured.`); + } + return `${id}.${encryptWithKey(plaintext, key)}`; +} + function encryptWithKey(plaintext: string, key: Buffer): string { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); diff --git a/src/lib/crypto/__tests__/canary.test.ts b/src/lib/crypto/__tests__/canary.test.ts new file mode 100644 index 000000000..b99220d2a --- /dev/null +++ b/src/lib/crypto/__tests__/canary.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetCryptoCacheForTests, + candidateMatchesKey, + encrypt, + encryptUnderKeyId, + fingerprintKeyBytes, + getKeyFingerprint, +} from "@/lib/crypto"; +import { + canaryPlaintext, + checkEncryptionKeyCanaries, + keyMismatchLogBlock, + type CanaryClient, +} from "../canary"; + +const KEY_A = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_B = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +function useKey(hex: string) { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", hex); + _resetCryptoCacheForTests(); +} + +/** An in-memory stand-in for the two raw statements and the probe delegates. */ +function fakeClient(opts: { + canaries?: Record; + probe?: Record>; +}) { + const canaries = new Map(Object.entries(opts.canaries ?? {})); + const inserts: string[] = []; + const client = { + async $queryRaw() { + return [...canaries.entries()].map(([key_id, ciphertext]) => ({ + key_id, + ciphertext, + })); + }, + async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) { + const [keyId, ciphertext] = values as [string, string]; + inserts.push(keyId); + if (!canaries.has(keyId)) canaries.set(keyId, ciphertext); + return 1; + }, + } as unknown as CanaryClient & Record; + for (const [delegate, fields] of Object.entries(opts.probe ?? {})) { + client[delegate] = { + async findFirst(args: { select: Record }) { + const field = Object.keys(args.select)[0]; + return field in fields ? { [field]: fields[field] } : null; + }, + }; + } + return { client, canaries, inserts }; +} + +describe("encryption key canary", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + useKey(KEY_A); + }); + + it("writes a canary for a configured key with no row and no data", async () => { + const { client, canaries, inserts } = fakeClient({}); + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome).toEqual({ state: "ok", written: ["v1"], verified: [] }); + expect(inserts).toEqual(["v1"]); + expect(canaries.get("v1")?.startsWith("v1.")).toBe(true); + }); + + it("verifies a canary written under the same key", async () => { + const { client, inserts } = fakeClient({ + canaries: { v1: encryptUnderKeyId(canaryPlaintext("v1"), "v1") }, + }); + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome).toEqual({ state: "ok", written: [], verified: ["v1"] }); + expect(inserts).toEqual([]); + }); + + it("reports a mismatch when the key changed under the same id", async () => { + const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1"); + useKey(KEY_B); + const { client, inserts } = fakeClient({ canaries: { v1: sealedUnderA } }); + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] }); + expect(inserts).toEqual([]); + }); + + it("reports a mismatch for a canary that opens to the wrong value", async () => { + // A row from another key id copied over: opens, but says the wrong thing. + const { client } = fakeClient({ + canaries: { v1: encryptUnderKeyId(canaryPlaintext("v2"), "v1") }, + }); + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome.state).toBe("mismatch"); + }); + + it("refuses to seal a wrong key when existing data predates the canary", async () => { + const existing = encrypt("a stored token"); + useKey(KEY_B); + const { client, inserts } = fakeClient({ + probe: { user: { codexAccessTokenEncrypted: existing } }, + }); + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] }); + expect(inserts).toEqual([]); + }); + + it("writes the canary when existing data opens under the key", async () => { + const { client, inserts } = fakeClient({ + probe: { user: { codexAccessTokenEncrypted: encrypt("a stored token") } }, + }); + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome.state).toBe("ok"); + expect(inserts).toEqual(["v1"]); + }); + + it("reports an error, not a mismatch, when the table cannot be read", async () => { + const client = { + $queryRaw: async () => { + throw new Error('relation "encryption_key_canaries" does not exist'); + }, + $executeRaw: async () => 0, + } as unknown as CanaryClient; + const outcome = await checkEncryptionKeyCanaries(client); + expect(outcome.state).toBe("error"); + }); + + it("reports an error when no key is configured", async () => { + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); + const outcome = await checkEncryptionKeyCanaries(fakeClient({}).client); + expect(outcome.state).toBe("error"); + }); + + it("names the key ids and never a key in the log block", () => { + const block = keyMismatchLogBlock(["v1"]); + expect(block).toContain("'v1'"); + expect(block).toContain("encryption.key_mismatch"); + expect(block).not.toContain(KEY_A); + }); +}); + +describe("key fingerprint and copy check", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + useKey(KEY_A); + }); + + it("is the first 12 hex of SHA-256 over the raw key bytes", () => { + expect(getKeyFingerprint("v1")).toBe( + fingerprintKeyBytes(Buffer.from(KEY_A, "hex")), + ); + expect(getKeyFingerprint("v1")).toMatch(/^[0-9a-f]{12}$/); + expect(getKeyFingerprint("v1")).not.toBe( + fingerprintKeyBytes(Buffer.from(KEY_B, "hex")), + ); + expect(getKeyFingerprint("nope")).toBeNull(); + }); + + it("matches the configured key in hex, base64 and with whitespace", () => { + expect(candidateMatchesKey(KEY_A, "v1")).toBe(true); + expect(candidateMatchesKey(` ${KEY_A.toUpperCase()}\n`, "v1")).toBe(true); + expect( + candidateMatchesKey(Buffer.from(KEY_A, "hex").toString("base64"), "v1"), + ).toBe(true); + }); + + it("does not match another key, garbage, or an unknown id", () => { + expect(candidateMatchesKey(KEY_B, "v1")).toBe(false); + expect(candidateMatchesKey("not a key", "v1")).toBe(false); + expect(candidateMatchesKey(KEY_A, "v9")).toBe(false); + }); +}); diff --git a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts index b6a156acf..5b2814c04 100644 --- a/src/lib/crypto/__tests__/encrypted-column-writers.test.ts +++ b/src/lib/crypto/__tests__/encrypted-column-writers.test.ts @@ -60,11 +60,19 @@ const RELATION_ENVELOPE = new Set([ ]); /** - * Columns this scan derives that are deliberately NOT in the rotation - * registry. Empty today. An entry here is a written decision with a reason, - * never a way to quiet the guard. + * Columns that hold ciphertext and are deliberately NOT in the rotation + * registry. An entry here is a written decision with a reason, never a way to + * quiet the guard. + * + * `EncryptionKeyCanary.ciphertext` — one known value per key id, sealed under + * THAT key (`encryptUnderKeyId`, written by raw SQL in + * `src/lib/crypto/canary.ts`). Re-sealing it under the active key would make + * every id's boot check test the active key instead of its own. A retired + * key's row is never read again: only configured ids are checked. */ -const NOT_ROTATED: ReadonlySet = new Set(); +const NOT_ROTATED: ReadonlySet = new Set([ + "EncryptionKeyCanary.ciphertext", +]); /** * Ciphertext written where the Prisma-call walk below cannot see it: by raw diff --git a/src/lib/crypto/__tests__/key-backup.test.ts b/src/lib/crypto/__tests__/key-backup.test.ts new file mode 100644 index 000000000..e82929db8 --- /dev/null +++ b/src/lib/crypto/__tests__/key-backup.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { isKeyBackupDue, resolvePlatformHint } from "../key-backup"; + +const confirmed = (keyId: string, fingerprint: string) => ({ + encryptionKeyBackupConfirmedAt: new Date("2026-10-03T10:00:00Z"), + encryptionKeyBackupConfirmedKeyId: keyId, + encryptionKeyBackupConfirmedFingerprint: fingerprint, +}); + +describe("key backup step", () => { + it("is due without any confirmation", () => { + expect(isKeyBackupDue(null, "v1", "abcdefabcdef")).toBe(true); + expect( + isKeyBackupDue( + { + encryptionKeyBackupConfirmedAt: null, + encryptionKeyBackupConfirmedKeyId: null, + encryptionKeyBackupConfirmedFingerprint: null, + }, + "v1", + "abcdefabcdef", + ), + ).toBe(true); + }); + + it("is done only for the exact key that was confirmed", () => { + expect( + isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "abcdefabcdef"), + ).toBe(false); + }); + + it("is due again after a rotation (new active id)", () => { + expect( + isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v2", "abcdefabcdef"), + ).toBe(true); + }); + + it("is due again after a re-keyed install (same id, other key)", () => { + expect( + isKeyBackupDue(confirmed("v1", "abcdefabcdef"), "v1", "123456123456"), + ).toBe(true); + }); + + it("reads the platform hint and falls back to compose", () => { + expect(resolvePlatformHint("truenas")).toBe("truenas"); + expect(resolvePlatformHint(" Unraid ")).toBe("unraid"); + expect(resolvePlatformHint(undefined)).toBe("compose"); + expect(resolvePlatformHint("kubernetes")).toBe("compose"); + }); +}); diff --git a/src/lib/crypto/canary.ts b/src/lib/crypto/canary.ts new file mode 100644 index 000000000..e925b8d43 --- /dev/null +++ b/src/lib/crypto/canary.ts @@ -0,0 +1,211 @@ +/** + * Boot check: does the configured encryption key open this database? + * + * Without it, a server started with a different `ENCRYPTION_KEY` than the one + * its database was written with (a reinstalled NAS app over an old dataset, a + * restored dump under a fresh key) signs people in normally, because sessions + * hash with `API_TOKEN_HMAC_KEY`, and then answers random 500s wherever a value + * has to be decrypted. This module turns that into one clear refusal. + * + * For every configured key id, `encryption_key_canaries` holds a known + * plaintext sealed under that key: + * + * - row present: it must decrypt to the expected value, else the key is not + * the one the row was written with; + * - row absent: before writing one, look for ANY existing ciphertext under + * that key id in the registered string columns and try it. Without this + * probe, the first boot after an upgrade (or a re-keyed boot over a + * database that predates the canary) would seal the wrong key as the + * right one. A value that opens, or no value at all, lets the canary be + * written (`ON CONFLICT DO NOTHING`, so two processes racing is fine). + * + * The probe is bounded by a time budget; past it, the canary is written + * without a verdict from existing data, which is exactly the behaviour before + * this check existed. + * + * Never logs or returns key material. The canary is written by raw SQL and is + * deliberately not in the rotation registry: it belongs to its key id. + */ +import { + decrypt, + encryptUnderKeyId, + extractKeyId, + getConfiguredKeyIds, +} from "@/lib/crypto"; +import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns"; + +export const CANARY_PREFIX = "healthlog-canary:"; + +export function canaryPlaintext(keyId: string): string { + return `${CANARY_PREFIX}${keyId}`; +} + +/** How long the existing-data probe may take before the canary is written anyway. */ +export const PROBE_BUDGET_MS = 10_000; + +/** The slice of a Prisma client this module needs. */ +export interface CanaryClient { + $queryRaw( + query: TemplateStringsArray, + ...values: unknown[] + ): Promise; + $executeRaw( + query: TemplateStringsArray, + ...values: unknown[] + ): Promise; +} + +type ProbeDelegate = { + findFirst: (args: { + where: Record; + select: Record; + }) => Promise | null>; +}; + +export type KeyCheckOutcome = + | { + state: "ok"; + /** Key ids whose canary this run wrote. */ + written: string[]; + /** Key ids whose canary existed and opened. */ + verified: string[]; + } + | { state: "mismatch"; keyIds: string[] } + | { state: "error"; message: string }; + +function opensAs(ciphertext: string, keyId: string, expected?: string) { + try { + if (extractKeyId(ciphertext) !== keyId) return false; + const plain = decrypt(ciphertext); + return expected === undefined ? true : plain === expected; + } catch { + return false; + } +} + +/** + * Look for one existing value sealed under `keyId` and try it. Returns + * `"opens"`, `"fails"`, or `"none"` (no value found inside the budget). + */ +export async function probeExistingData( + client: unknown, + keyId: string, + deadline: number, +): Promise<"opens" | "fails" | "none"> { + const delegates = client as Record; + for (const column of ENCRYPTED_COLUMNS) { + if (Date.now() > deadline) return "none"; + if (column.kind !== "string" || column.codec || column.codecField) { + continue; + } + const delegate = + delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)]; + if (!delegate || typeof delegate.findFirst !== "function") continue; + let row: Record | null; + try { + row = await delegate.findFirst({ + where: { [column.field]: { startsWith: `${keyId}.` } }, + select: { [column.field]: true }, + }); + } catch { + continue; + } + const value = row?.[column.field]; + if (typeof value !== "string") continue; + return opensAs(value, keyId) ? "opens" : "fails"; + } + return "none"; +} + +/** + * Run the check for every configured key id. Never throws: a failure to run + * the check at all is reported as `error`, and only a value that provably does + * not open is reported as `mismatch`. + */ +export async function checkEncryptionKeyCanaries( + client: CanaryClient, + options: { probeBudgetMs?: number } = {}, +): Promise { + let keyIds: string[]; + try { + keyIds = getConfiguredKeyIds(); + } catch (err) { + return { state: "error", message: (err as Error).message }; + } + + let rows: Array<{ key_id: string; ciphertext: string }>; + try { + rows = await client.$queryRaw< + Array<{ key_id: string; ciphertext: string }> + >`SELECT key_id, ciphertext FROM encryption_key_canaries WHERE key_id = ANY(${keyIds})`; + } catch (err) { + return { state: "error", message: (err as Error).message }; + } + const stored = new Map(rows.map((r) => [r.key_id, r.ciphertext])); + + const mismatched: string[] = []; + const verified: string[] = []; + const missing: string[] = []; + for (const keyId of keyIds) { + const ciphertext = stored.get(keyId); + if (ciphertext === undefined) { + missing.push(keyId); + } else if (opensAs(ciphertext, keyId, canaryPlaintext(keyId))) { + verified.push(keyId); + } else { + mismatched.push(keyId); + } + } + + const deadline = Date.now() + (options.probeBudgetMs ?? PROBE_BUDGET_MS); + const toWrite: string[] = []; + for (const keyId of missing) { + const probe = await probeExistingData(client, keyId, deadline); + if (probe === "fails") mismatched.push(keyId); + else toWrite.push(keyId); + } + + if (mismatched.length > 0) { + // Nothing is written while any key fails: a process in this state must + // not leave anything behind sealed under a key that may be wrong. + return { state: "mismatch", keyIds: mismatched.sort() }; + } + + try { + for (const keyId of toWrite) { + const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId); + await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`; + } + } catch (err) { + return { state: "error", message: (err as Error).message }; + } + + return { state: "ok", written: toWrite, verified }; +} + +/** + * The one block an operator reads in the container log. English literal on + * purpose: logs are not translated. Names the key ids, never a key. + */ +export function keyMismatchLogBlock(keyIds: string[]): string { + const ids = keyIds.map((id) => `'${id}'`).join(", "); + return [ + "============================================================", + " HealthLog refuses to serve: encryption key does not match", + "============================================================", + ` The encryption key configured for key id ${ids} cannot open the`, + " data already stored in this database. Serving anyway would fail on", + " every encrypted value, so every API request answers 503 with", + " errorCode 'encryption.key_mismatch' and /api/health reports", + " reason 'encryption_key_mismatch'. Background jobs are not started.", + "", + " Fix it one of three ways, then restart:", + " 1. Restore the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry)", + " this database was written with.", + " 2. Point DATABASE_URL at the database that belongs to this key.", + " 3. Start fresh: an empty database with this key. Stored data and", + " in-database backups of the old database cannot be read without", + " the old key.", + "============================================================", + ].join("\n"); +} diff --git a/src/lib/crypto/key-backup.ts b/src/lib/crypto/key-backup.ts new file mode 100644 index 000000000..6ea14555c --- /dev/null +++ b/src/lib/crypto/key-backup.ts @@ -0,0 +1,142 @@ +/** + * The "Back up your encryption key" step: instance state and its rules. + * + * The confirmation lives on the `AppSettings` singleton, bound to the active + * key id and that key's fingerprint, so it is an instance fact (it survives + * the admin who gave it) and a rotation or a re-keyed install makes the step + * due again. Nothing here ever reads, returns or stores the key itself. + */ +import { prisma } from "@/lib/db"; +import { envValue } from "@/lib/env"; +import { getActiveKeyId, getKeyFingerprint } from "@/lib/crypto"; + +/** Where the operator sets the key; picks the tab the step opens on. */ +export const KEY_BACKUP_PLATFORMS = [ + "compose", + "truenas", + "coolify", + "unraid", + "portainer", +] as const; +export type KeyBackupPlatform = (typeof KEY_BACKUP_PLATFORMS)[number]; + +/** Wire code for a confirmation given for a key that is no longer active. */ +export const KEY_BACKUP_STALE_CODE = "encryption.keyBackup.stale"; + +/** + * `HEALTHLOG_PLATFORM`, when it names a known platform; `compose` otherwise. + * Catalog templates set it so the step opens on the right instructions. + */ +export function resolvePlatformHint( + raw: string | undefined = envValue("HEALTHLOG_PLATFORM"), +): KeyBackupPlatform { + const value = raw?.trim().toLowerCase(); + return (KEY_BACKUP_PLATFORMS as readonly string[]).includes(value ?? "") + ? (value as KeyBackupPlatform) + : "compose"; +} + +export interface KeyBackupStatus { + /** True until a confirmation exists for the active key id AND fingerprint. */ + due: boolean; + activeKeyId: string; + /** Fingerprint of the active key (first 12 hex of SHA-256 over its bytes). */ + fingerprint: string; + confirmedAt: string | null; + confirmedKeyId: string | null; + confirmedFingerprint: string | null; + /** The confirming admin, while that account exists. */ + confirmedBy: { id: string; email: string | null } | null; + platformHint: KeyBackupPlatform; +} + +interface StoredConfirmation { + encryptionKeyBackupConfirmedAt: Date | null; + encryptionKeyBackupConfirmedKeyId: string | null; + encryptionKeyBackupConfirmedFingerprint: string | null; +} + +/** Pure rule: is the stored confirmation for this exact key? */ +export function isKeyBackupDue( + stored: StoredConfirmation | null, + activeKeyId: string, + fingerprint: string, +): boolean { + return !( + stored?.encryptionKeyBackupConfirmedAt && + stored.encryptionKeyBackupConfirmedKeyId === activeKeyId && + stored.encryptionKeyBackupConfirmedFingerprint === fingerprint + ); +} + +function activeKey(): { activeKeyId: string; fingerprint: string } { + const activeKeyId = getActiveKeyId(); + // The active id is always configured, so the fingerprint is never null. + return { activeKeyId, fingerprint: getKeyFingerprint(activeKeyId) ?? "" }; +} + +export async function readKeyBackupStatus(): Promise { + const { activeKeyId, fingerprint } = activeKey(); + const row = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + encryptionKeyBackupConfirmedAt: true, + encryptionKeyBackupConfirmedKeyId: true, + encryptionKeyBackupConfirmedFingerprint: true, + encryptionKeyBackupConfirmedByUserId: true, + }, + }); + const confirmedBy = row?.encryptionKeyBackupConfirmedByUserId + ? await prisma.user.findUnique({ + where: { id: row.encryptionKeyBackupConfirmedByUserId }, + select: { id: true, email: true }, + }) + : null; + return { + due: isKeyBackupDue(row, activeKeyId, fingerprint), + activeKeyId, + fingerprint, + confirmedAt: row?.encryptionKeyBackupConfirmedAt?.toISOString() ?? null, + confirmedKeyId: row?.encryptionKeyBackupConfirmedKeyId ?? null, + confirmedFingerprint: row?.encryptionKeyBackupConfirmedFingerprint ?? null, + confirmedBy: confirmedBy ?? null, + platformHint: resolvePlatformHint(), + }; +} + +export type ConfirmKeyBackupResult = + | { ok: true; status: KeyBackupStatus } + | { ok: false; activeKeyId: string; fingerprint: string }; + +/** + * Record the confirmation, but only for the key the admin was shown: a page + * opened before a rotation must not confirm the new key. + */ +export async function confirmKeyBackup(input: { + keyId: string; + fingerprint: string; + userId: string; +}): Promise { + const { activeKeyId, fingerprint } = activeKey(); + if (input.keyId !== activeKeyId || input.fingerprint !== fingerprint) { + return { ok: false, activeKeyId, fingerprint }; + } + const now = new Date(); + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { + id: "singleton", + encryptionKeyBackupConfirmedAt: now, + encryptionKeyBackupConfirmedKeyId: activeKeyId, + encryptionKeyBackupConfirmedFingerprint: fingerprint, + encryptionKeyBackupConfirmedByUserId: input.userId, + }, + update: { + encryptionKeyBackupConfirmedAt: now, + encryptionKeyBackupConfirmedKeyId: activeKeyId, + encryptionKeyBackupConfirmedFingerprint: fingerprint, + encryptionKeyBackupConfirmedByUserId: input.userId, + }, + }); + return { ok: true, status: await readKeyBackupStatus() }; +} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index d6769473b..5dfd0531c 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -295,6 +295,8 @@ export const WIPE_EXEMPT: Readonly> = { */ export const INSTANCE_SCOPED: Readonly> = { AppSettings: "instance-wide configuration singleton", + EncryptionKeyCanary: + "one known value per configured encryption key id, checked at boot; belongs to the instance's keys, not to any account", RateLimit: "instance-wide rate-limit buckets, keyed by string, self-expiring", HostMetric: "instance-wide host telemetry, not attributable to an account", OffhostBackupKeyUse: diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index 327ad6d68..848ff09e3 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -96,6 +96,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ // `client_secret` are already covered by `/token/i` + `/secret/i`; the // verifier is the one secret-shaped key those patterns miss. /verifier/i, + // v1.40.0 — the encryption key an admin pastes into "Check my copy" + // (`POST /api/admin/encryption/key-backup/verify`). The route never logs its + // body; this keeps it out of any excerpt a future change might take. + // Anchored so `encryptionKeyId` and friends stay readable. + /^encryption[_-]?key$/i, ]; /** diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index d54d2e68e..1404ab306 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -165,6 +165,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "encounter.reminder-conflict", "encounter.reminder-not-found", ], + encryption: ["encryption.keyBackup.stale", "encryption.key_mismatch"], environment: [ "environment.invalid", "environment.no_home", diff --git a/src/lib/openapi/routes/health.ts b/src/lib/openapi/routes/health.ts index 8d394f38b..1f9ffa54b 100644 --- a/src/lib/openapi/routes/health.ts +++ b/src/lib/openapi/routes/health.ts @@ -25,15 +25,24 @@ const healthStatusEnum = z.enum(["ok", "degraded"]).meta({ "`ok` when the database answers AND every role this process runs holds up: the pg-boss producer is bound when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing.", }); -const healthPublicResponse = z.object({ status: healthStatusEnum }).meta({ - id: "HealthPublicResponse", +const healthReason = z.enum(["encryption_key_mismatch"]).optional().meta({ + id: "HealthReason", description: - "What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.", + "Present only when the process refuses to serve for a reason a person has to fix. `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, point the server at the matching database, or start with an empty one; then restart.", }); +const healthPublicResponse = z + .object({ status: healthStatusEnum, reason: healthReason }) + .meta({ + id: "HealthPublicResponse", + description: + "What an unauthenticated (or non-admin) caller sees: the aggregate verdict and nothing else. No component detail, so an uptime probe cannot be used to fingerprint which part of a self-hosted instance is unwell.", + }); + const healthAdminResponse = z .object({ status: healthStatusEnum, + reason: healthReason, timestamp: z.iso .datetime({ offset: true }) .describe("When the probe ran. UTC (`Z`) — no user context is loaded."), diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 8a90672d1..23b9603ad 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -48,6 +48,9 @@ export const adminKeys = { adminBackupRestores: () => ["admin", "backup-restores"] as const, /** v1.23 — encryption coverage + rotation progress (admin Encryption section). */ adminEncryptionStatus: () => ["admin", "encryption", "status"] as const, + /** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */ + adminEncryptionKeyBackup: () => + ["admin", "encryption", "key-backup"] as const, adminCoachFeedback: () => ["admin", "coach-feedback"] as const, adminFeedback: (status: string) => ["admin", "feedback", status] as const, adminFeedbackRoot: () => ["admin", "feedback"] as const, diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts new file mode 100644 index 000000000..1838a7c58 --- /dev/null +++ b/tests/integration/encryption-key-check.test.ts @@ -0,0 +1,290 @@ +/** + * The boot encryption key check and the "Back up your encryption key" step, + * against real Postgres. + * + * - a first boot writes a canary; a second boot with the same key opens it; + * - a boot with another key under the same id refuses: the state is set, + * every API route answers 503 `encryption.key_mismatch`, `/api/health` + * names the reason, and nothing is written; + * - a database written before the canary existed is probed: data that does + * not open under the configured key refuses rather than sealing the wrong + * key as the right one; + * - the confirmation binds to the active key id and fingerprint, refuses a + * stale one with 409, and is due again after a re-keyed install; + * - "Check my copy" answers a boolean, stores nothing, and is rate limited. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { + _resetCryptoCacheForTests, + encrypt, + getKeyFingerprint, +} from "@/lib/crypto"; +import { runBootKeyCheck } from "@/lib/boot/key-check"; +import { + isKeyMismatch, + setKeyMismatchState, +} from "@/lib/boot/key-mismatch-state"; + +const KEY_A = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_B = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +function useKey(hex: string) { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", hex); + _resetCryptoCacheForTests(); +} + +async function seedAdmin(): Promise { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "key-admin", + email: "key-admin@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: admin.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return admin.id; +} + +/** The GET handlers take no parameter; `apiHandler` still reads the request. */ +type RouteFn = (request: NextRequest) => Promise; +const asRoute = (fn: unknown) => fn as RouteFn; + +function post(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.$executeRaw`DELETE FROM encryption_key_canaries`; + await prisma.$executeRaw`DELETE FROM rate_limits WHERE key LIKE 'key-backup-verify:%'`; + cookieJar.clear(); + headerJar.clear(); + useKey(KEY_A); + setKeyMismatchState(null); +}); + +afterEach(() => { + setKeyMismatchState(null); + vi.unstubAllEnvs(); + _resetCryptoCacheForTests(); +}); + +describe("boot encryption key check (real Postgres)", () => { + it("writes a canary on first boot and opens it on the next", async () => { + const prisma = getPrismaClient(); + expect(await runBootKeyCheck(prisma)).toBe(false); + const rows = await prisma.encryptionKeyCanary.findMany(); + expect(rows.map((r) => r.keyId)).toEqual(["v1"]); + expect(rows[0].ciphertext.startsWith("v1.")).toBe(true); + + expect(await runBootKeyCheck(prisma)).toBe(false); + expect(isKeyMismatch()).toBe(false); + }); + + it("refuses to serve when the key changed under the same id", async () => { + const prisma = getPrismaClient(); + await runBootKeyCheck(prisma); + const before = await prisma.encryptionKeyCanary.findMany(); + + useKey(KEY_B); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(true); + expect(errors.mock.calls[0]?.[0]).toContain("encryption.key_mismatch"); + errors.mockRestore(); + expect(isKeyMismatch()).toBe(true); + // Nothing re-sealed under the wrong key. + expect(await prisma.encryptionKeyCanary.findMany()).toEqual(before); + + await seedAdmin(); + const { GET: keyBackupGet } = + await import("@/app/api/admin/encryption/key-backup/route"); + const refused = await asRoute(keyBackupGet)( + new NextRequest("http://localhost/api/admin/encryption/key-backup"), + ); + expect(refused.status).toBe(503); + const body = await refused.json(); + expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" }); + + const { GET: healthGet } = await import("@/app/api/health/route"); + const health = await asRoute(healthGet)( + new NextRequest("http://localhost/api/health"), + ); + expect(health.status).toBe(503); + const healthBody = await health.json(); + expect(healthBody.status).toBe("degraded"); + expect(healthBody.reason).toBe("encryption_key_mismatch"); + + // Restoring the original key clears it on the next boot. + useKey(KEY_A); + expect(await runBootKeyCheck(prisma)).toBe(false); + expect(isKeyMismatch()).toBe(false); + }); + + it("refuses rather than sealing the wrong key over data that predates the canary", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + username: "existing", + email: "existing@example.test", + codexAccessTokenEncrypted: encrypt("a stored token"), + }, + }); + + useKey(KEY_B); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(await runBootKeyCheck(prisma)).toBe(true); + errors.mockRestore(); + expect(await prisma.encryptionKeyCanary.count()).toBe(0); + + // With the right key the same data lets the canary be written. + useKey(KEY_A); + expect(await runBootKeyCheck(prisma)).toBe(false); + expect(await prisma.encryptionKeyCanary.count()).toBe(1); + }); +}); + +describe("encryption key backup step (real Postgres)", () => { + it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => { + const prisma = getPrismaClient(); + const adminId = await seedAdmin(); + const { GET } = await import("@/app/api/admin/encryption/key-backup/route"); + const { POST: confirm } = + await import("@/app/api/admin/encryption/key-backup/confirm/route"); + const status = async () => + ( + await ( + await asRoute(GET)( + new NextRequest("http://localhost/api/admin/encryption/key-backup"), + ) + ).json() + ).data; + + const first = await status(); + expect(first.due).toBe(true); + expect(first.activeKeyId).toBe("v1"); + expect(first.fingerprint).toBe(getKeyFingerprint("v1")); + expect(first.platformHint).toBe("compose"); + expect(JSON.stringify(first)).not.toContain(KEY_A); + + const stale = await confirm( + post("/api/admin/encryption/key-backup/confirm", { + keyId: "v1", + fingerprint: "000000000000", + }), + ); + expect(stale.status).toBe(409); + expect((await stale.json()).meta.errorCode).toBe( + "encryption.keyBackup.stale", + ); + expect( + (await prisma.appSettings.findUnique({ where: { id: "singleton" } })) + ?.encryptionKeyBackupConfirmedAt ?? null, + ).toBeNull(); + + const ok = await confirm( + post("/api/admin/encryption/key-backup/confirm", { + keyId: "v1", + fingerprint: first.fingerprint, + }), + ); + expect(ok.status).toBe(200); + const confirmed = (await ok.json()).data; + expect(confirmed.due).toBe(false); + expect(confirmed.confirmedBy).toEqual({ + id: adminId, + email: "key-admin@example.test", + }); + const audit = await prisma.auditLog.findFirst({ + where: { action: "encryption.keyBackup.confirmed" }, + }); + expect(audit).not.toBeNull(); + + vi.stubEnv("HEALTHLOG_PLATFORM", "truenas"); + useKey(KEY_B); + const after = await status(); + expect(after.due).toBe(true); + expect(after.confirmedKeyId).toBe("v1"); + expect(after.platformHint).toBe("truenas"); + }); + + it("checks a copy without storing it, and limits the checks", async () => { + const prisma = getPrismaClient(); + await seedAdmin(); + const { POST: verify } = + await import("@/app/api/admin/encryption/key-backup/verify/route"); + const check = (encryptionKey: string) => + verify( + post("/api/admin/encryption/key-backup/verify", { encryptionKey }), + ); + + const good = await check(`${KEY_A}\n`); + expect(good.status).toBe(200); + expect((await good.json()).data).toEqual({ matches: true, keyId: "v1" }); + + const bad = await check(KEY_B); + expect((await bad.json()).data.matches).toBe(false); + + const invalid = await verify( + post("/api/admin/encryption/key-backup/verify", { encryptionKey: "" }), + ); + expect(invalid.status).toBe(422); + expect(JSON.stringify(await invalid.json())).not.toContain("issues"); + + // Nothing of the candidate is stored anywhere a row could hold it. + const audits = await prisma.auditLog.findMany(); + expect(JSON.stringify(audits)).not.toContain(KEY_A); + expect(JSON.stringify(audits)).not.toContain(KEY_B); + expect( + (await prisma.appSettings.findUnique({ where: { id: "singleton" } })) + ?.encryptionKeyBackupConfirmedAt ?? null, + ).toBeNull(); + + await check(KEY_B); + await check(KEY_B); + const limited = await check(KEY_A); + expect(limited.status).toBe(429); + }); +}); From 911e642c240ab1372238b9d299412e74de559d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:06:33 +0200 Subject: [PATCH 2/7] i18n: encryption key backup step, refusal page and dashboard banner Adds admin.keyBackup, admin.keyMismatch and dashboard.keyBackupBanner in all seven locales. --- messages/de.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ messages/en.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ messages/es.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ messages/fr.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ messages/it.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ messages/ko.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ messages/pl.json | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 392 insertions(+) diff --git a/messages/de.json b/messages/de.json index 64e5a0e0b..5bbc93fcb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "Sichere deinen Verschlüsselungsschlüssel", + "body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.", + "action": "Zum Schritt" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "Dieser Teil der Seite konnte nicht angezeigt werden", "description": "Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt." + }, + "keyBackup": { + "title": "Sichere deinen Verschlüsselungsschlüssel", + "description": "Ohne eine Kopie dieses Schlüssels lassen sich deine verschlüsselten Daten und Sicherungen nie wieder lesen.", + "loading": "Status der Schlüsselsicherung wird geladen…", + "loadError": "Der Status der Schlüsselsicherung konnte nicht geladen werden.", + "badgeDue": "Offen", + "badgeDone": "Gesichert", + "keyId": "Aktive Schlüssel-ID", + "fingerprint": "Fingerabdruck", + "whatIsLost": "Bewahre eine Kopie des Schlüssels außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Wird der Server mit einem anderen Schlüssel neu installiert, nimmt HealthLog den Betrieb erst wieder auf, wenn der ursprüngliche Schlüssel zurück ist.", + "fingerprintHint": "Der Fingerabdruck sind die ersten 12 Zeichen des SHA-256-Hashes des Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, damit du ihn mit deiner Kopie vergleichen kannst.", + "checkLabel": "Meine Kopie prüfen", + "checkButton": "Prüfen", + "checkNote": "Der eingefügte Schlüssel wird mit dem aktiven Schlüssel verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.", + "checkInsecureNote": "Diese Seite läuft nicht über https://, also geht der eingefügte Schlüssel unverschlüsselt über das Netz, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.", + "checkMatches": "Deine Kopie passt zum aktiven Schlüssel.", + "checkNoMatch": "Deine Kopie passt nicht zum aktiven Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.", + "checkFailed": "Die Prüfung ist fehlgeschlagen.", + "confirmButton": "Ich habe diesen Schlüssel gesichert", + "confirmed": "Schlüsselsicherung bestätigt.", + "confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.", + "stale": "Der Schlüssel des Servers hat sich geändert, seit diese Seite geladen wurde. Prüfe die neue Schlüssel-ID und den Fingerabdruck und bestätige dann erneut.", + "confirmedAt": "Bestätigt am {date}.", + "confirmedAtBy": "Bestätigt am {date} von {who}.", + "dueAgain": "Für Schlüssel {keyId} wurde eine Sicherung bestätigt, aber der Server nutzt jetzt einen anderen Schlüssel. Sichere den aktuellen.", + "tileLabel": "Sicherung des Verschlüsselungsschlüssels", + "tileDue": "Nicht bestätigt", + "tileDone": "Bestätigt", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Bearbeite im Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.", + "portainerLabel": "Portainer", + "portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY aus seinen Umgebungsvariablen." + } + }, + "keyMismatch": { + "title": "HealthLog kann seine Daten nicht öffnen", + "body": "Der Verschlüsselungsschlüssel, mit dem dieser Server gestartet wurde, ist nicht der, mit dem seine Datenbank geschrieben wurde. Zum Schutz deiner Daten liefert HealthLog nichts aus, bis das behoben ist.", + "fixTitle": "So behebst du es", + "fixRestore": "Trage den ursprünglichen ENCRYPTION_KEY (oder den Eintrag in ENCRYPTION_KEYS) wieder in die Servereinstellungen ein.", + "fixDatabase": "Oder lass DATABASE_URL auf die Datenbank zeigen, die zu diesem Schlüssel gehört.", + "fixFresh": "Oder starte neu mit einer leeren Datenbank. Die alten Daten und ihre Sicherungen lassen sich nur mit dem alten Schlüssel lesen.", + "restartNote": "Starte HealthLog nach der Änderung neu.", + "codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}." } }, "achievements": { diff --git a/messages/en.json b/messages/en.json index 2f5660ba4..9e01d4d71 100644 --- a/messages/en.json +++ b/messages/en.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "Back up your encryption key", + "body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.", + "action": "Open the step" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "This part of the page could not be shown", "description": "The rest of the page still works; try again or reload if it keeps failing." + }, + "keyBackup": { + "title": "Back up your encryption key", + "description": "Without a copy of this key, your encrypted data and this server's backups cannot be read again.", + "loading": "Loading the key backup status…", + "loadError": "Could not load the key backup status.", + "badgeDue": "To do", + "badgeDone": "Backed up", + "keyId": "Active key id", + "fingerprint": "Fingerprint", + "whatIsLost": "Keep a copy of the key somewhere outside this server, for example in a password manager. If the server is reinstalled with a different key, HealthLog refuses to start serving until the original key is back.", + "fingerprintHint": "The fingerprint is the first 12 characters of the SHA-256 hash of the key. It identifies the key without revealing it, so you can compare it with your copy.", + "checkLabel": "Check my copy", + "checkButton": "Check", + "checkNote": "The key you paste is compared with the active key and then discarded. It is never stored or logged.", + "checkInsecureNote": "This page is not served over https://, so the key you paste crosses the network unencrypted, just like your password does. It is compared and discarded, never stored or logged.", + "checkMatches": "Your copy matches the active key.", + "checkNoMatch": "Your copy does not match the active key. Check that you copied the whole value.", + "checkFailed": "The check failed.", + "confirmButton": "I have backed up this key", + "confirmed": "Key backup confirmed.", + "confirmFailed": "Could not save the confirmation.", + "stale": "The server's key changed since this page loaded. Check the new key id and fingerprint, then confirm again.", + "confirmedAt": "Confirmed on {date}.", + "confirmedAtBy": "Confirmed on {date} by {who}.", + "dueAgain": "A backup was confirmed for key {keyId}, but the server now uses another key. Back up the current one.", + "tileLabel": "Encryption key backup", + "tileDue": "Not confirmed", + "tileDone": "Confirmed", + "platform": { + "composeLabel": "Compose", + "composeWhere": "The key is ENCRYPTION_KEY (or ENCRYPTION_KEYS) in the .env file next to docker-compose.yml.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Open Apps, select HealthLog, choose Edit and copy ENCRYPTION_KEY from the environment settings.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY from its Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY from the template, or from the .env of your Compose Manager stack.", + "portainerLabel": "Portainer", + "portainerWhere": "Open the HealthLog stack in Portainer and copy ENCRYPTION_KEY from its environment variables." + } + }, + "keyMismatch": { + "title": "HealthLog cannot open its data", + "body": "The encryption key this server was started with is not the key its database was written with. To protect your data, HealthLog does not serve anything until this is fixed.", + "fixTitle": "How to fix it", + "fixRestore": "Put the original ENCRYPTION_KEY (or ENCRYPTION_KEYS entry) back into the server's settings.", + "fixDatabase": "Or point DATABASE_URL at the database that belongs to this key.", + "fixFresh": "Or start fresh with an empty database. The old data and its backups can only be read with the old key.", + "restartNote": "Restart HealthLog after the change.", + "codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}." } }, "achievements": { diff --git a/messages/es.json b/messages/es.json index 8917fa569..379dc08a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "Haz una copia de tu clave de cifrado", + "body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.", + "action": "Abrir el paso" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "Esta parte de la página no se pudo mostrar", "description": "El resto de la página sigue funcionando; inténtalo de nuevo o recarga si el fallo continúa." + }, + "keyBackup": { + "title": "Haz una copia de tu clave de cifrado", + "description": "Sin una copia de esta clave, tus datos cifrados y las copias de este servidor no se podrán volver a leer.", + "loading": "Cargando el estado de la copia de la clave…", + "loadError": "No se pudo cargar el estado de la copia de la clave.", + "badgeDue": "Pendiente", + "badgeDone": "Guardada", + "keyId": "ID de la clave activa", + "fingerprint": "Huella", + "whatIsLost": "Guarda una copia de la clave fuera de este servidor, por ejemplo en un gestor de contraseñas. Si el servidor se reinstala con otra clave, HealthLog no vuelve a funcionar hasta que la clave original esté de vuelta.", + "fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de la clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.", + "checkLabel": "Comprobar mi copia", + "checkButton": "Comprobar", + "checkNote": "La clave que pegues se compara con la clave activa y después se descarta. Nunca se guarda ni se registra.", + "checkInsecureNote": "Esta página no se sirve por https://, así que la clave que pegues viaja por la red sin cifrar, igual que tu contraseña. Se compara y se descarta, nunca se guarda ni se registra.", + "checkMatches": "Tu copia coincide con la clave activa.", + "checkNoMatch": "Tu copia no coincide con la clave activa. Comprueba que copiaste el valor completo.", + "checkFailed": "La comprobación ha fallado.", + "confirmButton": "He guardado esta clave", + "confirmed": "Copia de la clave confirmada.", + "confirmFailed": "No se pudo guardar la confirmación.", + "stale": "La clave del servidor cambió desde que se cargó esta página. Revisa el nuevo ID y la huella, y vuelve a confirmar.", + "confirmedAt": "Confirmada el {date}.", + "confirmedAtBy": "Confirmada el {date} por {who}.", + "dueAgain": "Se confirmó una copia de la clave {keyId}, pero el servidor usa ahora otra clave. Guarda una copia de la actual.", + "tileLabel": "Copia de la clave de cifrado", + "tileDue": "Sin confirmar", + "tileDone": "Confirmada", + "platform": { + "composeLabel": "Compose", + "composeWhere": "La clave es ENCRYPTION_KEY (o ENCRYPTION_KEYS) en el archivo .env junto a docker-compose.yml.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Abre Apps, selecciona HealthLog, elige Editar y copia ENCRYPTION_KEY de las variables de entorno.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY de sus Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY de la plantilla, o del .env de tu stack de Compose Manager.", + "portainerLabel": "Portainer", + "portainerWhere": "Abre el stack de HealthLog en Portainer y copia ENCRYPTION_KEY de sus variables de entorno." + } + }, + "keyMismatch": { + "title": "HealthLog no puede abrir sus datos", + "body": "La clave de cifrado con la que se inició este servidor no es la clave con la que se escribió su base de datos. Para proteger tus datos, HealthLog no sirve nada hasta que se corrija.", + "fixTitle": "Cómo corregirlo", + "fixRestore": "Vuelve a poner la ENCRYPTION_KEY original (o la entrada de ENCRYPTION_KEYS) en la configuración del servidor.", + "fixDatabase": "O haz que DATABASE_URL apunte a la base de datos que corresponde a esta clave.", + "fixFresh": "O empieza de cero con una base de datos vacía. Los datos antiguos y sus copias solo se pueden leer con la clave antigua.", + "restartNote": "Reinicia HealthLog después del cambio.", + "codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}." } }, "achievements": { diff --git a/messages/fr.json b/messages/fr.json index c7b83d06a..3f0d198b1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "Sauvegarde ta clé de chiffrement", + "body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.", + "action": "Ouvrir l'étape" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "Cette partie de la page n’a pas pu s’afficher", "description": "Le reste de la page fonctionne ; réessayez ou rechargez si le problème persiste." + }, + "keyBackup": { + "title": "Sauvegarde ta clé de chiffrement", + "description": "Sans copie de cette clé, tes données chiffrées et les sauvegardes de ce serveur ne pourront plus être lues.", + "loading": "Chargement de l'état de la sauvegarde de la clé…", + "loadError": "Impossible de charger l'état de la sauvegarde de la clé.", + "badgeDue": "À faire", + "badgeDone": "Sauvegardée", + "keyId": "ID de la clé active", + "fingerprint": "Empreinte", + "whatIsLost": "Garde une copie de la clé en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Si le serveur est réinstallé avec une autre clé, HealthLog ne sert plus rien tant que la clé d'origine n'est pas revenue.", + "fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 de la clé. Elle identifie la clé sans la révéler, pour que tu puisses la comparer à ta copie.", + "checkLabel": "Vérifier ma copie", + "checkButton": "Vérifier", + "checkNote": "La clé collée est comparée à la clé active puis oubliée. Elle n'est jamais stockée ni journalisée.", + "checkInsecureNote": "Cette page n'est pas servie en https://, donc la clé collée traverse le réseau en clair, comme ton mot de passe. Elle est comparée puis oubliée, jamais stockée ni journalisée.", + "checkMatches": "Ta copie correspond à la clé active.", + "checkNoMatch": "Ta copie ne correspond pas à la clé active. Vérifie que tu as copié toute la valeur.", + "checkFailed": "La vérification a échoué.", + "confirmButton": "J'ai sauvegardé cette clé", + "confirmed": "Sauvegarde de la clé confirmée.", + "confirmFailed": "Impossible d'enregistrer la confirmation.", + "stale": "La clé du serveur a changé depuis le chargement de cette page. Vérifie le nouvel ID et l'empreinte, puis confirme à nouveau.", + "confirmedAt": "Confirmée le {date}.", + "confirmedAtBy": "Confirmée le {date} par {who}.", + "dueAgain": "Une sauvegarde a été confirmée pour la clé {keyId}, mais le serveur utilise maintenant une autre clé. Sauvegarde la clé actuelle.", + "tileLabel": "Sauvegarde de la clé de chiffrement", + "tileDue": "Non confirmée", + "tileDone": "Confirmée", + "platform": { + "composeLabel": "Compose", + "composeWhere": "La clé est ENCRYPTION_KEY (ou ENCRYPTION_KEYS) dans le fichier .env à côté de docker-compose.yml.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Ouvre Apps, sélectionne HealthLog, choisis Modifier et copie ENCRYPTION_KEY dans les variables d'environnement.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY depuis ses Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Dans l'onglet Docker, modifie le conteneur HealthLog et copie ENCRYPTION_KEY depuis le modèle, ou depuis le .env de ta pile Compose Manager.", + "portainerLabel": "Portainer", + "portainerWhere": "Ouvre la pile HealthLog dans Portainer et copie ENCRYPTION_KEY depuis ses variables d'environnement." + } + }, + "keyMismatch": { + "title": "HealthLog ne peut pas ouvrir ses données", + "body": "La clé de chiffrement avec laquelle ce serveur a démarré n'est pas celle avec laquelle sa base de données a été écrite. Pour protéger tes données, HealthLog ne sert rien tant que ce n'est pas corrigé.", + "fixTitle": "Comment corriger", + "fixRestore": "Remets la ENCRYPTION_KEY d'origine (ou l'entrée de ENCRYPTION_KEYS) dans les paramètres du serveur.", + "fixDatabase": "Ou fais pointer DATABASE_URL vers la base de données qui correspond à cette clé.", + "fixFresh": "Ou repars de zéro avec une base vide. Les anciennes données et leurs sauvegardes ne se lisent qu'avec l'ancienne clé.", + "restartNote": "Redémarre HealthLog après la modification.", + "codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}." } }, "achievements": { diff --git a/messages/it.json b/messages/it.json index dc823ccee..401435bac 100644 --- a/messages/it.json +++ b/messages/it.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "Fai un backup della tua chiave di cifratura", + "body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.", + "action": "Apri il passaggio" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "Questa parte della pagina non è stata mostrata", "description": "Il resto della pagina funziona; riprova o ricarica se il problema continua." + }, + "keyBackup": { + "title": "Fai un backup della tua chiave di cifratura", + "description": "Senza una copia di questa chiave, i tuoi dati cifrati e i backup di questo server non saranno più leggibili.", + "loading": "Caricamento dello stato del backup della chiave…", + "loadError": "Impossibile caricare lo stato del backup della chiave.", + "badgeDue": "Da fare", + "badgeDone": "Salvata", + "keyId": "ID della chiave attiva", + "fingerprint": "Impronta", + "whatIsLost": "Conserva una copia della chiave fuori da questo server, per esempio in un gestore di password. Se il server viene reinstallato con un'altra chiave, HealthLog non torna in servizio finché la chiave originale non è di nuovo al suo posto.", + "fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 della chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.", + "checkLabel": "Verifica la mia copia", + "checkButton": "Verifica", + "checkNote": "La chiave incollata viene confrontata con quella attiva e poi scartata. Non viene mai salvata né registrata.", + "checkInsecureNote": "Questa pagina non è servita tramite https://, quindi la chiave incollata attraversa la rete in chiaro, come la tua password. Viene confrontata e scartata, mai salvata né registrata.", + "checkMatches": "La tua copia corrisponde alla chiave attiva.", + "checkNoMatch": "La tua copia non corrisponde alla chiave attiva. Controlla di aver copiato l'intero valore.", + "checkFailed": "La verifica non è riuscita.", + "confirmButton": "Ho salvato questa chiave", + "confirmed": "Backup della chiave confermato.", + "confirmFailed": "Impossibile salvare la conferma.", + "stale": "La chiave del server è cambiata da quando questa pagina è stata caricata. Controlla il nuovo ID e l'impronta, poi conferma di nuovo.", + "confirmedAt": "Confermato il {date}.", + "confirmedAtBy": "Confermato il {date} da {who}.", + "dueAgain": "È stato confermato un backup per la chiave {keyId}, ma ora il server usa un'altra chiave. Salva quella attuale.", + "tileLabel": "Backup della chiave di cifratura", + "tileDue": "Non confermato", + "tileDone": "Confermato", + "platform": { + "composeLabel": "Compose", + "composeWhere": "La chiave è ENCRYPTION_KEY (o ENCRYPTION_KEYS) nel file .env accanto a docker-compose.yml.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Apri Apps, seleziona HealthLog, scegli Modifica e copia ENCRYPTION_KEY dalle variabili d'ambiente.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY dalle sue Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY dal modello, oppure dal .env del tuo stack di Compose Manager.", + "portainerLabel": "Portainer", + "portainerWhere": "Apri lo stack di HealthLog in Portainer e copia ENCRYPTION_KEY dalle sue variabili d'ambiente." + } + }, + "keyMismatch": { + "title": "HealthLog non riesce ad aprire i suoi dati", + "body": "La chiave di cifratura con cui è stato avviato questo server non è quella con cui è stato scritto il suo database. Per proteggere i tuoi dati, HealthLog non serve nulla finché il problema non è risolto.", + "fixTitle": "Come risolvere", + "fixRestore": "Rimetti la ENCRYPTION_KEY originale (o la voce in ENCRYPTION_KEYS) nelle impostazioni del server.", + "fixDatabase": "Oppure fai puntare DATABASE_URL al database che appartiene a questa chiave.", + "fixFresh": "Oppure ricomincia con un database vuoto. I vecchi dati e i loro backup si leggono solo con la vecchia chiave.", + "restartNote": "Riavvia HealthLog dopo la modifica.", + "codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}." } }, "achievements": { diff --git a/messages/ko.json b/messages/ko.json index ad5942532..34cdf7001 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "암호화 키를 백업하세요", + "body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.", + "action": "단계 열기" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "페이지의 이 부분을 표시할 수 없습니다", "description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요." + }, + "keyBackup": { + "title": "암호화 키를 백업하세요", + "description": "이 키의 사본이 없으면 암호화된 데이터와 이 서버의 백업을 다시는 읽을 수 없습니다.", + "loading": "키 백업 상태를 불러오는 중…", + "loadError": "키 백업 상태를 불러오지 못했습니다.", + "badgeDue": "해야 함", + "badgeDone": "백업됨", + "keyId": "활성 키 ID", + "fingerprint": "지문", + "whatIsLost": "키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 서버가 다른 키로 다시 설치되면 HealthLog는 원래 키가 돌아올 때까지 서비스를 시작하지 않습니다.", + "fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.", + "checkLabel": "내 사본 확인", + "checkButton": "확인", + "checkNote": "붙여넣은 키는 활성 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.", + "checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.", + "checkMatches": "사본이 활성 키와 일치합니다.", + "checkNoMatch": "사본이 활성 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.", + "checkFailed": "확인하지 못했습니다.", + "confirmButton": "이 키를 백업했습니다", + "confirmed": "키 백업이 확인되었습니다.", + "confirmFailed": "확인을 저장하지 못했습니다.", + "stale": "이 페이지를 연 뒤 서버의 키가 바뀌었습니다. 새 키 ID와 지문을 확인한 뒤 다시 확인하세요.", + "confirmedAt": "{date}에 확인됨.", + "confirmedAtBy": "{date}에 {who}님이 확인함.", + "dueAgain": "키 {keyId}의 백업이 확인되었지만 서버는 이제 다른 키를 사용합니다. 현재 키를 백업하세요.", + "tileLabel": "암호화 키 백업", + "tileDue": "확인 안 됨", + "tileDone": "확인됨", + "platform": { + "composeLabel": "Compose", + "composeWhere": "키는 docker-compose.yml 옆 .env 파일의 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS)입니다.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누른 뒤 환경 설정에서 ENCRYPTION_KEY를 복사하세요.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY를 복사하세요.", + "unraidLabel": "Unraid", + "unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY를 복사하세요.", + "portainerLabel": "Portainer", + "portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY를 복사하세요." + } + }, + "keyMismatch": { + "title": "HealthLog가 데이터를 열 수 없습니다", + "body": "이 서버를 시작한 암호화 키가 데이터베이스를 기록할 때 쓴 키와 다릅니다. 데이터를 보호하기 위해 이 문제가 해결될 때까지 HealthLog는 아무것도 제공하지 않습니다.", + "fixTitle": "해결 방법", + "fixRestore": "원래 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS 항목)를 서버 설정에 다시 넣으세요.", + "fixDatabase": "또는 DATABASE_URL이 이 키에 맞는 데이터베이스를 가리키게 하세요.", + "fixFresh": "또는 빈 데이터베이스로 새로 시작하세요. 이전 데이터와 백업은 이전 키로만 읽을 수 있습니다.", + "restartNote": "변경 후 HealthLog를 다시 시작하세요.", + "codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다." } }, "achievements": { diff --git a/messages/pl.json b/messages/pl.json index 8fead53ee..04bdd266a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -632,6 +632,11 @@ "mood": "/5", "bmi": "kg/m²" } + }, + "keyBackupBanner": { + "title": "Zrób kopię klucza szyfrowania", + "body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.", + "action": "Otwórz krok" } }, "measurements": { @@ -7568,6 +7573,57 @@ "cardError": { "title": "Nie udało się wyświetlić tej części strony", "description": "Reszta strony działa; spróbuj ponownie lub odśwież, jeśli błąd się powtarza." + }, + "keyBackup": { + "title": "Zrób kopię klucza szyfrowania", + "description": "Bez kopii tego klucza Twoich zaszyfrowanych danych i kopii zapasowych z tego serwera nie da się już odczytać.", + "loading": "Wczytywanie stanu kopii klucza…", + "loadError": "Nie udało się wczytać stanu kopii klucza.", + "badgeDue": "Do zrobienia", + "badgeDone": "Zapisany", + "keyId": "ID aktywnego klucza", + "fingerprint": "Odcisk", + "whatIsLost": "Przechowuj kopię klucza poza tym serwerem, na przykład w menedżerze haseł. Jeśli serwer zostanie przeinstalowany z innym kluczem, HealthLog nie wznowi działania, dopóki oryginalny klucz nie wróci.", + "fingerprintHint": "Odcisk to pierwsze 12 znaków skrótu SHA-256 klucza. Rozpoznaje klucz, nie ujawniając go, więc możesz porównać go ze swoją kopią.", + "checkLabel": "Sprawdź moją kopię", + "checkButton": "Sprawdź", + "checkNote": "Wklejony klucz jest porównywany z aktywnym kluczem, a potem odrzucany. Nigdy nie jest zapisywany ani logowany.", + "checkInsecureNote": "Ta strona nie jest serwowana przez https://, więc wklejony klucz przechodzi przez sieć bez szyfrowania, tak jak Twoje hasło. Jest porównywany i odrzucany, nigdy nie jest zapisywany ani logowany.", + "checkMatches": "Twoja kopia pasuje do aktywnego klucza.", + "checkNoMatch": "Twoja kopia nie pasuje do aktywnego klucza. Sprawdź, czy skopiowano całą wartość.", + "checkFailed": "Sprawdzenie nie powiodło się.", + "confirmButton": "Mam kopię tego klucza", + "confirmed": "Kopia klucza potwierdzona.", + "confirmFailed": "Nie udało się zapisać potwierdzenia.", + "stale": "Klucz serwera zmienił się od wczytania tej strony. Sprawdź nowe ID i odcisk, a potem potwierdź ponownie.", + "confirmedAt": "Potwierdzono {date}.", + "confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.", + "dueAgain": "Potwierdzono kopię klucza {keyId}, ale serwer używa teraz innego klucza. Zrób kopię obecnego.", + "tileLabel": "Kopia klucza szyfrowania", + "tileDue": "Niepotwierdzona", + "tileDone": "Potwierdzona", + "platform": { + "composeLabel": "Compose", + "composeWhere": "Klucz to ENCRYPTION_KEY (lub ENCRYPTION_KEYS) w pliku .env obok docker-compose.yml.", + "truenasLabel": "TrueNAS", + "truenasWhere": "Otwórz Apps, wybierz HealthLog, kliknij Edytuj i skopiuj ENCRYPTION_KEY ze zmiennych środowiskowych.", + "coolifyLabel": "Coolify", + "coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY z jej Environment Variables.", + "unraidLabel": "Unraid", + "unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.", + "portainerLabel": "Portainer", + "portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY z jego zmiennych środowiskowych." + } + }, + "keyMismatch": { + "title": "HealthLog nie może otworzyć swoich danych", + "body": "Klucz szyfrowania, z którym uruchomiono ten serwer, nie jest kluczem, którym zapisano jego bazę danych. Aby chronić Twoje dane, HealthLog niczego nie udostępnia, dopóki to nie zostanie naprawione.", + "fixTitle": "Jak to naprawić", + "fixRestore": "Przywróć oryginalny ENCRYPTION_KEY (lub wpis w ENCRYPTION_KEYS) w ustawieniach serwera.", + "fixDatabase": "Albo ustaw DATABASE_URL na bazę danych, która należy do tego klucza.", + "fixFresh": "Albo zacznij od nowa z pustą bazą. Stare dane i ich kopie można odczytać tylko starym kluczem.", + "restartNote": "Po zmianie uruchom HealthLog ponownie.", + "codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}." } }, "achievements": { From b100c73a62e3077e5c12c71fec4cca6871d98c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 18:19:57 +0200 Subject: [PATCH 3/7] fix(auth): refuse a sign-in over plain http before it spends a factor (#1097) A server that issues Secure session cookies cannot sign anyone in on a plain http:// page: the browser drops the cookie. The login request was still verified, the MFA ticket minted and then claimed, and the next attempt answered "Invalid or expired challenge". The web client now sends an optional clientTransport on the browser sign-in requests (password login, passkey start, both MFA completions). When the server issues Secure cookies and the page is plain http on a non-loopback host, the request is refused with 409 auth.session.insecure_transport before the password is checked, a ticket is minted or claimed, a code is spent, or a passkey challenge is issued. A request without the field is never refused. The SSO start takes client_proto/client_host and sends such a page back to the login page instead of to the provider. GET /api/auth/registration-status publishes sessionCookieSecure, and the login page shows the reason with both fixes and disables the sign-in controls before anything is sent; on localhost it shows a note only. SESSION_COOKIE_SECURE keeps its meaning; the header heuristic log line stays. --- docs/api/openapi.yaml | 964 ++++++++++-------- docs/self-hosting/getting-started.md | 7 + .../auth/registration-status/route.test.ts | 27 + src/app/api/auth/login/route.ts | 8 + src/app/api/auth/mfa/verify/route.ts | 6 + src/app/api/auth/mfa/webauthn/verify/route.ts | 5 + src/app/api/auth/oidc/login/route.ts | 19 + .../api/auth/passkey/login-options/route.ts | 12 +- src/app/api/auth/registration-status/route.ts | 9 +- src/app/auth/login/page.tsx | 83 +- .../insecure-transport-banner.test.tsx | 46 + .../auth/insecure-transport-banner.tsx | 75 ++ src/components/auth/mfa-login-step.tsx | 15 +- .../auth/__tests__/transport-refusal.test.ts | 106 ++ src/lib/auth/client-transport.ts | 64 ++ src/lib/auth/transport-refusal.ts | 40 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/auth.ts | 43 +- src/lib/validations/auth.ts | 11 + src/lib/validations/client-transport.ts | 23 + src/lib/validations/mfa.ts | 3 + .../login-insecure-transport.test.ts | 221 ++++ 22 files changed, 1325 insertions(+), 463 deletions(-) create mode 100644 src/components/auth/__tests__/insecure-transport-banner.test.tsx create mode 100644 src/components/auth/insecure-transport-banner.tsx create mode 100644 src/lib/auth/__tests__/transport-refusal.test.ts create mode 100644 src/lib/auth/client-transport.ts create mode 100644 src/lib/auth/transport-refusal.ts create mode 100644 src/lib/validations/client-transport.ts create mode 100644 tests/integration/login-insecure-transport.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20ca5766a..bcd5cff68 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -391,7 +391,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a11 + "429": &a12 description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is @@ -701,7 +701,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a13 + "400": &a14 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -893,7 +893,7 @@ paths: type: string format: binary "401": *a1 - "403": &a10 + "403": &a11 description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the @@ -1900,6 +1900,16 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": &a9 + description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a + non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. + `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was + verified, no MFA ticket was minted or consumed, no code was spent. A request without `clientTransport` (the + iOS app, scripts) is never refused this way.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/mfa/verify: @@ -1925,6 +1935,7 @@ paths: schema: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 + "409": *a9 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2221,7 +2232,7 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a9 + "401": &a10 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: @@ -2369,7 +2380,7 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a9 + "401": *a10 "422": *a4 "429": *a5 /api/auth/me/mfa/webauthn/register/verify: @@ -2517,6 +2528,7 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 + "409": *a9 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -2832,10 +2844,17 @@ paths: - Auth summary: Begin a passkey sign-in (discoverable credentials) description: Returns SimpleWebAuthn assertion options plus a server-issued challenge id to present at POST - /api/auth/passkey/login-verify. Anonymous, takes no body, and scopes the assertion to no account — the options - carry no `allowCredentials`, so the authenticator picks the credential and the endpoint is not an - account-existence oracle. Rate-limited 10 / 15 min through the anonymous auth-surface bucket, which collapses - every caller into one bucket if the proxy trust chain is misconfigured rather than falling open. + /api/auth/passkey/login-verify. Anonymous, takes no body (the web client may send an optional + `clientTransport`), and scopes the assertion to no account — the options carry no `allowCredentials`, so the + authenticator picks the credential and the endpoint is not an account-existence oracle. Rate-limited 10 / 15 min + through the anonymous auth-surface bucket, which collapses every caller into one bucket if the proxy trust chain + is misconfigured rather than falling open. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/PasskeyLoginOptionsRequest" responses: "200": description: Assertion options issued. @@ -2850,6 +2869,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a9 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3460,7 +3480,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a15 + "429": &a16 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -3547,19 +3567,42 @@ paths: type: string description: "Browser leg only: where to land after login. Sanitised to an in-app path; ignored entirely on the native leg." + - in: query + name: client_proto + schema: + description: "Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback + `client_host` while the server issues `Secure` session cookies, the start redirects to + `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be + dropped on return." + type: string + enum: + - http + - https + description: "Browser leg only: the scheme of the page the SSO button was pressed on. With `http` on a non-loopback + `client_host` while the server issues `Secure` session cookies, the start redirects to + `/auth/login?error=insecure_transport` instead of the provider, because the session cookie would be dropped + on return." + - in: query + name: client_host + schema: + description: "Browser leg only: `location.host` of that page, read together with `client_proto`." + type: string + maxLength: 255 + description: "Browser leg only: `location.host` of that page, read together with `client_proto`." responses: "302": description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or `healthlog://oidc-callback?error=` (native), where reason is one of `oidc_disabled`, - `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not - listed here, because the identity provider drives it rather than a client), which ends the native leg at - `healthlog://oidc-callback` with `code=`, with `mfa_ticket=&methods=`, or with - `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, - `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`, - `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an - existing local account that is not linked yet; the owner links it by signing in once with that account's own - password or passkey in a browser. The reasons are this exact snake_case vocabulary on both legs; a client - maps a value it does not know to a generic sign-in failure. + `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`, and on the browser leg `insecure_transport` (see + `client_proto`). The provider then returns to the callback (not listed here, because the identity provider + drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with + `code=`, with `mfa_ticket=&methods=`, or with `error=`, where reason is one of + `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, + `oidc_identity_conflict`, `oidc_link_required`, `oidc_registration_disabled`, `oidc_failed`. + `oidc_link_required` means the provider's e-mail matches an existing local account that is not linked yet; + the owner links it by signing in once with that account's own password or passkey in a browser. The reasons + are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic + sign-in failure. /api/auth/passkey/register-options: post: tags: @@ -3873,7 +3916,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/measurements/series: @@ -3925,7 +3968,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/measurements: @@ -4123,7 +4166,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -4179,7 +4222,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4195,7 +4238,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a11 + "429": *a12 /api/measurements/{id}: get: tags: @@ -4216,7 +4259,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4264,7 +4307,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4300,7 +4343,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Measurement not found (or owned by another user). content: @@ -4406,7 +4449,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -4435,7 +4478,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -4466,7 +4509,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a12 + "403": &a13 description: >- Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a @@ -4507,7 +4550,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/personal-records: @@ -4554,7 +4597,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4609,7 +4652,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4645,7 +4688,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a18 + "403": &a19 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -4867,7 +4910,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -4992,7 +5035,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a11 + "429": *a12 /api/medications/{id}/glp1: get: tags: @@ -5017,7 +5060,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5101,7 +5144,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5236,7 +5279,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5352,7 +5395,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5391,7 +5434,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5436,7 +5479,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -5484,7 +5527,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5532,7 +5575,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No event with that id under this medication and record. content: @@ -5575,7 +5618,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5617,7 +5660,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5662,7 +5705,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another account). content: @@ -5742,7 +5785,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -5773,7 +5816,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -5793,7 +5836,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 put: @@ -5827,7 +5870,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a14 + "422": &a15 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -5872,7 +5915,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -5909,7 +5952,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -5982,7 +6025,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -5991,7 +6034,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 get: tags: - Medications @@ -6056,7 +6099,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6089,7 +6132,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6125,7 +6168,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6169,7 +6212,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -6204,7 +6247,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -6300,7 +6343,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6326,7 +6369,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -6507,7 +6550,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6545,7 +6588,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication not found (or owned by another user). content: @@ -6589,7 +6632,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication or revision not found (or owned by another user). content: @@ -6630,7 +6673,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Medication or revision not found (or owned by another user). content: @@ -6693,7 +6736,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": description: A dose-history import is already in progress. content: @@ -6755,7 +6798,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No such job for this medication (or owned by another user). content: @@ -6789,7 +6832,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No such job for this account (or owned by another user). content: @@ -6819,7 +6862,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/ai/test: @@ -7024,7 +7067,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a13 + "400": *a14 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -7034,7 +7077,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/auth/me/disable-coach: get: @@ -7072,7 +7115,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a13 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -7227,7 +7270,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a13 + "400": *a14 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7237,7 +7280,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/auth/me/source-priority: get: @@ -8106,7 +8149,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a13 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -8259,7 +8302,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a15 + "429": *a16 /api/auth/me/notification-prefs: get: tags: @@ -8300,7 +8343,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a13 + "400": *a14 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -8310,7 +8353,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/auth/me/report-selection: get: @@ -8352,7 +8395,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a13 + "400": *a14 "401": *a1 "422": *a4 "429": *a5 @@ -9073,7 +9116,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a17 + "503": &a18 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -9438,7 +9481,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 delete: tags: @@ -9529,7 +9572,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -9647,7 +9690,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/anamnesis/facts: get: @@ -9665,7 +9708,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -9775,7 +9818,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a16 + "404": &a17 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9811,7 +9854,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a16 + "404": *a17 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10171,7 +10214,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -10520,7 +10563,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -10542,7 +10585,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/cards: @@ -10563,7 +10606,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10698,7 +10741,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a17 + "503": *a18 post: tags: - Insights @@ -10900,7 +10943,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10973,7 +11016,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -11010,7 +11053,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -11046,7 +11089,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -11120,7 +11163,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/analytics/range: @@ -11193,7 +11236,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -11225,7 +11268,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -11257,7 +11300,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -11289,7 +11332,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -11321,7 +11364,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -11354,7 +11397,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -11449,7 +11492,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -11494,7 +11537,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/derived: @@ -11606,7 +11649,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -11646,7 +11689,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -11666,7 +11709,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/ecg: @@ -11688,7 +11731,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -11765,7 +11808,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11797,7 +11840,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11819,7 +11862,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11837,7 +11880,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11867,7 +11910,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: No current pattern with that id for this account. content: @@ -11892,7 +11935,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11911,7 +11954,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11930,7 +11973,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11953,7 +11996,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": *a4 "429": *a5 /api/mood/insights: @@ -11975,7 +12018,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11998,7 +12041,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -12108,7 +12151,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a19 + "404": &a20 description: Not found / not owned by the caller. content: application/json: @@ -12143,7 +12186,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -12179,7 +12222,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -12232,7 +12275,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 delete: @@ -12263,7 +12306,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a19 + "404": *a20 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -12311,7 +12354,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 /api/mood-entries: get: @@ -12394,7 +12437,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -12427,8 +12470,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a10 - "409": &a20 + "403": *a11 + "409": &a21 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -12437,7 +12480,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a11 + "429": *a12 /api/mood-entries/{id}: get: tags: @@ -12459,8 +12502,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a10 - "404": *a19 + "403": *a11 + "404": *a20 "422": *a4 "429": *a5 put: @@ -12494,9 +12537,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a10 - "404": *a19 - "409": *a20 + "403": *a11 + "404": *a20 + "409": *a21 "422": *a4 "429": *a5 delete: @@ -12521,8 +12564,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a10 - "404": *a19 + "403": *a11 + "404": *a20 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -12551,7 +12594,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -12580,7 +12623,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -12624,7 +12667,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -12672,7 +12715,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a21 + "400": &a22 description: Body is not parseable JSON. content: application/json: @@ -12685,13 +12728,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a22 + "413": &a23 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a23 + "415": &a24 description: Content-Type is not `application/json`. content: application/json: @@ -12751,7 +12794,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a21 + "400": *a22 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12766,8 +12809,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a22 - "415": *a23 + "413": *a23 + "415": *a24 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12821,10 +12864,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a21 + "400": *a22 "401": *a1 - "413": *a22 - "415": *a23 + "413": *a23 + "415": *a24 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -12851,7 +12894,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/settings/global-services: @@ -13381,7 +13424,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a24 + "400": &a25 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -13441,7 +13484,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a24 + "400": *a25 "401": *a1 "422": *a4 "429": *a5 @@ -13693,7 +13736,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -13718,7 +13761,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -13742,8 +13785,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a10 - "404": &a25 + "403": *a11 + "404": &a26 description: Measurement reminder not found / not owned. content: application/json: @@ -13780,8 +13823,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13803,8 +13846,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13828,8 +13871,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13856,8 +13899,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13886,8 +13929,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13920,8 +13963,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13961,8 +14004,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a10 - "404": *a25 + "403": *a11 + "404": *a26 "422": *a4 "429": *a5 /api/labs: @@ -14034,7 +14077,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14061,10 +14104,10 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/labs/{id}: get: tags: @@ -14085,8 +14128,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a10 - "404": &a26 + "403": *a11 + "404": &a27 description: Lab result not found (or owned by another user). content: application/json: @@ -14120,8 +14163,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a10 - "404": *a26 + "403": *a11 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -14143,8 +14186,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a10 - "404": *a26 + "403": *a11 + "404": *a27 "422": *a4 "429": *a5 /api/labs/restore: @@ -14170,7 +14213,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -14301,7 +14344,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14326,15 +14369,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a10 - "409": &a28 + "403": *a11 + "409": &a29 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a11 + "429": *a12 /api/biomarkers/{id}: get: tags: @@ -14355,8 +14398,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a10 - "404": &a27 + "403": *a11 + "404": &a28 description: Biomarker not found (or owned by another user). content: application/json: @@ -14390,9 +14433,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a10 - "404": *a27 - "409": *a28 + "403": *a11 + "404": *a28 + "409": *a29 "422": *a4 "429": *a5 delete: @@ -14415,7 +14458,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/encounters: @@ -14473,7 +14516,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14499,10 +14542,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/encounters/procedures: get: tags: @@ -14533,7 +14576,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/body-sites: @@ -14570,7 +14613,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -14600,7 +14643,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -14623,8 +14666,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a10 - "404": &a29 + "403": *a11 + "404": &a30 description: Visit not found (or owned by another user). content: application/json: @@ -14657,8 +14700,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a10 - "404": *a29 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14682,8 +14725,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a10 - "404": *a29 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -14708,8 +14751,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a10 - "404": *a29 + "403": *a11 + "404": *a30 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -14739,7 +14782,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14768,7 +14811,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/practitioners: @@ -14798,7 +14841,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -14822,7 +14865,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -14846,8 +14889,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a10 - "404": &a30 + "403": *a11 + "404": &a31 description: Practitioner not found (or owned by another user). content: application/json: @@ -14880,8 +14923,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a10 - "404": *a30 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14904,8 +14947,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a10 - "404": *a30 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14930,8 +14973,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a10 - "404": *a30 + "403": *a11 + "404": *a31 "422": *a4 "429": *a5 /api/vaccinations: @@ -14974,7 +15017,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15001,10 +15044,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/vaccinations/{id}: get: tags: @@ -15025,8 +15068,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": &a31 + "403": *a11 + "404": &a32 description: Vaccination not found (or owned by another user). content: application/json: @@ -15060,8 +15103,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": *a31 + "403": *a11 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15085,8 +15128,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": *a31 + "403": *a11 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -15110,8 +15153,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a10 - "404": *a31 + "403": *a11 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -15177,7 +15220,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -15205,7 +15248,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -15235,7 +15278,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15264,7 +15307,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/illness/episodes: @@ -15297,7 +15340,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15323,7 +15366,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15347,8 +15390,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": &a32 + "403": *a11 + "404": &a33 description: Illness episode not found (or owned by another user). content: application/json: @@ -15382,8 +15425,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 delete: @@ -15406,8 +15449,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -15434,8 +15477,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "409": *a7 "422": *a4 "429": *a5 @@ -15467,8 +15510,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -15518,8 +15561,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 post: @@ -15555,8 +15598,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a10 - "404": *a32 + "403": *a11 + "404": *a33 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -15583,7 +15626,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 /api/illness/insights: @@ -15656,7 +15699,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15689,7 +15732,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15716,7 +15759,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/allergies: @@ -15749,7 +15792,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15774,10 +15817,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/allergies/{id}: get: tags: @@ -15798,8 +15841,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a10 - "404": &a33 + "403": *a11 + "404": &a34 description: Allergy not found (or owned by another user). content: application/json: @@ -15832,8 +15875,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a10 - "404": *a33 + "403": *a11 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -15856,8 +15899,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a10 - "404": *a33 + "403": *a11 + "404": *a34 "422": *a4 "429": *a5 /api/family-history: @@ -15881,7 +15924,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -15906,7 +15949,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "409": *a7 "422": *a4 "429": *a5 @@ -15930,8 +15973,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a10 - "404": &a34 + "403": *a11 + "404": &a35 description: Family history entry not found (or owned by another user). content: application/json: @@ -15964,8 +16007,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a10 - "404": *a34 + "403": *a11 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -15988,8 +16031,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a10 - "404": *a34 + "403": *a11 + "404": *a35 "422": *a4 "429": *a5 /api/documents/inbound: @@ -16102,7 +16145,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -16381,7 +16424,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -16428,7 +16471,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 patch: @@ -16547,7 +16590,7 @@ paths: type: string format: binary "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -16576,7 +16619,7 @@ paths: type: string format: binary "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -16615,7 +16658,7 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 - "403": &a35 + "403": &a36 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -16639,7 +16682,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a36 + "503": &a37 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -16701,10 +16744,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/summary: post: tags: @@ -16783,10 +16826,10 @@ paths: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/index: post: tags: @@ -16843,10 +16886,10 @@ paths: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/chat: post: tags: @@ -16886,10 +16929,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 get: tags: - Documents @@ -16952,10 +16995,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 - "503": *a36 + "503": *a37 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -17037,7 +17080,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 post: @@ -17062,7 +17105,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a38 + "409": &a39 description: A custom metric with this name already exists for the caller. content: application/json: @@ -17090,8 +17133,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a10 - "404": &a37 + "403": *a11 + "404": &a38 description: Custom metric not found (or owned by another user). content: application/json: @@ -17125,8 +17168,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a37 - "409": *a38 + "404": *a38 + "409": *a39 "422": *a4 "429": *a5 delete: @@ -17149,7 +17192,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a37 + "404": *a38 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -17192,8 +17235,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a10 - "404": *a37 + "403": *a11 + "404": *a38 "422": *a4 "429": *a5 post: @@ -17222,10 +17265,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a37 + "404": *a38 "409": *a7 "422": *a4 - "429": *a11 + "429": *a12 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -17258,7 +17301,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a39 + "404": &a40 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -17291,7 +17334,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a39 + "404": *a40 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -17703,7 +17746,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a40 + "403": &a41 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -17758,7 +17801,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "422": *a4 "429": *a5 /api/nutrients/water: @@ -17787,7 +17830,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a40 + "403": *a41 "409": *a7 "422": *a4 "429": *a5 @@ -18289,26 +18332,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a41 + "400": &a42 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a42 + "413": &a43 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a43 + "415": &a44 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a44 + "422": &a45 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -18494,11 +18537,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -18641,11 +18684,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -18798,11 +18841,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -18878,21 +18921,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a45 + "422": &a46 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a46 + "429": &a47 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a47 + "502": &a48 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -18937,11 +18980,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -19017,9 +19060,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/strava/credentials: get: tags: @@ -19055,11 +19098,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a41 + "400": *a42 "401": *a1 - "413": *a42 - "415": *a43 - "422": *a44 + "413": *a43 + "415": *a44 + "422": *a45 "429": *a5 delete: tags: @@ -19137,9 +19180,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/status: get: tags: @@ -19175,7 +19218,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/fitbit/test: post: tags: @@ -19193,9 +19236,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/google-health/resume: post: tags: @@ -19212,7 +19255,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/google-health/test: post: tags: @@ -19245,9 +19288,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/whoop/resume: post: tags: @@ -19264,7 +19307,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/whoop/test: post: tags: @@ -19281,9 +19324,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 - "502": *a47 + "422": *a46 + "429": *a47 + "502": *a48 /api/integrations/withings/resume: post: tags: @@ -19301,7 +19344,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a46 + "429": *a47 /api/integrations/withings/test: post: tags: @@ -19319,8 +19362,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a45 - "429": *a46 + "422": *a46 + "429": *a47 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -19353,7 +19396,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -19444,7 +19487,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -19463,7 +19506,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 put: @@ -19497,7 +19540,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a14 + "422": *a15 "429": *a5 delete: tags: @@ -19560,7 +19603,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a48 + "403": &a49 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -19585,7 +19628,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "422": *a4 "429": *a5 post: @@ -19687,7 +19730,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "404": description: No such invitation for this account. content: @@ -19731,7 +19774,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "404": description: No such grant on this account's record. content: @@ -19772,7 +19815,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "404": description: No such grant held by this account. content: @@ -20154,7 +20197,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a48 + "403": *a49 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -20484,7 +20527,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a49 + "403": &a50 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -20517,7 +20560,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -20544,7 +20587,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "422": *a4 "429": *a5 post: @@ -20577,7 +20620,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "413": description: Body exceeds 16 KiB. content: @@ -20620,7 +20663,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a49 + "403": *a50 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -20647,7 +20690,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -20667,7 +20710,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -20678,7 +20721,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a50 + - &a51 name: family in: path required: true @@ -20702,7 +20745,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -20722,7 +20765,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a50 + - *a51 requestBody: required: true content: @@ -20743,7 +20786,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a10 + "403": *a11 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -20856,7 +20899,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a12 + "403": *a13 "422": *a4 "429": *a5 /api/environment: @@ -20874,7 +20917,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": *a4 "429": *a5 /api/environment/home: @@ -20911,7 +20954,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "413": description: Body exceeds 4 KiB. content: @@ -20965,7 +21008,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -21024,7 +21067,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -21063,7 +21106,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "413": description: Body exceeds 4 KiB. content: @@ -21116,7 +21159,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a18 + "403": *a19 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -21259,13 +21302,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a51 + "403": &a52 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a52 + "404": &a53 description: No stored copy with this id. content: application/json: @@ -21339,8 +21382,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a51 - "404": *a52 + "403": *a52 + "404": *a53 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -21384,7 +21427,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a51 + "403": *a52 /api/admin/backups/restores/{jobId}: get: tags: @@ -21407,7 +21450,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a51 + "403": *a52 "404": description: No restore job with this id. content: @@ -21426,7 +21469,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a53 + security: &a54 - cookieAuth: [] responses: "200": @@ -21436,7 +21479,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a54 + "403": &a55 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -21459,9 +21502,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a53 + security: *a54 parameters: - - &a55 + - &a56 name: system in: path required: true @@ -21500,8 +21543,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a54 - "404": &a56 + "403": *a55 + "404": &a57 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -21520,7 +21563,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a57 + "502": &a58 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -21539,9 +21582,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a53 + security: *a54 parameters: - - *a55 + - *a56 responses: "200": description: Removed, or there was nothing to remove. @@ -21550,7 +21593,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -21561,9 +21604,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a53 + security: *a54 parameters: - - *a55 + - *a56 responses: "200": description: The source answered. @@ -21572,9 +21615,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a54 - "404": *a56 - "422": &a58 + "403": *a55 + "404": *a57 + "422": &a59 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -21583,7 +21626,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a57 + "502": *a58 /api/documents/sources/{system}/tags: get: tags: @@ -21591,9 +21634,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a53 + security: *a54 parameters: - - *a55 + - *a56 responses: "200": description: The tags. @@ -21602,11 +21645,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a54 - "404": *a56 - "422": *a58 + "403": *a55 + "404": *a57 + "422": *a59 "429": *a5 - "502": *a57 + "502": *a58 /api/documents/sources/{system}/search: get: tags: @@ -21620,9 +21663,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a53 + security: *a54 parameters: - - *a55 + - *a56 - name: q in: query required: false @@ -21663,11 +21706,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a54 - "404": *a56 - "422": *a58 + "403": *a55 + "404": *a57 + "422": *a59 "429": *a5 - "502": *a57 + "502": *a58 /api/documents/sources/{system}/import: post: tags: @@ -21686,9 +21729,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a53 + security: *a54 parameters: - - *a55 + - *a56 requestBody: required: true content: @@ -21745,7 +21788,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a54 + "403": *a55 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -21776,7 +21819,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a58 + "422": *a59 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -21784,7 +21827,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a57 + "502": *a58 /api/bugreport: post: security: [] @@ -22670,11 +22713,32 @@ components: password: type: string minLength: 1 + clientTransport: + $ref: "#/components/schemas/ClientTransport" required: - email - password description: "Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present." + ClientTransport: + type: object + properties: + protocol: + type: string + enum: + - http + - https + description: The scheme of the page the request comes from. + host: + type: string + maxLength: 255 + description: "`location.host` of that page: hostname and optional port." + required: + - protocol + - host + description: Sent by the web client only. When the server issues `Secure` session cookies and this says the page is on + plain `http://` (and not a loopback host), the sign-in is refused with 409 `auth.session.insecure_transport` + before any credential, ticket or code is checked or used, because the browser would drop the session cookie. MfaVerifyRequest: type: object properties: @@ -22696,6 +22760,8 @@ components: description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required). type: boolean + clientTransport: + $ref: "#/components/schemas/ClientTransport" required: - mfaTicket - code @@ -22981,6 +23047,8 @@ components: description: Opt in to trusting this device for 30 days — subsequent logins skip the second factor (the password is still required). type: boolean + clientTransport: + $ref: "#/components/schemas/ClientTransport" required: - mfaTicket - challengeId @@ -23042,6 +23110,13 @@ components: required: - refreshToken description: Exchange a one-time-use refresh token for a fresh access + refresh pair. + PasskeyLoginOptionsRequest: + type: object + properties: + clientTransport: + $ref: "#/components/schemas/ClientTransport" + description: Optional. The web client sends its page transport so a sign-in that could not keep its cookie is refused + before a challenge is issued. Any other caller may send no body at all. CreateMeasurementTokenRequest: type: object properties: @@ -31716,31 +31791,32 @@ components: anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, - 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; 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.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; 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, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + auth.session.insecure_transport, 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; 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.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; 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, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -39853,8 +39929,14 @@ components: type: boolean description: Whether open self-registration is on. An operator who turned it off can still admit a signup through an invite token. + sessionCookieSecure: + type: boolean + description: Whether this server issues its session cookies with the `Secure` flag (`SESSION_COOKIE_SECURE`, else on in + production). A browser on a plain `http://` page cannot keep such a cookie; the web login page compares this + with its own scheme before any credential is sent. required: - registrationEnabled + - sessionCookieSecure additionalProperties: false PasskeyLoginOptionsEnvelope: type: object diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index fe1c87d1b..f67feb66f 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -207,6 +207,13 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds the container unhealthy if Postgres is still starting. Tail `docker compose logs db` to confirm Postgres came up cleanly. +- **The login page says "Signing in cannot complete on this page".** The + server sets its session cookie with the `Secure` flag (the production + default) and the page was opened over plain `http://`, where the browser + would drop it. Set `SESSION_COOKIE_SECURE=false` for a server reached only + over HTTP on a private network, or open the `https://` address (behind a TLS + proxy, make sure it forwards `X-Forwarded-Proto: https`). The sign-in is + refused before any password, code or ticket is used. - **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and `NEXT_PUBLIC_APP_URL` both point at the public hostname, not at `localhost`, and restart `app` after the change. diff --git a/src/__tests__/api/auth/registration-status/route.test.ts b/src/__tests__/api/auth/registration-status/route.test.ts index ee2943bf9..72a12d416 100644 --- a/src/__tests__/api/auth/registration-status/route.test.ts +++ b/src/__tests__/api/auth/registration-status/route.test.ts @@ -114,3 +114,30 @@ describe("GET /api/auth/registration-status", () => { expect(body.data.registrationEnabled).toBe(false); }); }); + +describe("GET /api/auth/registration-status — sessionCookieSecure (#1097)", () => { + it("publishes whether the server issues Secure session cookies, on every branch", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + } as never); + vi.stubEnv("SESSION_COOKIE_SECURE", "true"); + let body = await (await callGet()).json(); + expect(body.data.sessionCookieSecure).toBe(true); + + vi.stubEnv("SESSION_COOKIE_SECURE", "false"); + body = await (await callGet()).json(); + expect(body.data.sessionCookieSecure).toBe(false); + + vi.mocked(prisma.appSettings.findUnique).mockRejectedValue( + new Error("db down"), + ); + vi.stubEnv("SESSION_COOKIE_SECURE", "true"); + body = await (await callGet()).json(); + expect(body.data).toEqual({ + registrationEnabled: false, + sessionCookieSecure: true, + }); + vi.unstubAllEnvs(); + }); +}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 75dc974d6..e34470049 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { isOidcOnly } from "@/lib/auth/oidc"; import { readPendingLink } from "@/lib/auth/oidc-pending-link"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const POST = apiHandler(async (request: NextRequest) => { // OIDC_ONLY means password login must be a dead end, not just a hidden @@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; + + // A browser page on plain http:// with Secure-only cookies cannot keep the + // session. Refuse before the password is verified (the account throttle + // never counts it) and before an MFA ticket is minted. + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + const parsed = loginPasswordSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 94bd30393..6bec4ebf3 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa"; import { mintTrustedDevice } from "@/lib/auth/trusted-device"; import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const dynamic = "force-dynamic"; @@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; + // Before the ticket is loaded, an attempt reserved, or a one-time code + // spent: a sign-in whose cookie cannot stick must not consume either. + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + const parsed = mfaVerifySchema.safeParse(body); if (!parsed.success) { return apiValidationError( diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 9df1e93d7..6c46bf27b 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa"; import { mintTrustedDevice } from "@/lib/auth/trusted-device"; import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint"; import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const dynamic = "force-dynamic"; @@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; + // Before the ticket is loaded or an attempt reserved (see mfa/verify). + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + const parsed = mfaWebauthnLoginVerifySchema.safeParse(body); if (!parsed.success) { return apiValidationError( diff --git a/src/app/api/auth/oidc/login/route.ts b/src/app/api/auth/oidc/login/route.ts index 3c5c0ff36..9e55d9c0a 100644 --- a/src/app/api/auth/oidc/login/route.ts +++ b/src/app/api/auth/oidc/login/route.ts @@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { encrypt } from "@/lib/crypto"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { transportVerdict } from "@/lib/auth/client-transport"; import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce"; import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff"; import { @@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => { return loginError("oidc_disabled"); } + // The SSO button states the page's transport. A browser on plain http:// + // with Secure-only cookies would come back from the provider and drop the + // session cookie, so it is stopped here instead of after the round trip. + if (!isNative) { + const clientProto = req.nextUrl.searchParams.get("client_proto"); + const clientHost = req.nextUrl.searchParams.get("client_host") ?? ""; + if ( + (clientProto === "http" || clientProto === "https") && + transportVerdict(shouldEmitSecureCookie(), { + protocol: clientProto, + host: clientHost, + }) === "blocked" + ) { + annotate({ meta: { reason: "insecure_transport" } }); + return loginError("insecure_transport"); + } + } + const rl = await checkAuthSurfaceRateLimit( req, "auth:oidc:login", diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts index e9ea2e6dc..cf1969cef 100644 --- a/src/app/api/auth/passkey/login-options/route.ts +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -1,10 +1,11 @@ import { createAuthenticationOptions } from "@/lib/auth/passkey"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { isOidcOnly } from "@/lib/auth/oidc"; +import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal"; export const POST = apiHandler(async (request: Request) => { // OIDC_ONLY must block passkey login too, not just password login. @@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => { ); } + // The body is optional (the web client sends its page transport; nothing + // else needs to send anything). A missing or unreadable body is no body, as + // before; only a stated plain-http page refuses, before a challenge exists. + if (request.headers.get("content-type")?.includes("application/json")) { + const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 }); + const transportRefusal = refuseWhenCookieCannotStick(body); + if (transportRefusal) return transportRefusal; + } + const { options, challengeId } = await createAuthenticationOptions(); annotate({ action: { name: "auth.passkey.login-options" } }); diff --git a/src/app/api/auth/registration-status/route.ts b/src/app/api/auth/registration-status/route.ts index 41f5dd26b..3a8961aeb 100644 --- a/src/app/api/auth/registration-status/route.ts +++ b/src/app/api/auth/registration-status/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; export const dynamic = "force-dynamic"; @@ -15,10 +16,16 @@ export const GET = apiHandler(async () => { return apiSuccess({ registrationEnabled: settings?.registrationEnabled ?? true, + // Whether this server issues `Secure` session cookies. The login page + // compares it with its own scheme before any credential is sent. + sessionCookieSecure: shouldEmitSecureCookie(), }); } catch { // Fail closed on backend errors. annotate({ action: { name: "auth.registration-status" } }); - return apiSuccess({ registrationEnabled: false }); + return apiSuccess({ + registrationEnabled: false, + sessionCookieSecure: shouldEmitSecureCookie(), + }); } }); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 571c9aeb7..08915392c 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; +import { + InsecureTransportBanner, + useClientTransport, +} from "@/components/auth/insecure-transport-banner"; +import { transportVerdict } from "@/lib/auth/client-transport"; + +/** The server's refusal for a page whose sign-in cookie cannot stick. */ +const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport"; import { loadDocument } from "@/lib/navigation/load-document"; export default function LoginPage() { @@ -44,7 +52,8 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(() => { const oidcError = searchParams.get("error"); - if (!oidcError) return null; + // The SSO start refused a page on plain http://; the banner says why. + if (!oidcError || oidcError === "insecure_transport") return null; const key: Record = { oidc_denied: "auth.oidc.errorDenied", oidc_no_email: "auth.oidc.errorNoEmail", @@ -71,22 +80,55 @@ export default function LoginPage() { // standalone alert detached from the form. const errorId = useId(); const errorDescriptor = error ? errorId : undefined; - const { data: registrationEnabled } = useQuery({ + const { data: registrationStatus } = useQuery({ queryKey: queryKeys.authRegistrationStatus(), queryFn: async () => { try { - const data = await apiGet<{ registrationEnabled?: boolean }>( - "/api/auth/registration-status", - { cache: "no-store" }, - ); - return Boolean(data?.registrationEnabled ?? true); + const data = await apiGet<{ + registrationEnabled?: boolean; + sessionCookieSecure?: boolean; + }>("/api/auth/registration-status", { cache: "no-store" }); + return { + registrationEnabled: Boolean(data?.registrationEnabled ?? true), + sessionCookieSecure: data?.sessionCookieSecure === true, + }; } catch { - // Fail open, exactly as the raw `if (!res.ok) return true` did. - return true; + // Fail open, exactly as the raw `if (!res.ok) return true` did; and + // no transport gate without the server's word on its cookies. + return { registrationEnabled: true, sessionCookieSecure: false }; } }, staleTime: 60 * 1000, }); + const registrationEnabled = registrationStatus?.registrationEnabled; + + // #1097 — a server that sets its sign-in cookie only over https:// cannot + // sign anyone in on a plain http:// page: the browser drops the cookie. Say + // so before a credential leaves the browser, and stop the sign-in controls. + // The server refuses the same requests (409) if they are sent anyway, and + // the SSO start sends the browser back here with `insecure_transport`. + const clientTransport = useClientTransport(); + const [transportRefused, setTransportRefused] = useState( + () => searchParams.get("error") === "insecure_transport", + ); + const verdict = transportRefused + ? "blocked" + : transportVerdict( + registrationStatus?.sessionCookieSecure === true, + clientTransport, + ); + const signInBlocked = verdict === "blocked"; + + function noteRefusal(err: unknown): boolean { + if ( + err instanceof ApiError && + err.meta?.errorCode === INSECURE_TRANSPORT_CODE + ) { + setTransportRefused(true); + return true; + } + return false; + } const { data: oidcStatus } = useQuery({ queryKey: queryKeys.authOidcStatus(), @@ -106,6 +148,10 @@ export default function LoginPage() { function handleOidcLogin() { const params = new URLSearchParams({ next: getRedirectTarget() }); + if (clientTransport) { + params.set("client_proto", clientTransport.protocol); + params.set("client_host", clientTransport.host); + } loadDocument(`/api/auth/oidc/login?${params}`); } @@ -176,7 +222,7 @@ export default function LoginPage() { const { options, challengeId } = await apiPost<{ options: Parameters[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/passkey/login-options"); + }>("/api/auth/passkey/login-options", { clientTransport }); const credential = await startAuthentication({ optionsJSON: options }); @@ -189,6 +235,7 @@ export default function LoginPage() { // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { + if (noteRefusal(err)) return; // Route rejections carry the envelope error verbatim; everything // else (WebAuthn ceremony failures) keeps the descriptive mapping. if (err instanceof ApiError) { @@ -220,7 +267,7 @@ export default function LoginPage() { >("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, clientTransport }), }); if (meta?.mfaRequired && meta.mfaTicket) { @@ -235,6 +282,7 @@ export default function LoginPage() { // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { + if (noteRefusal(err)) return; setError( err instanceof ApiError && err.message ? err.message @@ -289,6 +337,9 @@ export default function LoginPage() { useEffect(() => { if (oidcStatus === undefined) return; if (oidcStatus.only === true) return; + // Wait for the server's word on its cookies, and never arm the browser's + // credential prompt for a sign-in that cannot keep its cookie. + if (registrationStatus === undefined || signInBlocked) return; if (autofillStarted.current) return; autofillStarted.current = true; let cancelled = false; @@ -302,7 +353,7 @@ export default function LoginPage() { const { options, challengeId } = await apiPost<{ options: Parameters[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/passkey/login-options"); + }>("/api/auth/passkey/login-options", { clientTransport }); const credential = await startAuthentication({ optionsJSON: options, @@ -327,7 +378,7 @@ export default function LoginPage() { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [oidcStatus]); + }, [oidcStatus, registrationStatus, signInBlocked]); return (
@@ -380,12 +431,14 @@ export default function LoginPage() { data-slot="login-actions" className="mt-8 space-y-4" > + {oidcStatus?.enabled && (
{t("dashboard.keyBackupBanner.action")} From e132110fcd880eac2935c9c0cc95ad8bc8713481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 19:43:39 +0200 Subject: [PATCH 7/7] chore(bundle): re-base the client budget for v1.40 --- bundle-budget.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle-budget.json b/bundle-budget.json index 43ee910d7..e45168d89 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,5 +1,5 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it.", "routeBaselineKbGz": { "/page": 447, "/insights/page": 434, @@ -7,6 +7,6 @@ "/insights/mood/page": 447 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3749, + "totalClientKbGz": 3790, "maxRechartsChunks": 1 }