diff --git a/Dockerfile b/Dockerfile index f9e8bc5a5..6b871db84 100644 --- a/Dockerfile +++ b/Dockerfile @@ -170,6 +170,24 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/src/generated ./src/generated +# Operator maintenance scripts that a runbook tells the operator to run inside +# this container (`docs/ops/password-reset.md`, `docs/ops/intake-repair.md`). +# The standalone output carries none of `scripts/`, so without these lines the +# documented `docker compose exec app node scripts/reset-password.mjs` failed +# with "Cannot find module". Only self-contained scripts belong here: plain +# `node` or the `healthlog-tsx` launcher below, resolving `pg` and +# `@node-rs/argon2` from the traced runtime tree. Scripts that import the +# application graph (`@/…`, the generated Prisma client) run from a source +# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds +# the runbooks and this list in step. Root-owned on purpose: the app user can +# run them but not rewrite them. +COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs +COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs +COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts +# Prove the reset CLI's imports resolve in this tree: with every import found +# it stops at its own usage line; a missing module throws before reaching it. +RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:" + # Install the migration CLI, its config dependency, and a pinned launcher for # the maintenance scripts already shipped in the standalone tree. The Prisma # config loads dotenv from /app, so expose the isolated copy there before @@ -213,6 +231,10 @@ RUN mkdir -p /opt/prisma-cli && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \ rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx +# Same proof for the intake-repair script under the launcher it is documented +# with: its `pg` import resolves, so it reaches its own DATABASE_URL check. +RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | grep -q "DATABASE_URL must be set" + # v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN # lookups. The MMDB files live in `/opt/geolite2/` and are read by # `src/lib/geo.ts` via `mmdb-lib`. They are downloaded outside the diff --git a/docs/migration/v1.3-to-v1.4.md b/docs/migration/v1.3-to-v1.4.md index 5b29c4d24..ff89c40d1 100644 --- a/docs/migration/v1.3-to-v1.4.md +++ b/docs/migration/v1.3-to-v1.4.md @@ -70,10 +70,11 @@ worker`. Default `all` keeps the single-container behaviour. - **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback when `ENCRYPTION_KEYS` is empty. No action needed for existing installs. -- Rotation: `pnpm dlx tsx scripts/rotate-encryption-key.ts ` - re-encrypts every ciphertext under the new key without downtime. +- Rotation: **Admin → Encryption → Rotate now** re-encrypts every + ciphertext under the new key without downtime; the script + `scripts/rotate-encryption-key.ts` does the same from a source checkout. Walk-through in `docs/ops/encryption-key-rotation.md` (the current - playbook; the production image strips `tsx`, hence `pnpm dlx`). + playbook). ### Off-host backup target _(optional)_ diff --git a/docs/ops/encryption-key-rotation.md b/docs/ops/encryption-key-rotation.md index 5a62c55e0..078fff9a9 100644 --- a/docs/ops/encryption-key-rotation.md +++ b/docs/ops/encryption-key-rotation.md @@ -41,15 +41,33 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). Restart the app. New writes are now encrypted under `v2`; existing `v1`-keyed and legacy bare rows still decrypt because the `v1` entry is retained. -3. **Run the rotation script** to re-encrypt every registered encrypted column - under the new active key: +3. **Re-encrypt the stored data under the new key.** Open **Admin → + Encryption** and press **Rotate now**. The button needs an admin account + with a second factor, confirmed recently; it queues one background run + (`POST /api/admin/encryption/rotate`) on the app's own worker, so it + works on the published image with nothing else installed. The same page + shows the per-column counts and the result of the last run. + + Without a second factor on any admin account, run the rotation script + instead. It imports the application code and the generated database + client, so it runs from a source checkout of the same release, not + inside the production container (the image has no package manager and + does not contain `scripts/rotate-encryption-key.ts`). It needs the same + `ENCRYPTION_KEYS` and `ENCRYPTION_ACTIVE_KEY_ID` as the app and a + `DATABASE_URL` that reaches the database: ``` + git checkout vX.Y.Z # the release the instance runs + pnpm install && pnpm db:generate pnpm dlx tsx scripts/rotate-encryption-key.ts v2 ``` - The script is idempotent — running it again is a no-op for rows already - prefixed with `v2.`. It rotates every column in the canonical registry + The bundled `db` service publishes no port. On a stock compose stack, + either publish `5432` for the duration, or run the checkout in a + throwaway `node:22` container attached to the compose network. + + Both paths are idempotent: running again is a no-op for rows already + prefixed with `v2.`. Both rotate every column in the canonical registry (`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted` columns plus the ones whose names say nothing about their contents: `IntegrationStatus.lastError`, `CoachMessage.encryptedContent`, @@ -58,7 +76,8 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). `IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account backup blob. - Read three things off the output before going further: + When you used the script, read three things off its output before going + further: - the per-column line, `scanned` / `rotated` / `errors` / `dropped`. `scanned` counts the rows that hold ciphertext in that column, not every @@ -78,10 +97,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). 4. **Confirm nothing is left on the old key, THEN drop it.** This is the step that destroys data if it is taken on a bad signal, so check the corpus - rather than the absence of complaints. Either re-run the script — a clean - second pass reports `rotated=0`, `errors=0` and a full `Columns walked` - line — or open **Admin → Encryption** and confirm the status view reports - zero rows outside the active key. Both read the same registry the rotation + rather than the absence of complaints. Open **Admin → Encryption** and + confirm the status view reports zero rows outside the active key, or + re-run the script: a clean second pass reports `rotated=0`, `errors=0` and + a full `Columns walked` line. Both read the same registry the rotation walks. A zero only means "nothing left" when the run also says it walked every @@ -117,13 +136,14 @@ remaining. ``` ENCRYPTION_KEYS='{"v2":"","v3":""}' ENCRYPTION_ACTIVE_KEY_ID="v3" -pnpm dlx tsx scripts/rotate-encryption-key.ts v3 +# restart, then Admin → Encryption → Rotate now +# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout) ENCRYPTION_KEYS='{"v3":""}' ``` ## Rollback -> **Important.** Once the rotation script has run, ciphertexts in the +> **Important.** Once a rotation has run, ciphertexts in the > database start with `v2.` (or whatever the active id is). The pre-PR > v1.3.x image cannot read that prefix — it expects bare base64 — and > calling `decrypt()` on those rows will throw. @@ -133,7 +153,7 @@ If you need to revert to the pre-rotation image: - Either keep the new image. The new code reads both formats, so most rollback scenarios don't need to undo rotation. - Or, if you must run the old code, restore a database backup taken - _before_ the rotation script ran. There is no script to convert + _before_ the rotation ran. There is no script to convert `v2.`-prefixed rows back to legacy format — by design, rotation is a forward-only operation. @@ -145,10 +165,10 @@ at least 24 hours. - `Encryption key id 'v1' is not configured` — the database still contains `v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add - the key, run the rotation script, then remove again. + the key, run the rotation again, then remove it. - `Found a legacy-format ciphertext but no v1 key is configured` — same cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a - `v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before + `v1` entry to `ENCRYPTION_KEYS`) and run the rotation again before removing it. - `Refusing to rotate: argv key id ... does not match the currently active id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The diff --git a/docs/ops/password-reset.md b/docs/ops/password-reset.md index 4ad1eace2..64ca06846 100644 --- a/docs/ops/password-reset.md +++ b/docs/ops/password-reset.md @@ -18,10 +18,12 @@ instance). ## Run it -The reset CLI ships in the image and runs under plain `node` against the built -runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the -in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id -parameters): +The reset CLI ships in the image from v1.39.3 on. It runs under plain `node` +against the built runtime: a `.mjs` file that uses the in-image `pg` and +`@node-rs/argon2` directly and reuses the app's exact Argon2id parameters. +Images up to and including v1.39.2 did not contain the script, so on those +the command below fails with "Cannot find module"; use the source-checkout +form at the end of this page instead. ```sh docker compose exec app node scripts/reset-password.mjs @@ -32,7 +34,7 @@ confirmation that names only the user — never the password: ``` New password (input hidden): -reset-password: password updated for "alice" +reset-password: password updated for "alice"; revoked 2 sessions, 0 API tokens, 1 refresh tokens, 0 trusted devices, and 0 step-up elevations ``` You can also pass the password as a second argument for non-interactive use, @@ -57,8 +59,10 @@ docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphra ## Source checkout -From a source checkout (not the production image) the same script runs under -Node directly: +From a source checkout of the same release (after `pnpm install`) the same +script runs under Node directly. It needs a `DATABASE_URL` that reaches the +database; the bundled `db` service publishes no port, so on a stock compose +stack run it inside the compose network or publish the port temporarily: ```sh DATABASE_URL='postgres://…' node scripts/reset-password.mjs diff --git a/messages/de.json b/messages/de.json index 06801e37e..cc74dd185 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7348,7 +7348,7 @@ "rotateEnqueued": "Rotation gestartet.", "rotateFailed": "Rotation konnte nicht gestartet werden.", "stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.", - "cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.", + "cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Ohne ihn führen Betreiber scripts/rotate-encryption-key.ts aus einem Quellcode-Checkout dieser Version aus; die Schritte stehen in docs/ops/encryption-key-rotation.md.", "columnsTitle": "Abdeckung pro Spalte", "columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.", "colColumn": "Spalte", diff --git a/messages/en.json b/messages/en.json index fa1fb5458..dec23c781 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7348,7 +7348,7 @@ "rotateEnqueued": "Rotation started.", "rotateFailed": "Could not start the rotation.", "stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.", - "cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.", + "cliNote": "A second factor is required to use this button. Without one, run scripts/rotate-encryption-key.ts from a source checkout of this release; the steps are in docs/ops/encryption-key-rotation.md.", "columnsTitle": "Per-column coverage", "columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.", "colColumn": "Column", diff --git a/messages/es.json b/messages/es.json index 46f903102..946637bbe 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7348,7 +7348,7 @@ "rotateEnqueued": "Rotación iniciada.", "rotateFailed": "No se pudo iniciar la rotación.", "stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.", - "cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.", + "cliNote": "Se necesita un segundo factor para usar este botón. Sin él, ejecuta scripts/rotate-encryption-key.ts desde una copia del código fuente de esta versión; los pasos están en docs/ops/encryption-key-rotation.md.", "columnsTitle": "Cobertura por columna", "columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.", "colColumn": "Columna", diff --git a/messages/fr.json b/messages/fr.json index 722b77a24..2aab4b132 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7348,7 +7348,7 @@ "rotateEnqueued": "Rotation démarrée.", "rotateFailed": "Impossible de démarrer la rotation.", "stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.", - "cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.", + "cliNote": "Un second facteur est requis pour ce bouton. Sans lui, lancez scripts/rotate-encryption-key.ts depuis une copie des sources de cette version ; les étapes sont dans docs/ops/encryption-key-rotation.md.", "columnsTitle": "Couverture par colonne", "columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.", "colColumn": "Colonne", diff --git a/messages/it.json b/messages/it.json index ae8310f2d..7bc694845 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7348,7 +7348,7 @@ "rotateEnqueued": "Rotazione avviata.", "rotateFailed": "Impossibile avviare la rotazione.", "stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.", - "cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.", + "cliNote": "Per questo pulsante serve un secondo fattore. Senza, esegui scripts/rotate-encryption-key.ts da una copia dei sorgenti di questa versione; i passaggi sono in docs/ops/encryption-key-rotation.md.", "columnsTitle": "Copertura per colonna", "columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.", "colColumn": "Colonna", diff --git a/messages/ko.json b/messages/ko.json index 674b6c9a3..f0ad6c0d7 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7327,7 +7327,7 @@ "rotateEnqueued": "교체를 시작했어요.", "rotateFailed": "교체를 시작하지 못했어요.", "stepUpNeeded": "2단계 인증을 다시 확인해야 해요. 2단계 인증을 확인하거나, 등록한 수단이 없으면 명령줄 도구를 써 주세요.", - "cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않은 운영자는 scripts/rotate-encryption-key.ts로 교체해 주세요.", + "cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않았다면 이 버전의 소스 코드 체크아웃에서 scripts/rotate-encryption-key.ts를 실행해 주세요. 절차는 docs/ops/encryption-key-rotation.md에 있어요.", "columnsTitle": "열별 적용 범위", "columnsDescription": "암호화된 열마다 활성 키와 이전 키의 행 수를 보여줘요.", "colColumn": "열", diff --git a/messages/pl.json b/messages/pl.json index a2c23d8b3..73dc235bd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7348,7 +7348,7 @@ "rotateEnqueued": "Rotacja rozpoczęta.", "rotateFailed": "Nie udało się rozpocząć rotacji.", "stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.", - "cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.", + "cliNote": "Do tego przycisku wymagany jest drugi składnik. Bez niego uruchom scripts/rotate-encryption-key.ts z kopii kodu źródłowego tej wersji; kroki opisuje docs/ops/encryption-key-rotation.md.", "columnsTitle": "Pokrycie wg kolumn", "columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.", "colColumn": "Kolumna", diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts new file mode 100644 index 000000000..83e2e928f --- /dev/null +++ b/src/__tests__/container-scripts-ship-guard.test.ts @@ -0,0 +1,111 @@ +/** + * Structural guard: a script a runbook tells the operator to run inside the + * container is actually in the image, together with every local file it + * imports. + * + * ## The failure this freezes + * + * `docs/ops/password-reset.md` told operators to run + * `docker compose exec app node scripts/reset-password.mjs`, and the runner + * stage of the Dockerfile copied nothing from `scripts/`: the standalone + * output does not carry it. The recovery path for a locked-out admin failed + * with "Cannot find module" at the moment it was needed. The intake-repair + * runbook had the same gap under the `healthlog-tsx` launcher. + * + * ## What it proves and what it does not + * + * Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…` + * command in `docs/` names + * a file the runner stage copies, and every relative import of that file is + * copied too. Whether the package imports resolve is proved at image build by + * the `RUN` checks next to the copies; this file cannot see the traced tree. + * A command written in some other form (a different launcher) is outside the + * matcher. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; + +const ROOT = process.cwd(); +const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8"); + +/** + * In-container command forms: `docker compose exec node scripts/…` + * or the image's own `healthlog-tsx scripts/…` launcher. A bare + * `node scripts/…` is a workstation or source-checkout command and is out of + * scope. + */ +const CONTAINER_COMMAND = + /(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g; + +function markdownFiles(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf8" }) + .filter((rel) => rel.endsWith(".md")) + .map((rel) => join(dir, rel)); +} + +function runnerStage(): string { + const start = DOCKERFILE.search(/^FROM .* AS runner$/m); + expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0); + return DOCKERFILE.slice(start); +} + +function copiedIntoRunner(path: string): boolean { + const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp( + `^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`, + "m", + ).test(runnerStage()); +} + +/** Scripts named by a container command anywhere under docs/. */ +function documentedContainerScripts(): string[] { + const found = new Set(); + for (const file of markdownFiles(join(ROOT, "docs"))) { + for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) { + found.add(m[1]); + } + } + return [...found].sort(); +} + +/** Relative import specifiers of a script, resolved against its location. */ +function localImports(script: string): string[] { + const src = readFileSync(join(ROOT, script), "utf8"); + const specs = [ + ...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm), + ].map((m) => m[1]); + return specs.map((spec) => normalize(join(dirname(script), spec))); +} + +describe("scripts the runbooks run inside the container ship in the image", () => { + it("finds the commands it is meant to check", () => { + const scripts = documentedContainerScripts(); + // A matcher that found nothing would agree with any Dockerfile. + expect(scripts).toContain("scripts/reset-password.mjs"); + expect(scripts).toContain("scripts/repair-intake-anomalies.ts"); + expect(localImports("scripts/reset-password.mjs")).toContain( + "src/lib/auth/argon2-params.mjs", + ); + expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false); + }); + + it("copies every documented script into the runner stage", () => { + const missing = documentedContainerScripts().filter( + (script) => !copiedIntoRunner(script), + ); + expect( + missing, + "A runbook runs these inside the container, but the Dockerfile runner " + + "stage does not COPY them. Add the COPY (and its local imports), or " + + "change the runbook to a source-checkout command.", + ).toEqual([]); + }); + + it("copies every local file those scripts import", () => { + const missing = documentedContainerScripts() + .flatMap((script) => localImports(script)) + .filter((path) => !copiedIntoRunner(path)); + expect(missing).toEqual([]); + }); +}); diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts index 0b6c23bbc..0beacfe7f 100644 --- a/src/app/api/admin/encryption/rotate/route.ts +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -3,9 +3,10 @@ * * v1.23 — admin-triggered encryption-key rotation. Enqueues the * `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted- - * column corpus to the configured ACTIVE key id. The CLI - * (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a - * convenience that reuses the same column registry and is safe by construction: + * column corpus to the configured ACTIVE key id. This is the primary path on + * the published image, which contains neither a package manager nor + * `scripts/rotate-encryption-key.ts`; the CLI runs from a source checkout and + * reuses the same column registry. Safe by construction: * * - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active * key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env + @@ -16,8 +17,8 @@ * Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS * `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a * high-blast-radius action. An admin without a second factor cannot use the - * button and falls back to the CLI (this is intentional: step-up cannot be - * softened, and the CLI is the documented canonical path). + * button and falls back to the CLI from a source checkout (intentional: + * step-up cannot be softened). */ import { apiHandler, diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index fdbff5e84..39a6583d7 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -255,7 +255,7 @@ export function decrypt(encoded: string): string { throw new Error( "Found a legacy-format ciphertext but no v1 key is configured. " + "Restore the original ENCRYPTION_KEY (or add a 'v1' entry to " + - "ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before " + + "ENCRYPTION_KEYS) and run the key rotation (Admin > Encryption) before " + "removing it.", ); } diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index 78bfe11a6..d470a163b 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -9,10 +9,10 @@ * `rotateCorpus()` to re-encrypt every row that is not already on the * active key. * - * The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical - * rotation path and stays independent (its own Prisma client). This module is - * the in-app convenience that reuses the SAME registry, so the guard test keeps - * both in lock-step. + * The admin-triggered job is the primary rotation path on the published image. + * The standalone CLI (`scripts/rotate-encryption-key.ts`) runs from a source + * checkout with its own Prisma client and reuses the SAME registry, so the + * guard test keeps both in lock-step. * * GUARANTEES (the security review must confirm these on the rotation path): * - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()` diff --git a/src/lib/jobs/encryption-key-rotate.ts b/src/lib/jobs/encryption-key-rotate.ts index 725659f55..86eb671d9 100644 --- a/src/lib/jobs/encryption-key-rotate.ts +++ b/src/lib/jobs/encryption-key-rotate.ts @@ -2,9 +2,9 @@ * v1.23 — admin-triggered encryption-key rotation, on pg-boss. * * Re-encrypts every registered encrypted column to the configured ACTIVE key - * id, reusing the canonical column registry via `rotateCorpus()`. This is the - * in-app convenience the admin panel triggers; the CLI - * (`scripts/rotate-encryption-key.ts`) remains the documented canonical path. + * id, reusing the canonical column registry via `rotateCorpus()`. The admin + * panel triggers it, and it is the documented primary path; the CLI + * (`scripts/rotate-encryption-key.ts`) does the same from a source checkout. * pg-boss is the right home because the standalone production image strips * `tsx`, so a button that ran the CLI inside the container would fail. *