From d7778c1085fa9ece36d392490b3862c0fdcb619a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 14:33:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(v1.4):=20multi-tenant=20prep=20=E2=80=94?= =?UTF-8?q?=20off-host=20backup,=20encryption-key=20versioning,=20worker/w?= =?UTF-8?q?eb=20split,=20native-Bearer=20UA=20gating=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(crypto): G2 — encryption-key versioning + rotation CLI Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of { "id": "" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now "."; legacy bare-base64 ciphertext still decrypts under the synthetic "v1" id derived from the existing ENCRYPTION_KEY. scripts/rotate-encryption-key.ts re-encrypts every encrypted column on User / WithingsConnection / AppSettings with the active key. Idempotent — rows already on the active id are skipped. Why: enables zero-downtime rotation when a key is suspected leaked, without forcing every encrypted row through a single big-bang rewrite. Co-Authored-By: Marc-André Bombeck * feat(auth): G4 — native-Bearer UA gating + rotating refresh tokens Web callers (UA contains 'Mozilla/') keep the legacy 90d Bearer when they explicitly opt in via X-Client-Type: native. Native callers (HealthLog-iOS, HealthLog-iPad, n8n, Health-Connect, unrecognised UAs) now receive a 24h access token plus a 60d rotating refresh token in the login response. New /api/auth/refresh endpoint atomically validates + rotates the refresh token (one-time-use), revokes the previously paired access token, and audit-logs every refresh + revoke. Reuse of an already-consumed refresh token revokes the entire family for the user/device — defence against a stolen refresh token. Migration 0025_refresh_tokens adds the table; the only DDL is CREATE TABLE so it is forward-compatible. Down migration documented at docs/ops/migrations/0025-down.sql. Why: the V3 audit (still-V2-A) flagged that 90d tokens are too long for native callers when refresh-token rotation is now table-stakes for mobile apps. Web flows are unchanged so existing browser sessions keep working. Co-Authored-By: Marc-André Bombeck * feat(infra): G3 — web/worker container split via HEALTHLOG_PROCESS_TYPE Adds a tiny startup gate (src/lib/process-type.ts) so the same Docker image can run as web-only, worker-only, or 'all' (default — single-container, backwards compatible). assertSubsystemEnabled() refuses to boot the reminder worker when HEALTHLOG_PROCESS_TYPE=web, preventing accidental double-cron when an operator splits the deployment. docker-compose.yml ships the optional app-worker service block commented out so 'docker compose up' keeps working out of the box; uncommenting it + flipping app to HEALTHLOG_PROCESS_TYPE=web is the only change to scale HTTP horizontally. Healthchecks are isolated per service so neither startup deadlocks the other; both wait on db: service_healthy. docs/self-hosting/scaling.md walks through when + how to split. Co-Authored-By: Marc-André Bombeck * feat(jobs): G1 — daily off-host encrypted backup to S3-compatible storage New pg-boss queue 'data-backup-offhost' runs at 02:30 Europe/Berlin, dumps each user's measurements + medications + intake events + mood entries, encrypts the JSON under a SEPARATE key (BACKUP_ENCRYPTION_KEY, isolated from the application ENCRYPTION_KEY), and uploads to any S3-compatible target via @aws-sdk/client-s3. Wire format documented in docs/ops/backup-restore.md: magic "HLBK" || version 0x01 || iv(12) || authTag(16) || ciphertext The worker prunes objects older than BACKUP_RETENTION_DAYS (default 30); operators are encouraged to also configure a bucket-level lifecycle rule. Admin endpoint POST /api/admin/backup/test issues a 1-byte PUT+GET roundtrip and returns endpoint + bucket + per-call latency. Credentials are never returned. scripts/restore-backup.ts downloads + decrypts a single object so operators can verify the chain end-to-end without standing up a separate restore harness. Why: GDPR Art. 32 + V3 audit "data durability" — without an off-host copy the only line of defence against a Postgres volume loss is the in-DB DataBackup model on the same host. Separate-key isolation means a leak of the application encryption key does not also expose every user's archive. Co-Authored-By: Marc-André Bombeck * fix(multi-tenant): address review findings — rotation coverage, hard decrypt errors, web/worker gates Multi-agent review on PR #135 caught several CRITICAL + HIGH issues that would have shipped silent data corruption or weakened the threat model: Encryption + rotation - scripts/rotate-encryption-key.ts: stop selecting `bugReportTokenEncrypted` (column doesn't exist in the schema; the script crashed on the AppSettings step). Add the three encrypted columns it was missing — NotificationChannel.config, PushSubscription.p256dh, PushSubscription.auth — so rotating to a new key actually re-encrypts every encrypted row. Without this, dropping the old key id from ENCRYPTION_KEYS would leave notification channels and web-push subscriptions permanently undecryptable. - src/lib/crypto.ts: refuse to silently fall back to the active key when a legacy bare-base64 row is decrypted but no v1 entry is configured. Throws a clear "Restore ENCRYPTION_KEY (or add a v1 entry to ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before removing it" so an operator who pulled the v1 too early notices immediately instead of hitting opaque GCM tag errors. - docs/ops/encryption-key-rotation.md: full step-by-step + rollback note + troubleshooting section. Spells out that rotation is a forward-only operation and what to do if the rollback path is needed. Web / worker split (G3) - src/proxy.ts: refuse HTTP traffic with a 503 + X-HealthLog-Process-Type: worker header when HEALTHLOG_PROCESS_TYPE=worker. The worker container (which still runs `node server.js` for now) no longer accepts requests, so a misrouted client gets a clear hint instead of a partially-served response. - src/instrumentation.ts: short-circuit before importing the reminder worker when HEALTHLOG_PROCESS_TYPE=web — avoids the noisy "[instrumentation] CRITICAL" stderr line we emitted on every web boot. - /api/version added to PUBLIC_PATHS so the compose healthcheck doesn't silently 302-redirect through the auth gate (the prior wget treated the login page as a 200 success and would never notice an unhealthy backend). Refresh-token rotation (G4) - src/lib/auth/refresh-token.ts: reuse-detection now revokes ALL active refresh tokens for the user, not only those scoped to the same deviceId. An attacker who rotated without an X-Device-Id header would otherwise keep their family alive on the no-device branch. Defence-in-depth. - revokeRefreshToken() also revokes the paired access token so a leaked access token cannot outlive the refresh-token sibling that the caller just killed (logout-on-device). Off-host backup (G1) - src/lib/jobs/offhost-backup.ts: drop the worker-side DeleteObject path. Retention is the bucket's lifecycle-policy job; the worker keeps a PutObject + GetObject grant only. A compromised worker can no longer wipe the backup history. Per-user errors are now surfaced as Wide Event warnings + a structured failures[] array on the report so an operator can tell which user failed and why. - scripts/restore-backup.ts: schema-validate the decrypted payload before writing to disk, plus an optional --user-id= flag that refuses to proceed unless the dump's userId matches. Prevents silent cross-tenant restores when an operator points the script at the wrong S3 key. Idempotency - src/lib/idempotency.ts: the leak-guard already blocked `hlk_` (access-token prefix); also block `hlr_` so a future caller wrapping the new /api/auth/refresh endpoint with withIdempotency() can't accidentally cache a freshly-issued refresh token. Migration / schema - prisma/migrations/0025_refresh_tokens/migration.sql: wrap in a single transaction; switch to CREATE { TABLE | INDEX } IF NOT EXISTS so a partial-deploy retry is a no-op instead of "relation already exists"; guard the FK ADD with information_schema lookup for the same reason. - Schema replaces the single-column `@@index([deviceId])` with a composite `@@index([userId, deviceId])` (every lookup also filters on userId; the old index would never have been used). Comment on accessTokenHash corrected to HMAC-SHA-256. Container / ops - Dockerfile: install tzdata + ENV TZ=Europe/Berlin so the daily pg-boss schedule (`30 2 * * *` Europe/Berlin) actually fires at 02:30 Berlin instead of silently falling back to UTC on the alpine base. - docker-compose.yml: tighten the healthcheck to `wget --spider` (matches the Dockerfile HEALTHCHECK style, doesn't pipe the body), pass through BACKUP_RETENTION_DAYS so a custom retention env reaches the container. - .env.example: document every new env var added in this PR (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, ENCRYPTION_ACTIVE_KEY_ID, BACKUP_S3_*, BACKUP_ENCRYPTION_KEY, BACKUP_RETENTION_DAYS) with comments and the all-or-none semantics for the backup block. 491 tests still green, typecheck clean. * chore: retrigger CI --------- Co-authored-by: Marc-André Bombeck --- .env.example | 38 + Dockerfile | 7 +- docker-compose.yml | 68 +- docs/ops/backup-restore.md | 87 ++ docs/ops/encryption-key-rotation.md | 107 ++ docs/ops/migrations/0025-down.sql | 6 + docs/self-hosting/scaling.md | 53 + package.json | 1 + pnpm-lock.yaml | 1193 +++++++++++++++++ .../0025_refresh_tokens/migration.sql | 47 + prisma/schema.prisma | 42 + scripts/restore-backup.ts | 128 ++ scripts/rotate-encryption-key.ts | 246 ++++ src/app/api/admin/backup/test/route.ts | 36 + .../auth/login/__tests__/native-token.test.ts | 4 + src/app/api/auth/login/route.ts | 70 +- .../__tests__/native-token.test.ts | 4 + .../api/auth/passkey/login-verify/route.ts | 72 +- src/app/api/auth/refresh/route.ts | 101 ++ src/instrumentation.ts | 4 + src/lib/__tests__/crypto.test.ts | 135 +- src/lib/__tests__/process-type.test.ts | 53 + src/lib/auth/__tests__/native-client.test.ts | 85 ++ src/lib/auth/__tests__/refresh-token.test.ts | 269 ++++ src/lib/auth/native-client.ts | 91 ++ src/lib/auth/refresh-token.ts | 224 ++++ src/lib/crypto.ts | 265 +++- src/lib/idempotency.ts | 11 +- src/lib/jobs/__tests__/offhost-backup.test.ts | 192 +++ src/lib/jobs/offhost-backup.ts | 319 +++++ src/lib/jobs/reminder-worker.ts | 49 + src/lib/process-type.ts | 61 + src/proxy.ts | 44 +- 33 files changed, 3983 insertions(+), 129 deletions(-) create mode 100644 docs/ops/backup-restore.md create mode 100644 docs/ops/encryption-key-rotation.md create mode 100644 docs/ops/migrations/0025-down.sql create mode 100644 docs/self-hosting/scaling.md create mode 100644 prisma/migrations/0025_refresh_tokens/migration.sql create mode 100644 scripts/restore-backup.ts create mode 100644 scripts/rotate-encryption-key.ts create mode 100644 src/app/api/admin/backup/test/route.ts create mode 100644 src/app/api/auth/refresh/route.ts create mode 100644 src/lib/__tests__/process-type.test.ts create mode 100644 src/lib/auth/__tests__/native-client.test.ts create mode 100644 src/lib/auth/__tests__/refresh-token.test.ts create mode 100644 src/lib/auth/native-client.ts create mode 100644 src/lib/auth/refresh-token.ts create mode 100644 src/lib/jobs/__tests__/offhost-backup.test.ts create mode 100644 src/lib/jobs/offhost-backup.ts create mode 100644 src/lib/process-type.ts diff --git a/.env.example b/.env.example index 30b6af11a..9dfbea36f 100644 --- a/.env.example +++ b/.env.example @@ -104,3 +104,41 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # LOKI_ENDPOINT="http://loki:3100" # LOKI_USERNAME="" # LOKI_PASSWORD="" + + +# ----------------------------------------------------------------------------- +# Process split (optional, single-container deploys ignore this) +# ----------------------------------------------------------------------------- +# `all` — single container runs HTTP + reminder worker (default, legacy). +# `web` — HTTP only; pair with a separate `app-worker` service. +# `worker`— pg-boss only; rejects HTTP traffic with a 503. +# See docs/self-hosting/scaling.md for the split-stack guide. +# HEALTHLOG_PROCESS_TYPE="all" + + +# ----------------------------------------------------------------------------- +# Encryption-key versioning (optional — for at-rest key rotation) +# ----------------------------------------------------------------------------- +# JSON map of key id → 64-hex-char or base64 32-byte key. When set, it +# supersedes ENCRYPTION_KEY for new writes; old rows continue to decrypt under +# their original id. ENCRYPTION_ACTIVE_KEY_ID picks which entry encrypt() uses. +# Run `pnpm tsx scripts/rotate-encryption-key.ts ` to re-encrypt every +# existing row under the new key. See docs/ops/encryption-key-rotation.md. +# ENCRYPTION_KEYS='{"v1":"","v2":""}' +# ENCRYPTION_ACTIVE_KEY_ID="v2" + + +# ----------------------------------------------------------------------------- +# Off-host backup (optional — daily encrypted dumps to an S3-compatible bucket) +# ----------------------------------------------------------------------------- +# Required ALL-OR-NONE: setting any of these without setting the rest disables +# the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron. +# Restore with `pnpm tsx scripts/restore-backup.ts `. +# See docs/ops/backup-restore.md for the format spec + step-by-step. +# BACKUP_S3_ENDPOINT="https://.r2.cloudflarestorage.com" +# BACKUP_S3_BUCKET="healthlog-backups" +# BACKUP_S3_ACCESS_KEY="" +# BACKUP_S3_SECRET_KEY="" +# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3 +# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule) +# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY diff --git a/Dockerfile b/Dockerfile index f356f9bff..937fa8430 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,12 +23,17 @@ RUN pnpm build # ── Stage 3: Production runner ───────────────────────────── FROM node:22-alpine AS runner -RUN corepack enable && corepack prepare pnpm@latest --activate +# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware +# timestamp formatting) resolve to the actual offset instead of silently +# falling back to UTC on Alpine images that ship without it. +RUN apk add --no-cache tzdata && \ + corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_PATH="/opt/pg-boss/node_modules" +ENV TZ=Europe/Berlin RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs diff --git a/docker-compose.yml b/docker-compose.yml index 243e95ccf..e6047c42e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,10 @@ services: + # Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing + # self-host deployments stay working with `docker compose up`. + # For horizontal scaling switch to the web/worker split below + # (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker). app: - # Pre-built multi-arch images are published to GHCR by - # .github/workflows/docker-publish.yml on every push to main and on - # tagged releases. Pin to a specific version in production: - # e.g. ghcr.io/mbombeck/healthlog:1.2.0 image: ghcr.io/mbombeck/healthlog:latest - # Fallback build context: if the image is not yet in the registry, - # or you want to test local changes, run `docker compose up --build`. build: context: . dockerfile: Dockerfile @@ -14,22 +12,70 @@ services: ports: - "3000:3000" environment: - # Plain ${VAR} interpolation only — no ${VAR:?error} validation - # syntax. Some hosting platforms (e.g. Coolify) parse compose files - # eagerly and store the *fallback error string* as the literal value - # when the env var is unset, which then breaks the deployment. - # Validation is enforced by docker-entrypoint.sh before the app starts. DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public" NODE_ENV: production SESSION_SECRET: "${SESSION_SECRET}" ENCRYPTION_KEY: "${ENCRYPTION_KEY}" + ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" + ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}" API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" APP_URL: "${APP_URL:-http://localhost:3000}" + # 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}" + BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" + BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" + BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}" + BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}" + BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" + BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" + BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" depends_on: db: condition: service_healthy restart: unless-stopped + healthcheck: + test: + [ + "CMD-SHELL", + "wget --no-verbose --tries=1 --spider http://localhost:3000/api/version || exit 1", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + # Optional dedicated worker container — uncomment + set + # HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment. + # See docs/self-hosting/scaling.md for the full guide. + # + # app-worker: + # image: ghcr.io/mbombeck/healthlog:latest + # build: + # context: . + # dockerfile: Dockerfile + # container_name: healthlog-worker + # environment: + # DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public" + # NODE_ENV: production + # SESSION_SECRET: "${SESSION_SECRET}" + # ENCRYPTION_KEY: "${ENCRYPTION_KEY}" + # ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" + # ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}" + # API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}" + # APP_URL: "${APP_URL:-http://localhost:3000}" + # HEALTHLOG_PROCESS_TYPE: "worker" + # BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" + # BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" + # BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}" + # BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}" + # BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" + # BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" + # depends_on: + # db: + # condition: service_healthy + # restart: unless-stopped db: image: postgres:16-alpine diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md new file mode 100644 index 000000000..e1747deff --- /dev/null +++ b/docs/ops/backup-restore.md @@ -0,0 +1,87 @@ +# Off-host backup & restore (v1.4 G1) + +HealthLog ships with an optional daily off-host backup that ships every +user's JSON dump, encrypted with AES-256-GCM under a SEPARATE key +(`BACKUP_ENCRYPTION_KEY`), to any S3-compatible bucket — Cloudflare R2, +AWS S3, Backblaze B2, MinIO, etc. + +The backup runs at **02:30 Europe/Berlin** every day from the worker +container (queue `data-backup-offhost`). Object key layout: + +``` +/YYYY-MM-DD/user-.json.enc +``` + +## Wire format (binary) + +``` +magic = "HLBK" (4 bytes, ASCII) +version = 0x01 (1 byte) +iv = 12 random bytes (AES-GCM nonce) +authTag = 16 bytes (AES-GCM tag) +ciphertext = N bytes (AES-256-GCM, key = BACKUP_ENCRYPTION_KEY) +plaintext = JSON dump (UTF-8) +``` + +## Required env vars + +| Var | Required | Notes | +| ----------------------- | -------- | -------------------------------------------------------------------- | +| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** | +| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://.r2.cloudflarestorage.com` | +| `BACKUP_S3_BUCKET` | yes | | +| `BACKUP_S3_ACCESS_KEY` | yes | | +| `BACKUP_S3_SECRET_KEY` | yes | | +| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) | +| `BACKUP_RETENTION_DAYS` | no | defaults to `30` | + +## Bucket lifecycle (recommended) + +The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the +storage provider's lifecycle rule is the canonical safety net: + +``` +Filter: "" (all objects) +Action: Expire after 30 days +``` + +For Cloudflare R2 add this from the bucket's **Settings → Lifecycle**. + +## Smoke test + +After deploying, hit `POST /api/admin/backup/test` (admin-only). It +performs a 1-byte PUT + GET round-trip and returns: + +```json +{ + "endpoint": "https://...r2.cloudflarestorage.com", + "bucket": "healthlog-backups", + "region": "auto", + "putLatencyMs": 142, + "getLatencyMs": 38, + "ok": true +} +``` + +The credentials are never returned. + +## Restore + +Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket +and run: + +```bash +BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \ +BACKUP_S3_BUCKET=healthlog-backups \ +BACKUP_S3_ACCESS_KEY=... \ +BACKUP_S3_SECRET_KEY=... \ +BACKUP_S3_REGION=auto \ +BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \ +pnpm tsx scripts/restore-backup.ts \ + 2026-05-08/user-clx123.json.enc \ + /tmp/restored.json +``` + +The script downloads the object, decrypts it, and writes the JSON dump +to disk. Importing the JSON back into a HealthLog instance is left to +the operator (use `prisma db seed` or a custom script). diff --git a/docs/ops/encryption-key-rotation.md b/docs/ops/encryption-key-rotation.md new file mode 100644 index 000000000..9c3f7d4e0 --- /dev/null +++ b/docs/ops/encryption-key-rotation.md @@ -0,0 +1,107 @@ +# Encryption-key rotation + +HealthLog encrypts sensitive at-rest data (Withings tokens, AI provider keys, +notification channel configs, web-push subscription secrets, VAPID private +keys, etc.) with AES-256-GCM under a per-deployment key. v1.4 introduces a +versioned key format so the key can be rotated without downtime and without +re-encrypting every row by hand. + +## Format + +| Layout | Marker | Where | +| --------------- | ----------------------------------------------- | --------------------------- | +| Versioned (new) | `.` | All new writes | +| Legacy (v1.3.x) | `` | Existing rows until rotated | + +`` matches `[A-Za-z0-9_-]{1,32}` and indexes into the `ENCRYPTION_KEYS` +JSON map. Decryption tries the versioned format first; if no `.` is present +or the prefix isn't a known id, the row is treated as legacy and decrypted +under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). + +> **Why a separate `v1` is required.** Legacy ciphertexts have no key id, so +> the only way to identify them is "no `.` in the value". If you remove the +> `v1` entry from `ENCRYPTION_KEYS` _before_ every legacy row has been +> rotated, those rows can't be decrypted any more — the active key won't +> match the original ciphertext. The decrypt path now refuses to silently +> fall back to the active key in that scenario; it throws a clear error. + +## Rotating from v1.3.x to a new key + +1. **Generate the new key** on a machine with `openssl`: + ``` + openssl rand -hex 32 + ``` +2. **Update environment variables.** Keep the existing `ENCRYPTION_KEY` in + place — it's still needed to decrypt legacy rows during the transition: + ``` + ENCRYPTION_KEY="" + ENCRYPTION_KEYS='{"v1":"","v2":""}' + ENCRYPTION_ACTIVE_KEY_ID="v2" + ``` + 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 existing encrypted column + under the new active key: + ``` + pnpm 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 User, WithingsConnection, AppSettings, + NotificationChannel, and PushSubscription rows. The summary line at the + end shows `scanned`, `rotated`, and `errors` per table+field; treat + `errors > 0` as a hard failure and re-run after fixing the cause. +4. **Drop the old key (optional, recommended).** Once the script has run + cleanly: + ``` + ENCRYPTION_KEYS='{"v2":""}' + ENCRYPTION_ACTIVE_KEY_ID="v2" + # ENCRYPTION_KEY can now be removed + ``` + Restart. The legacy single-key fallback is now disconnected; only `v2` + exists. + +## Adding a third key (v2 → v3) + +Same procedure, just shift the labels — keep `v2` in the map until the +script reports zero `v2.`-prefixed rows remaining. + +``` +ENCRYPTION_KEYS='{"v2":"","v3":""}' +ENCRYPTION_ACTIVE_KEY_ID="v3" +pnpm tsx scripts/rotate-encryption-key.ts v3 +ENCRYPTION_KEYS='{"v3":""}' +``` + +## Rollback + +> **Important.** Once the rotation script 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. + +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 + `v2.`-prefixed rows back to legacy format — by design, rotation is a + forward-only operation. + +This is why we recommend running the rotation in a window where you have a +fresh DB snapshot and the new image has been smoke-tested in production for +at least 24 hours. + +## Troubleshooting + +- `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. +- `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 + 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 + guard prevents accidental re-encryption to a non-current key. diff --git a/docs/ops/migrations/0025-down.sql b/docs/ops/migrations/0025-down.sql new file mode 100644 index 000000000..912c21e8d --- /dev/null +++ b/docs/ops/migrations/0025-down.sql @@ -0,0 +1,6 @@ +-- 0025_refresh_tokens — down migration (v1.4 G4 rollback). +-- Forward-compatible: the up migration only ADDS a table, so this is a +-- safe DROP. Any in-flight refresh tokens are invalidated; native clients +-- need to re-authenticate. + +DROP TABLE IF EXISTS "refresh_tokens"; diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md new file mode 100644 index 000000000..49155dab7 --- /dev/null +++ b/docs/self-hosting/scaling.md @@ -0,0 +1,53 @@ +# Scaling: web/worker container split (v1.4 G3) + +The default `docker compose up` launches a single container that runs +both the Next.js HTTP server AND the pg-boss worker. That works fine +for a personal deployment but is hard to scale horizontally because +every replica also runs the full reminder/insight cron schedule. + +v1.4 introduces an environment-variable gate so the same image can be +deployed in three modes: + +| `HEALTHLOG_PROCESS_TYPE` | Web | Worker | Use when | +| ------------------------ | --- | ------ | ------------------------- | +| `all` (default) | ✅ | ✅ | single-host self-hosting | +| `web` | ✅ | ❌ | scaling HTTP horizontally | +| `worker` | ❌ | ✅ | dedicated job worker | + +## How to split + +1. Edit `docker-compose.yml`: set `HEALTHLOG_PROCESS_TYPE=web` on the + `app` service and uncomment the `app-worker` service block at the + bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in). +2. Make sure both containers see the same `DATABASE_URL`, + `ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), `API_TOKEN_HMAC_KEY`, + and `SESSION_SECRET`. +3. `docker compose up -d --build`. + +Both containers connect to the same Postgres. pg-boss claims jobs +atomically via row-level locking, so it is safe to run multiple worker +replicas — they will share the load. + +## Healthchecks + +- `app` (web) healthcheck: `wget /api/version` every 30s. +- `app-worker` does not expose a port; its liveness is reported into + the `worker_status` table on every reminder pass and surfaced through + `/api/admin/worker-status`. + +The web container does NOT depend on the worker, and vice versa, so +neither container's startup can deadlock the other. The shared +dependency is Postgres; both wait on `db: condition: service_healthy`. + +## Caveats + +- Background tasks that touch user-scoped Wide Events (e.g. reminder + notifications) emit telemetry from the worker; configure `LOKI_*` + env vars on the worker if you want them. +- The off-host backup job runs in the worker only. The admin + `POST /api/admin/backup/test` endpoint runs in the web container and + exercises the same S3 credentials — set `BACKUP_*` env vars on BOTH. +- The startup gate (`assertSubsystemEnabled`) refuses to boot the + reminder worker when `HEALTHLOG_PROCESS_TYPE=web`, so an accidental + cross-mode invocation aborts immediately instead of silently doubling + cron load. diff --git a/package.json b/package.json index fc4182813..de2c44f4c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:watch": "vitest" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1045.0", "@hookform/resolvers": "^5.2.2", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8af7b9dc1..1857c9a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1045.0 + version: 3.1045.0 '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.75.0(react@19.2.5)) @@ -148,6 +151,165 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.1045.0': + resolution: {integrity: sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.8': + resolution: {integrity: sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.7': + resolution: {integrity: sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.34': + resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.36': + resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.38': + resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.38': + resolution: {integrity: sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.39': + resolution: {integrity: sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.34': + resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.38': + resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.38': + resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + resolution: {integrity: sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.10': + resolution: {integrity: sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.974.16': + resolution: {integrity: sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.10': + resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.10': + resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.10': + resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.11': + resolution: {integrity: sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.37': + resolution: {integrity: sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.10': + resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.38': + resolution: {integrity: sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.6': + resolution: {integrity: sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.13': + resolution: {integrity: sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.25': + resolution: {integrity: sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1041.0': + resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.8': + resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.3': + resolution: {integrity: sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.8': + resolution: {integrity: sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.10': + resolution: {integrity: sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==} + + '@aws-sdk/util-user-agent-node@3.973.24': + resolution: {integrity: sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.22': + resolution: {integrity: sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -944,6 +1106,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@node-rs/argon2-android-arm-eabi@2.0.2': resolution: {integrity: sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==} engines: {node: '>= 10'} @@ -2022,6 +2187,218 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/chunked-blob-reader-native@4.2.3': + resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.2': + resolution: {integrity: sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.17': + resolution: {integrity: sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.17': + resolution: {integrity: sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.14': + resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.14': + resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.14': + resolution: {integrity: sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.14': + resolution: {integrity: sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.14': + resolution: {integrity: sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.14': + resolution: {integrity: sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.17': + resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.15': + resolution: {integrity: sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.14': + resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.14': + resolution: {integrity: sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.14': + resolution: {integrity: sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.14': + resolution: {integrity: sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.14': + resolution: {integrity: sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.32': + resolution: {integrity: sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.5.7': + resolution: {integrity: sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.20': + resolution: {integrity: sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.14': + resolution: {integrity: sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.14': + resolution: {integrity: sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.6.1': + resolution: {integrity: sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.14': + resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.14': + resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.14': + resolution: {integrity: sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.14': + resolution: {integrity: sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.3.1': + resolution: {integrity: sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.9': + resolution: {integrity: sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.14': + resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.13': + resolution: {integrity: sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.1': + resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.14': + resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.49': + resolution: {integrity: sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.54': + resolution: {integrity: sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.4.2': + resolution: {integrity: sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.14': + resolution: {integrity: sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.3.8': + resolution: {integrity: sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.25': + resolution: {integrity: sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.3.0': + resolution: {integrity: sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2594,6 +2971,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3254,6 +3634,13 @@ packages: fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-xml-builder@1.1.9: + resolution: {integrity: sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw==} + + fast-xml-parser@5.7.2: + resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4233,6 +4620,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -4899,6 +5290,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5325,6 +5719,453 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.8 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1045.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-node': 3.972.39 + '@aws-sdk/middleware-bucket-endpoint': 3.972.10 + '@aws-sdk/middleware-expect-continue': 3.972.10 + '@aws-sdk/middleware-flexible-checksums': 3.974.16 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-location-constraint': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/middleware-ssec': 3.972.10 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/eventstream-serde-browser': 4.2.14 + '@smithy/eventstream-serde-config-resolver': 4.3.14 + '@smithy/eventstream-serde-node': 4.2.14 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-blob-browser': 4.2.15 + '@smithy/hash-node': 4.2.14 + '@smithy/hash-stream-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/md5-js': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/util-waiter': 4.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.974.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/xml-builder': 3.972.22 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.7': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.36': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-login': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.39': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.34 + '@aws-sdk/credential-provider-http': 3.972.36 + '@aws-sdk/credential-provider-ini': 3.972.38 + '@aws-sdk/credential-provider-process': 3.972.34 + '@aws-sdk/credential-provider-sso': 3.972.38 + '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.974.16': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/crc64-nvme': 3.972.7 + '@aws-sdk/types': 3.973.8 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.11': + dependencies: + '@aws-sdk/types': 3.973.8 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.37': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-arn-parser': 3.972.3 + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.38': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-retry': 4.3.8 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.6': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.8 + '@aws-sdk/middleware-host-header': 3.972.10 + '@aws-sdk/middleware-logger': 3.972.10 + '@aws-sdk/middleware-recursion-detection': 3.972.11 + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/region-config-resolver': 3.972.13 + '@aws-sdk/signature-v4-multi-region': 3.996.25 + '@aws-sdk/types': 3.973.8 + '@aws-sdk/util-endpoints': 3.996.8 + '@aws-sdk/util-user-agent-browser': 3.972.10 + '@aws-sdk/util-user-agent-node': 3.973.24 + '@smithy/config-resolver': 4.4.17 + '@smithy/core': 3.23.17 + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/hash-node': 4.2.14 + '@smithy/invalid-dependency': 4.2.14 + '@smithy/middleware-content-length': 4.2.14 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-retry': 4.5.7 + '@smithy/middleware-serde': 4.2.20 + '@smithy/middleware-stack': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/node-http-handler': 4.6.1 + '@smithy/protocol-http': 5.3.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.49 + '@smithy/util-defaults-mode-node': 4.2.54 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/config-resolver': 4.4.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.25': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.37 + '@aws-sdk/types': 3.973.8 + '@smithy/protocol-http': 5.3.14 + '@smithy/signature-v4': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1041.0': + dependencies: + '@aws-sdk/core': 3.974.8 + '@aws-sdk/nested-clients': 3.997.6 + '@aws-sdk/types': 3.973.8 + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.8': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.3': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.8': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-endpoints': 3.4.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.10': + dependencies: + '@aws-sdk/types': 3.973.8 + '@smithy/types': 4.14.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.24': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.38 + '@aws-sdk/types': 3.973.8 + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.22': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.1 + fast-xml-parser: 5.7.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6006,6 +6847,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@2.1.0': {} + '@node-rs/argon2-android-arm-eabi@2.0.2': optional: true @@ -7142,6 +7985,339 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/chunked-blob-reader-native@4.2.3': + dependencies: + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.17': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.4.2 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/core@3.23.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-stream': 4.5.25 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.14': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.14': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.14': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.14': + dependencies: + '@smithy/eventstream-codec': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.17': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.15': + dependencies: + '@smithy/chunked-blob-reader': 5.2.2 + '@smithy/chunked-blob-reader-native': 4.2.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.14': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.32': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-serde': 4.2.20 + '@smithy/node-config-provider': 4.3.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + '@smithy/url-parser': 4.2.14 + '@smithy/util-middleware': 4.2.14 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.5.7': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/node-config-provider': 4.3.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/service-error-classification': 4.3.1 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-retry': 4.3.8 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.20': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.14': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/shared-ini-file-loader': 4.4.9 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.6.1': + dependencies: + '@smithy/protocol-http': 5.3.14 + '@smithy/querystring-builder': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.3.1': + dependencies: + '@smithy/types': 4.14.1 + + '@smithy/shared-ini-file-loader@4.4.9': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.14': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.14 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.13': + dependencies: + '@smithy/core': 3.23.17 + '@smithy/middleware-endpoint': 4.4.32 + '@smithy/middleware-stack': 4.2.14 + '@smithy/protocol-http': 5.3.14 + '@smithy/types': 4.14.1 + '@smithy/util-stream': 4.5.25 + tslib: 2.8.1 + + '@smithy/types@4.14.1': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.14': + dependencies: + '@smithy/querystring-parser': 4.2.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.49': + dependencies: + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.54': + dependencies: + '@smithy/config-resolver': 4.4.17 + '@smithy/credential-provider-imds': 4.2.14 + '@smithy/node-config-provider': 4.3.14 + '@smithy/property-provider': 4.2.14 + '@smithy/smithy-client': 4.12.13 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.4.2': + dependencies: + '@smithy/node-config-provider': 4.3.14 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.14': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-retry@4.3.8': + dependencies: + '@smithy/service-error-classification': 4.3.1 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.25': + dependencies: + '@smithy/fetch-http-handler': 5.3.17 + '@smithy/node-http-handler': 4.6.1 + '@smithy/types': 4.14.1 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-waiter@4.3.0': + dependencies: + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -7735,6 +8911,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -8571,6 +9749,17 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-builder@1.1.9: + dependencies: + path-expression-matcher: 1.5.0 + + fast-xml-parser@5.7.2: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.1.9 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -9493,6 +10682,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -10283,6 +11474,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.3.0: {} + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): dependencies: client-only: 0.0.1 diff --git a/prisma/migrations/0025_refresh_tokens/migration.sql b/prisma/migrations/0025_refresh_tokens/migration.sql new file mode 100644 index 000000000..5b1cab313 --- /dev/null +++ b/prisma/migrations/0025_refresh_tokens/migration.sql @@ -0,0 +1,47 @@ +-- 0025_refresh_tokens +-- Native-client short-lived access + rotating refresh tokens (v1.4 G4). +-- Forward-compatible: this migration only ADDS a table. No DROP COLUMN, no +-- type changes on existing tables. To roll back, the down migration in +-- docs/ops/migrations/0025-down.sql DROPs the table. +-- +-- All statements use IF NOT EXISTS so a partial-deploy retry is a no-op +-- instead of "relation already exists". Wrapped in a single transaction +-- so a mid-statement failure leaves no half-built table behind. + +BEGIN; + +CREATE TABLE IF NOT EXISTS "refresh_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "device_id" TEXT, + "access_token_hash" TEXT, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revoked_at" TIMESTAMP(3), + "used_at" TIMESTAMP(3), + "replaced_by_id" TEXT, + "user_agent" TEXT, + "ip_address" TEXT, + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); +CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id"); +CREATE INDEX IF NOT EXISTS "refresh_tokens_user_id_device_id_idx" ON "refresh_tokens"("user_id", "device_id"); +CREATE INDEX IF NOT EXISTS "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'refresh_tokens_user_id_fkey' + ) THEN + ALTER TABLE "refresh_tokens" + ADD CONSTRAINT "refresh_tokens_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +COMMIT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7f14e5060..e92255e7d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,7 @@ model User { medications Medication[] intakeEvents MedicationIntakeEvent[] apiTokens ApiToken[] + refreshTokens RefreshToken[] auditLogs AuditLog[] withingsConnection WithingsConnection? moodEntries MoodEntry[] @@ -357,6 +358,47 @@ model ApiToken { @@map("api_tokens") } +// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─ + +model RefreshToken { + id String @id @default(cuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") + /// Optional device fingerprint — links the refresh token to a specific + /// `Device` row when the caller advertised one (X-Device-Id header). Used + /// to revoke all tokens issued for a stolen/lost device. + deviceId String? @map("device_id") + /// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by + /// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response. + /// When the refresh token is rotated we revoke the matching access token + /// via `ApiToken.tokenHash`. Null if the access token was issued + /// out-of-band. + accessTokenHash String? @map("access_token_hash") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revokedAt DateTime? @map("revoked_at") + /// When this token was used (one-time-use). Set during rotation; a second + /// use of the same token is a hard fail and triggers reuse-detection. + usedAt DateTime? @map("used_at") + /// Pointer to the row that replaced this one during rotation. Helps audit + /// trail and reuse-detection ("this token was already exchanged into X"). + replacedById String? @map("replaced_by_id") + userAgent String? @map("user_agent") + ipAddress String? @map("ip_address") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + /// Composite index — every reuse-detection / device-scoped lookup filters + /// on (userId, deviceId), so a single-column device_id index would never + /// be used while costing writes. Multiple live rows per (userId, deviceId) + /// are intentional: rotated rows linger in `revokedAt != null` state for + /// audit and reuse-detection. + @@index([userId, deviceId]) + @@index([expiresAt]) + @@map("refresh_tokens") +} + // ─── Withings ────────────────────────────────────────────── model WithingsConnection { diff --git a/scripts/restore-backup.ts b/scripts/restore-backup.ts new file mode 100644 index 000000000..8a302206e --- /dev/null +++ b/scripts/restore-backup.ts @@ -0,0 +1,128 @@ +/** + * scripts/restore-backup.ts [output-file] + * + * Downloads a single off-host backup object, decrypts it with + * `BACKUP_ENCRYPTION_KEY`, and writes the JSON dump to disk. + * + * Wire format (binary): + * magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext + * + * Usage: + * BACKUP_S3_ENDPOINT=... \ + * BACKUP_S3_BUCKET=... \ + * BACKUP_S3_ACCESS_KEY=... \ + * BACKUP_S3_SECRET_KEY=... \ + * BACKUP_S3_REGION=auto \ + * BACKUP_ENCRYPTION_KEY= \ + * pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json + */ +import "dotenv/config"; +import { writeFileSync } from "node:fs"; +import { + loadOffhostConfig, + getS3Client, + decryptBackup, + OffhostBackupNotConfiguredError, +} from "@/lib/jobs/offhost-backup"; + +/** + * Schema-validate the decrypted JSON before writing it to disk. The wire + * format from `runOffhostBackup` always carries `userId` and the four data + * arrays — anything else means the operator may be looking at a tampered or + * mis-targeted object. We reject rather than silently writing a body that a + * later import script would trust. + */ +function validateBackupShape(payload: unknown): { + exportedAt: string; + userId: string; + measurements: unknown[]; + medications: unknown[]; + intakeEvents: unknown[]; + moodEntries: unknown[]; +} { + if (!payload || typeof payload !== "object") { + throw new Error("backup payload is not a JSON object"); + } + const p = payload as Record; + const required = [ + "exportedAt", + "userId", + "measurements", + "medications", + "intakeEvents", + "moodEntries", + ] as const; + for (const k of required) { + if (!(k in p)) throw new Error(`backup is missing required field: ${k}`); + } + if (typeof p.exportedAt !== "string") + throw new Error("backup.exportedAt must be string"); + if (typeof p.userId !== "string" || p.userId.length === 0) + throw new Error("backup.userId must be a non-empty string"); + for (const k of [ + "measurements", + "medications", + "intakeEvents", + "moodEntries", + ] as const) { + if (!Array.isArray(p[k])) throw new Error(`backup.${k} must be an array`); + } + return p as ReturnType; +} + +async function main() { + const key = process.argv[2]; + const out = process.argv[3] ?? "./restored.json"; + // Optional positional arg: --user-id= requires the dump's `userId` + // field to match. Defends against an operator pointing the script at the + // wrong S3 key (e.g. another tenant's dump under a similar path) and + // silently importing the wrong user's data downstream. + const expectedUserId = process.argv + .find((a) => a.startsWith("--user-id=")) + ?.slice("--user-id=".length); + if (!key) { + console.error( + "Usage: pnpm tsx scripts/restore-backup.ts [output-file] [--user-id=]", + ); + process.exit(1); + } + + const cfg = loadOffhostConfig(); + if (!cfg) { + throw new OffhostBackupNotConfiguredError( + "Off-host backup is not configured. Set BACKUP_* env vars.", + ); + } + + const s3 = await getS3Client(cfg); + console.log( + `Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`, + ); + const ciphertext = await s3.getObject(key); + const plaintext = decryptBackup(ciphertext, cfg.encryptionKey); + + let parsed: unknown; + try { + parsed = JSON.parse(plaintext); + } catch (err) { + throw new Error( + `Decrypted payload is not valid JSON: ${(err as Error).message}`, + ); + } + const body = validateBackupShape(parsed); + if (expectedUserId && body.userId !== expectedUserId) { + throw new Error( + `Refusing to write: backup.userId='${body.userId}' does not match --user-id='${expectedUserId}'`, + ); + } + + writeFileSync(out, plaintext, "utf8"); + console.log( + `Restored userId=${body.userId} (${plaintext.length} bytes) -> ${out}`, + ); +} + +main().catch((err) => { + console.error("Restore failed:", err); + process.exit(2); +}); diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts new file mode 100644 index 000000000..af3fd3405 --- /dev/null +++ b/scripts/rotate-encryption-key.ts @@ -0,0 +1,246 @@ +/** + * scripts/rotate-encryption-key.ts + * + * Re-encrypts every encrypted column in the database with the currently + * active key (`ENCRYPTION_ACTIVE_KEY_ID`). Idempotent: rows whose ciphertext + * already carries the active key id prefix are skipped. + * + * Usage: + * ENCRYPTION_KEYS='{"v1":"","v2":""}' \ + * ENCRYPTION_ACTIVE_KEY_ID=v2 \ + * pnpm tsx scripts/rotate-encryption-key.ts v2 + */ +import "dotenv/config"; +import { PrismaClient } from "@/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error("DATABASE_URL must be set"); + process.exit(1); +} + +const targetKeyId = process.argv[2] ?? getActiveKeyId(); +if (targetKeyId !== getActiveKeyId()) { + console.error( + `Refusing to rotate: argv key id '${targetKeyId}' does not match the ` + + `currently active id '${getActiveKeyId()}'. Set ENCRYPTION_ACTIVE_KEY_ID first.`, + ); + process.exit(2); +} + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: DATABASE_URL }), +}); + +interface RotationResult { + table: string; + field: string; + scanned: number; + rotated: number; + errors: number; +} + +function shouldRotate(value: string | null): boolean { + if (!value) return false; + const id = extractKeyId(value); + // null = legacy/unversioned (rotate); else rotate if not already on active. + return id !== getActiveKeyId(); +} + +async function rotateField( + table: string, + field: string, + rows: T[], + fieldGetter: (row: T) => string | null, + updater: (id: string, ciphertext: string) => Promise, +): Promise { + const result: RotationResult = { + table, + field, + scanned: rows.length, + rotated: 0, + errors: 0, + }; + for (const row of rows) { + const v = fieldGetter(row); + if (!shouldRotate(v)) continue; + try { + const re = encrypt(decrypt(v as string)); + await updater(row.id, re); + result.rotated++; + } catch (err) { + result.errors++; + console.error( + `[${table}.${field}] row ${row.id}: ${(err as Error).message}`, + ); + } + } + return result; +} + +async function main() { + console.log(`Rotating encrypted rows to active key id: ${targetKeyId}`); + const results: RotationResult[] = []; + + // ───── User table ───── + const users = await prisma.user.findMany({ + select: { + id: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + moodLogWebhookSecret: true, + telegramBotToken: true, + moodLogUrlEncrypted: true, + moodLogApiKeyEncrypted: true, + withingsClientIdEncrypted: true, + withingsClientSecretEncrypted: true, + }, + }); + + const userFields: Array = [ + "codexAccessTokenEncrypted", + "codexRefreshTokenEncrypted", + "aiAnthropicKeyEncrypted", + "aiLocalKeyEncrypted", + "moodLogWebhookSecret", + "telegramBotToken", + "moodLogUrlEncrypted", + "moodLogApiKeyEncrypted", + "withingsClientIdEncrypted", + "withingsClientSecretEncrypted", + ]; + for (const field of userFields) { + const r = await rotateField( + "User", + field as string, + users, + (u) => (u as Record)[field as string] as string | null, + async (id, ciphertext) => { + await prisma.user.update({ + where: { id }, + data: { [field]: ciphertext } as Record, + }); + }, + ); + results.push(r); + } + + // ───── WithingsConnection table (accessToken / refreshToken) ───── + const withings = await prisma.withingsConnection.findMany({ + select: { id: true, accessToken: true, refreshToken: true }, + }); + for (const field of ["accessToken", "refreshToken"] as const) { + const r = await rotateField( + "WithingsConnection", + field, + withings, + (w) => w[field], + async (id, ciphertext) => { + await prisma.withingsConnection.update({ + where: { id }, + data: { [field]: ciphertext } as Record, + }); + }, + ); + results.push(r); + } + + // ───── AppSettings table (singleton typically) ───── + const settings = await prisma.appSettings.findMany({ + select: { + id: true, + adminAiKeyEncrypted: true, + webPushVapidPrivateKeyEncrypted: true, + githubIssueTokenEncrypted: true, + }, + }); + const appFields = [ + "adminAiKeyEncrypted", + "webPushVapidPrivateKeyEncrypted", + "githubIssueTokenEncrypted", + ] as const; + for (const field of appFields) { + const r = await rotateField( + "AppSettings", + field, + settings, + (s) => s[field], + async (id, ciphertext) => { + await prisma.appSettings.update({ + where: { id }, + data: { [field]: ciphertext } as Record, + }); + }, + ); + results.push(r); + } + + // ───── NotificationChannel.config ───── + // Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON. + // Skipping these on rotation would leave channels permanently undecryptable + // once the operator drops `v1` from the key map. + const channels = await prisma.notificationChannel.findMany({ + select: { id: true, config: true }, + }); + results.push( + await rotateField( + "NotificationChannel", + "config", + channels, + (c) => c.config, + async (id, ciphertext) => { + await prisma.notificationChannel.update({ + where: { id }, + data: { config: ciphertext }, + }); + }, + ), + ); + + // ───── PushSubscription.{p256dh, auth} ───── + // Web-push routing secrets — without these, the push endpoint is reachable + // but the browser ignores the message (auth tag mismatch). + const subs = await prisma.pushSubscription.findMany({ + select: { id: true, p256dh: true, auth: true }, + }); + for (const field of ["p256dh", "auth"] as const) { + results.push( + await rotateField( + "PushSubscription", + field, + subs, + (s) => s[field], + async (id, ciphertext) => { + await prisma.pushSubscription.update({ + where: { id }, + data: { [field]: ciphertext } as Record, + }); + }, + ), + ); + } + + console.log("\n=== Rotation summary ==="); + let totalRotated = 0; + let totalErrors = 0; + for (const r of results) { + console.log( + `${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`, + ); + totalRotated += r.rotated; + totalErrors += r.errors; + } + console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`); + await prisma.$disconnect(); + process.exit(totalErrors > 0 ? 3 : 0); +} + +main().catch(async (err) => { + console.error("Rotation failed:", err); + await prisma.$disconnect(); + process.exit(1); +}); diff --git a/src/app/api/admin/backup/test/route.ts b/src/app/api/admin/backup/test/route.ts new file mode 100644 index 000000000..89bed35b9 --- /dev/null +++ b/src/app/api/admin/backup/test/route.ts @@ -0,0 +1,36 @@ +/** + * POST /api/admin/backup/test — admin-only smoke test for the off-host + * backup target. Issues a 1-byte PUT + GET roundtrip and returns the + * endpoint, bucket, region, and per-call latency. Credentials are NEVER + * returned; only the endpoint host + bucket name are surfaced. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + runOffhostRoundtripTest, + OffhostBackupNotConfiguredError, +} from "@/lib/jobs/offhost-backup"; + +export const POST = apiHandler(async (request: NextRequest) => { + void request; + await requireAdmin(); + try { + const report = await runOffhostRoundtripTest(); + annotate({ + action: { name: "admin.backup.test" }, + meta: { + ok: report.ok, + put_latency_ms: report.putLatencyMs, + get_latency_ms: report.getLatencyMs, + }, + }); + return apiSuccess(report); + } catch (err) { + if (err instanceof OffhostBackupNotConfiguredError) { + return apiError(err.message, 400); + } + throw err; + } +}); diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index 7e3d73b58..389956c30 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findFirst: vi.fn() }, apiToken: { create: vi.fn() }, + refreshToken: { create: vi.fn() }, }, })); @@ -84,6 +85,9 @@ beforeEach(() => { vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never); vi.mocked(verifyPassword).mockResolvedValue(true); vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); + vi.mocked(prisma.refreshToken.create).mockResolvedValue({ + id: "rt-1", + } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 5, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 3937ee13d..a6f820a2d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,13 +3,23 @@ import { loginPasswordSchema } from "@/lib/validations/auth"; import { verifyPassword } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; +import { + resolveTokenPolicy, + shouldIssueBearerToken, +} from "@/lib/auth/native-client"; +import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; export const POST = apiHandler(async (request: NextRequest) => { const ip = getClientIp(request) ?? "unknown"; @@ -75,22 +85,64 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.login.password" } }); - // Native clients (iOS) additionally receive a long-lived Bearer token in - // the response so they don't need to juggle the cookie jar. - if (isNativeClientRequest(request.headers)) { + // v1.4 G4: Native callers (iOS, n8n, Health-Connect, unrecognised UAs) + // get a 24h access token + 60d rotating refresh token. Web UAs keep the + // legacy 90d Bearer (issued only when X-Client-Type: native is set — + // browser flows continue to use the session cookie). + if ( + shouldIssueBearerToken(request.headers) || + isNativeClientRequest(request.headers) + ) { + const policy = resolveTokenPolicy(request.headers); + const deviceId = request.headers.get("x-device-id"); + + if (policy.refreshTokenDays !== null) { + const bundle = await issueAccessAndRefresh({ + userId: user.id, + policy, + deviceId, + userAgent: ua, + ipAddress: ip, + source: "login.password", + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { source: "login.password", policy: "native" }, + }); + annotate({ + action: { name: "auth.token.autoissue.native" }, + meta: { token_policy: "native" }, + }); + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: bundle.accessToken, + tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), + refreshToken: bundle.refreshToken, + refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), + }); + } + + // Web policy with explicit X-Client-Type:native (legacy iOS auto-login) const issued = await issueApiToken({ userId: user.id, - name: `iOS auto-login ${new Date().toISOString()}`, + name: `web auto-login ${new Date().toISOString()}`, permissions: ["*"], - expiresInDays: 90, + expiresInDays: policy.accessTokenDays, }); await auditLog("auth.token.autoissue.native", { userId: user.id, ipAddress: ip, - details: { tokenId: issued.tokenId, source: "login.password" }, + details: { + tokenId: issued.tokenId, + source: "login.password", + policy: "web", + }, + }); + annotate({ + action: { name: "auth.token.autoissue.native" }, + meta: { token_policy: "web" }, }); - annotate({ action: { name: "auth.token.autoissue.native" } }); - return apiSuccess({ user: { id: user.id, username: user.username }, token: issued.token, diff --git a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts index 0ee48dcb2..026f17940 100644 --- a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts +++ b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts @@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, apiToken: { create: vi.fn() }, + refreshToken: { create: vi.fn() }, }, })); @@ -71,6 +72,9 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never); vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); + vi.mocked(prisma.refreshToken.create).mockResolvedValue({ + id: "rt-1", + } as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 9, diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 5332f3519..a15a56f73 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,7 +1,12 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -9,17 +14,27 @@ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; +import { + resolveTokenPolicy, + shouldIssueBearerToken, +} from "@/lib/auth/native-client"; +import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); const ip = getClientIp(request); - const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000); + const rl = await checkRateLimit( + `auth:passkey-verify:${ip}`, + 10, + 15 * 60 * 1000, + ); if (!rl.allowed) { return apiError("Too many attempts. Please wait 15 minutes.", 429); } - const { data: body, error: jsonError } = await safeJson>(request); + const { data: body, error: jsonError } = + await safeJson>(request); if (jsonError) return jsonError; const challengeId = body.challengeId as string | undefined; @@ -60,20 +75,59 @@ export const POST = apiHandler(async (request: NextRequest) => { annotate({ action: { name: "auth.login.passkey" } }); - if (isNativeClientRequest(request.headers)) { + if ( + shouldIssueBearerToken(request.headers) || + isNativeClientRequest(request.headers) + ) { + const policy = resolveTokenPolicy(request.headers); + const deviceId = request.headers.get("x-device-id"); + + if (policy.refreshTokenDays !== null) { + const bundle = await issueAccessAndRefresh({ + userId: user.id, + policy, + deviceId, + userAgent: ua, + ipAddress: ip ?? null, + source: "login.passkey", + }); + await auditLog("auth.token.autoissue.native", { + userId: user.id, + ipAddress: ip, + details: { source: "login.passkey", policy: "native" }, + }); + annotate({ + action: { name: "auth.token.autoissue.native" }, + meta: { token_policy: "native" }, + }); + return apiSuccess({ + user: { id: user.id, username: user.username }, + token: bundle.accessToken, + tokenExpiresAt: bundle.accessTokenExpiresAt.toISOString(), + refreshToken: bundle.refreshToken, + refreshTokenExpiresAt: bundle.refreshTokenExpiresAt.toISOString(), + }); + } + const issued = await issueApiToken({ userId: user.id, - name: `iOS auto-login ${new Date().toISOString()}`, + name: `web auto-login ${new Date().toISOString()}`, permissions: ["*"], - expiresInDays: 90, + expiresInDays: policy.accessTokenDays, }); await auditLog("auth.token.autoissue.native", { userId: user.id, ipAddress: ip, - details: { tokenId: issued.tokenId, source: "login.passkey" }, + details: { + tokenId: issued.tokenId, + source: "login.passkey", + policy: "web", + }, + }); + annotate({ + action: { name: "auth.token.autoissue.native" }, + meta: { token_policy: "web" }, }); - annotate({ action: { name: "auth.token.autoissue.native" } }); - return apiSuccess({ user: { id: user.id, username: user.username }, token: issued.token, diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 000000000..be698f106 --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,101 @@ +/** + * POST /api/auth/refresh — exchange a one-time-use refresh token for a new + * access + refresh pair. Audited on every call (success + failure). + */ +import { NextRequest } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { resolveTokenPolicy } from "@/lib/auth/native-client"; +import { + rotateRefreshToken, + revokeRefreshToken, +} from "@/lib/auth/refresh-token"; + +export const POST = apiHandler(async (request: NextRequest) => { + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit(`auth:refresh:${ip}`, 60, 15 * 60 * 1000); + if (!rl.allowed) { + return apiError("Too many refresh attempts. Please retry later.", 429); + } + + const { data: body, error: jsonError } = await safeJson<{ + refreshToken?: string; + revoke?: boolean; + }>(request); + if (jsonError) return jsonError; + if (!body.refreshToken || typeof body.refreshToken !== "string") { + return apiError("refreshToken required", 422); + } + + // logout-on-device path: client wants to invalidate the refresh token. + if (body.revoke) { + const ok = await revokeRefreshToken(body.refreshToken); + annotate({ + action: { name: "auth.token.refresh.revoke" }, + meta: { refresh_revoked: ok }, + }); + await auditLog("auth.token.refresh.revoke", { + ipAddress: ip, + details: { ok }, + }); + return apiSuccess({ revoked: ok }); + } + + const policy = resolveTokenPolicy(request.headers); + // Refresh always uses the native policy: a refresh token only exists for + // native callers, and we never want to upgrade it back to a 90d token. + if (policy.refreshTokenDays === null) { + policy.refreshTokenDays = 60; + policy.accessTokenDays = 1; + } + + const result = await rotateRefreshToken({ + refreshToken: body.refreshToken, + policy, + deviceId: request.headers.get("x-device-id"), + userAgent: request.headers.get("user-agent"), + ipAddress: ip, + }); + + if (!result.ok) { + await auditLog("auth.token.refresh.failed", { + ipAddress: ip, + details: { reason: result.reason }, + }); + annotate({ + action: { name: "auth.token.refresh.failed" }, + meta: { refresh_failure: result.reason }, + }); + if (result.reason === "already_used") { + return apiError( + "Refresh token reuse detected — please log in again.", + 401, + ); + } + return apiError("Invalid refresh token", 401); + } + + await auditLog("auth.token.refresh", { + ipAddress: ip, + details: { policy: policy.policy }, + }); + annotate({ + action: { name: "auth.token.refresh" }, + meta: { token_policy: policy.policy }, + }); + + return apiSuccess({ + token: result.bundle.accessToken, + tokenExpiresAt: result.bundle.accessTokenExpiresAt.toISOString(), + refreshToken: result.bundle.refreshToken, + refreshTokenExpiresAt: result.bundle.refreshTokenExpiresAt.toISOString(), + }); +}); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index addf9fbfa..b55797577 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -3,6 +3,10 @@ import type { Instrumentation } from "next"; export async function register() { // Only start the worker on the Node.js server runtime (not Edge, not build) if (process.env.NEXT_RUNTIME === "nodejs") { + const { shouldRunWorker } = await import("@/lib/process-type"); + // Web-only container — the dedicated worker service runs the queues. + if (!shouldRunWorker()) return; + const { WideEventBuilder } = await import("@/lib/logging/event-builder"); const { emitIfSampled } = await import("@/lib/logging/transports"); diff --git a/src/lib/__tests__/crypto.test.ts b/src/lib/__tests__/crypto.test.ts index b7075fc69..25efbcd5c 100644 --- a/src/lib/__tests__/crypto.test.ts +++ b/src/lib/__tests__/crypto.test.ts @@ -1,70 +1,135 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { encrypt, decrypt } from "../crypto"; +import { + encrypt, + decrypt, + reencryptToActive, + extractKeyId, + getActiveKeyId, + _resetCryptoCacheForTests, +} from "../crypto"; -describe("crypto (AES-256-GCM)", () => { +const KEY_V1 = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const KEY_V2 = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +describe("crypto (AES-256-GCM, key versioning)", () => { beforeEach(() => { - // Set a deterministic encryption key for tests (64 hex chars = 32 bytes) - vi.stubEnv( - "ENCRYPTION_KEY", - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - ); + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY_V1); + _resetCryptoCacheForTests(); }); - it("encrypts and decrypts back to original", () => { + it("encrypts and decrypts back to original (legacy single-key path)", () => { const plaintext = "my-secret-access-token-12345"; const encrypted = encrypt(plaintext); expect(encrypted).not.toBe(plaintext); expect(decrypt(encrypted)).toBe(plaintext); }); + it("uses the synthetic 'v1' id when only ENCRYPTION_KEY is set", () => { + expect(getActiveKeyId()).toBe("v1"); + const encrypted = encrypt("hello"); + expect(encrypted.startsWith("v1.")).toBe(true); + expect(extractKeyId(encrypted)).toBe("v1"); + }); + it("produces different ciphertext each time (random IV)", () => { - const plaintext = "same-input"; - const a = encrypt(plaintext); - const b = encrypt(plaintext); + const a = encrypt("same-input"); + const b = encrypt("same-input"); expect(a).not.toBe(b); - // But both decrypt to the same value - expect(decrypt(a)).toBe(plaintext); - expect(decrypt(b)).toBe(plaintext); + expect(decrypt(a)).toBe("same-input"); + expect(decrypt(b)).toBe("same-input"); }); - it("handles empty strings", () => { - const encrypted = encrypt(""); - expect(decrypt(encrypted)).toBe(""); + it("handles empty strings, unicode, long input", () => { + expect(decrypt(encrypt(""))).toBe(""); + expect(decrypt(encrypt("Zugangsdaten: 🔐 € ñ"))).toBe( + "Zugangsdaten: 🔐 € ñ", + ); + expect(decrypt(encrypt("x".repeat(10000)))).toBe("x".repeat(10000)); }); - it("handles unicode characters", () => { - const plaintext = "Zugangsdaten: 🔐 Schlüssel € ñ"; - const encrypted = encrypt(plaintext); - expect(decrypt(encrypted)).toBe(plaintext); + it("decrypts legacy unversioned ciphertext (bare base64)", () => { + // Manually craft a legacy bare-base64 encryption with KEY_V1 by stripping + // the v1. prefix the new encoder adds. + const versioned = encrypt("legacy-row"); + const legacy = versioned.slice("v1.".length); + // legacy should still decrypt because v1 key is in the keymap + expect(decrypt(legacy)).toBe("legacy-row"); }); - it("handles long strings", () => { - const plaintext = "x".repeat(10000); - const encrypted = encrypt(plaintext); - expect(decrypt(encrypted)).toBe(plaintext); + it("supports ENCRYPTION_KEYS + ENCRYPTION_ACTIVE_KEY_ID multi-key mode", () => { + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1, v2: KEY_V2 })); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2"); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); + + expect(getActiveKeyId()).toBe("v2"); + const ct = encrypt("multi-key-secret"); + expect(ct.startsWith("v2.")).toBe(true); + expect(decrypt(ct)).toBe("multi-key-secret"); + }); + + it("rotation: rows encrypted under old key still decrypt after switching active id", () => { + // Phase 1 — write under v1 + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1 })); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1"); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); + const oldRow = encrypt("rotate-me"); + expect(oldRow.startsWith("v1.")).toBe(true); + + // Phase 2 — add v2 and rotate active to v2; old row still decrypts + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1, v2: KEY_V2 })); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2"); + _resetCryptoCacheForTests(); + expect(decrypt(oldRow)).toBe("rotate-me"); + + const newRow = reencryptToActive(oldRow); + expect(newRow.startsWith("v2.")).toBe(true); + expect(decrypt(newRow)).toBe("rotate-me"); }); it("fails to decrypt with wrong key", () => { const encrypted = encrypt("secret"); - // Change the env key - vi.stubEnv( - "ENCRYPTION_KEY", - "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ); + vi.stubEnv("ENCRYPTION_KEY", "f".repeat(64)); + _resetCryptoCacheForTests(); expect(() => decrypt(encrypted)).toThrow(); }); it("fails to decrypt tampered ciphertext", () => { const encrypted = encrypt("secret"); - const buf = Buffer.from(encrypted, "base64"); - // Flip a bit in the ciphertext portion + // Versioned: "v1." + const dot = encrypted.indexOf("."); + const buf = Buffer.from(encrypted.slice(dot + 1), "base64"); buf[buf.length - 1] ^= 0xff; - const tampered = buf.toString("base64"); + const tampered = `v1.${buf.toString("base64")}`; expect(() => decrypt(tampered)).toThrow(); }); - it("throws if ENCRYPTION_KEY is missing", () => { + it("throws if no encryption configuration is present", () => { vi.stubEnv("ENCRYPTION_KEY", ""); - expect(() => encrypt("test")).toThrow("ENCRYPTION_KEY"); + vi.stubEnv("ENCRYPTION_KEYS", ""); + _resetCryptoCacheForTests(); + expect(() => encrypt("test")).toThrow(/Encryption is not configured/); + }); + + it("rejects malformed ENCRYPTION_KEYS JSON", () => { + vi.stubEnv("ENCRYPTION_KEYS", "not-json"); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1"); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); + expect(() => encrypt("test")).toThrow(/valid JSON/); + }); + + it("rejects an active key id that doesn't exist in the map", () => { + vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_V1 })); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v9"); + vi.stubEnv("ENCRYPTION_KEY", ""); + _resetCryptoCacheForTests(); + expect(() => encrypt("test")).toThrow(/no matching entry/); }); }); diff --git a/src/lib/__tests__/process-type.test.ts b/src/lib/__tests__/process-type.test.ts new file mode 100644 index 000000000..0d5b634dd --- /dev/null +++ b/src/lib/__tests__/process-type.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + assertSubsystemEnabled, + getProcessType, + shouldRunWeb, + shouldRunWorker, +} from "../process-type"; + +describe("process-type gate", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it("defaults to 'all' when env is unset", () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", ""); + expect(getProcessType()).toBe("all"); + expect(shouldRunWeb()).toBe(true); + expect(shouldRunWorker()).toBe(true); + }); + + it("recognises web mode", () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + expect(getProcessType()).toBe("web"); + expect(shouldRunWeb()).toBe(true); + expect(shouldRunWorker()).toBe(false); + }); + + it("recognises worker mode", () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "worker"); + expect(getProcessType()).toBe("worker"); + expect(shouldRunWeb()).toBe(false); + expect(shouldRunWorker()).toBe(true); + }); + + it("rejects unknown values", () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "junk"); + expect(() => getProcessType()).toThrow(/Invalid HEALTHLOG_PROCESS_TYPE/); + }); + + it("assertSubsystemEnabled refuses cross-mode boots", () => { + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web"); + expect(() => assertSubsystemEnabled("worker")).toThrow( + /Refusing to start worker/, + ); + expect(() => assertSubsystemEnabled("web")).not.toThrow(); + + vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "worker"); + expect(() => assertSubsystemEnabled("web")).toThrow( + /Refusing to start web/, + ); + expect(() => assertSubsystemEnabled("worker")).not.toThrow(); + }); +}); diff --git a/src/lib/auth/__tests__/native-client.test.ts b/src/lib/auth/__tests__/native-client.test.ts new file mode 100644 index 000000000..d1f273053 --- /dev/null +++ b/src/lib/auth/__tests__/native-client.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { + classifyClient, + resolveTokenPolicy, + shouldIssueBearerToken, +} from "../native-client"; + +function h(pairs: Record): Headers { + return new Headers(pairs); +} + +describe("native-client classification", () => { + it.each([ + ["browser Chrome", "Mozilla/5.0 (Macintosh) Chrome/142", "web"], + [ + "browser Safari", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/605", + "web", + ], + ["HealthLog-iOS", "HealthLog-iOS/1.4.0", "native"], + ["HealthLog-iPad", "HealthLog-iPad/1.4.0", "native"], + ["n8n", "n8n/1.62.0", "native"], + ["Health-Connect", "Health-Connect/2025.1", "native"], + ["unknown UA", "curl/8.5", "native"], + ["empty UA", "", "native"], + ] as const)("%s → %s", (_name, ua, expected) => { + expect(classifyClient(h({ "user-agent": ua }))).toBe(expected); + }); + + it("X-Client-Type header overrides UA", () => { + const browserAsNative = h({ + "user-agent": "Mozilla/5.0", + "x-client-type": "native", + }); + expect(classifyClient(browserAsNative)).toBe("native"); + + const iosAsWeb = h({ + "user-agent": "HealthLog-iOS/1.0", + "x-client-type": "web", + }); + expect(classifyClient(iosAsWeb)).toBe("web"); + }); +}); + +describe("resolveTokenPolicy", () => { + it("web → 90d access, no refresh", () => { + const p = resolveTokenPolicy(h({ "user-agent": "Mozilla/5.0" })); + expect(p).toEqual({ + policy: "web", + accessTokenDays: 90, + refreshTokenDays: null, + tokenLabel: "web", + }); + }); + + it("native → 1d access + 60d refresh", () => { + const p = resolveTokenPolicy(h({ "user-agent": "HealthLog-iOS/1.4" })); + expect(p).toEqual({ + policy: "native", + accessTokenDays: 1, + refreshTokenDays: 60, + tokenLabel: "native", + }); + }); +}); + +describe("shouldIssueBearerToken", () => { + it("emits Bearer for native UAs and X-Client-Type:native", () => { + expect(shouldIssueBearerToken(h({ "user-agent": "HealthLog-iOS/1" }))).toBe( + true, + ); + expect(shouldIssueBearerToken(h({ "user-agent": "n8n/1" }))).toBe(true); + expect( + shouldIssueBearerToken( + h({ "x-client-type": "native", "user-agent": "Mozilla/5.0" }), + ), + ).toBe(true); + }); + it("does NOT emit Bearer for plain browser sessions", () => { + expect( + shouldIssueBearerToken(h({ "user-agent": "Mozilla/5.0 Safari" })), + ).toBe(false); + expect(shouldIssueBearerToken(h({}))).toBe(false); + }); +}); diff --git a/src/lib/auth/__tests__/refresh-token.test.ts b/src/lib/auth/__tests__/refresh-token.test.ts new file mode 100644 index 000000000..a8d56a1e9 --- /dev/null +++ b/src/lib/auth/__tests__/refresh-token.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock prisma + the underlying issueApiToken so we can assert flow +// without spinning up a database. +const dbState: { + refreshTokens: Array<{ + id: string; + userId: string; + tokenHash: string; + deviceId: string | null; + accessTokenHash: string | null; + expiresAt: Date; + revokedAt: Date | null; + usedAt: Date | null; + replacedById: string | null; + userAgent: string | null; + ipAddress: string | null; + createdAt: Date; + }>; + apiTokens: Array<{ tokenHash: string; revoked: boolean }>; +} = { refreshTokens: [], apiTokens: [] }; + +let issuedCounter = 0; + +vi.mock("@/lib/db", () => ({ + prisma: { + refreshToken: { + create: vi.fn(async ({ data }: { data: Record }) => { + const row = { + id: `rt_${++issuedCounter}`, + createdAt: new Date(), + revokedAt: null, + usedAt: null, + replacedById: null, + userAgent: null, + ipAddress: null, + deviceId: null, + accessTokenHash: null, + ...data, + } as (typeof dbState.refreshTokens)[number]; + dbState.refreshTokens.push(row); + return row; + }), + findUnique: vi.fn(async ({ where }: { where: { tokenHash: string } }) => { + return ( + dbState.refreshTokens.find((r) => r.tokenHash === where.tokenHash) ?? + null + ); + }), + findMany: vi.fn(async ({ where }: { where: Record }) => { + return dbState.refreshTokens.filter((r) => + Object.entries(where).every( + ([k, v]) => (r as unknown as Record)[k] === v, + ), + ); + }), + updateMany: vi.fn( + async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + let count = 0; + for (const row of dbState.refreshTokens) { + const matches = Object.entries(where).every( + ([k, v]) => (row as unknown as Record)[k] === v, + ); + if (matches) { + Object.assign(row, data); + count++; + } + } + return { count }; + }, + ), + }, + apiToken: { + updateMany: vi.fn( + async ({ + where, + data, + }: { + where: Record; + data: Record; + }) => { + let count = 0; + for (const row of dbState.apiTokens) { + const tokenHashFilter = where.tokenHash as + | string + | { in: string[] } + | undefined; + const matches = + !tokenHashFilter || + (typeof tokenHashFilter === "string" + ? row.tokenHash === tokenHashFilter + : tokenHashFilter.in.includes(row.tokenHash)); + if (matches) { + Object.assign(row, data); + count++; + } + } + return { count }; + }, + ), + }, + }, +})); + +vi.mock("@/lib/auth/issue-token", () => ({ + issueApiToken: vi.fn( + async (opts: { userId: string; expiresInDays?: number }) => { + issuedCounter++; + const token = `hlk_token_${issuedCounter}`; + dbState.apiTokens.push({ + tokenHash: `hash:${token}`, + revoked: false, + }); + return { + token, + expiresAt: new Date(Date.now() + (opts.expiresInDays ?? 1) * 86400000), + tokenId: `t_${issuedCounter}`, + name: "test", + }; + }, + ), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: (raw: string) => `hash:${raw}`, +})); + +import { + issueAccessAndRefresh, + rotateRefreshToken, + revokeRefreshToken, +} from "../refresh-token"; +import type { TokenPolicyDecision } from "../native-client"; + +const NATIVE_POLICY: TokenPolicyDecision = { + policy: "native", + accessTokenDays: 1, + refreshTokenDays: 60, + tokenLabel: "native", +}; + +beforeEach(() => { + dbState.refreshTokens = []; + dbState.apiTokens = []; + issuedCounter = 0; +}); + +describe("issueAccessAndRefresh", () => { + it("creates an ApiToken + RefreshToken row paired by hash", async () => { + const bundle = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + }); + expect(bundle.accessToken).toMatch(/^hlk_token_/); + expect(bundle.refreshToken).toMatch(/^hlr_/); + expect(dbState.refreshTokens).toHaveLength(1); + expect(dbState.refreshTokens[0].accessTokenHash).toBe( + `hash:${bundle.accessToken}`, + ); + }); + + it("throws if called for web policy (no refresh token)", async () => { + await expect( + issueAccessAndRefresh({ + userId: "u1", + policy: { ...NATIVE_POLICY, refreshTokenDays: null, policy: "web" }, + source: "login.password", + }), + ).rejects.toThrow(/web policy/); + }); +}); + +describe("rotateRefreshToken", () => { + it("rotates: marks old used, issues new pair, revokes old access token", async () => { + const initial = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + }); + const oldHash = `hash:${initial.accessToken}`; + expect( + dbState.apiTokens.find((a) => a.tokenHash === oldHash)?.revoked, + ).toBe(false); + + const result = await rotateRefreshToken({ + refreshToken: initial.refreshToken, + policy: NATIVE_POLICY, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const oldRow = dbState.refreshTokens[0]; + expect(oldRow.usedAt).not.toBeNull(); + expect(oldRow.replacedById).not.toBeNull(); + expect( + dbState.apiTokens.find((a) => a.tokenHash === oldHash)?.revoked, + ).toBe(true); + expect(dbState.refreshTokens).toHaveLength(2); + }); + + it("rejects + revokes family on reuse of consumed refresh token", async () => { + const initial = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + deviceId: "dev-1", + }); + const first = await rotateRefreshToken({ + refreshToken: initial.refreshToken, + policy: NATIVE_POLICY, + deviceId: "dev-1", + }); + expect(first.ok).toBe(true); + + // Replay the original token — must be rejected as `already_used` AND + // the replacement family must be revoked too (defence in depth). + const replay = await rotateRefreshToken({ + refreshToken: initial.refreshToken, + policy: NATIVE_POLICY, + deviceId: "dev-1", + }); + expect(replay.ok).toBe(false); + if (replay.ok) return; + expect(replay.reason).toBe("already_used"); + // All non-revoked refresh rows should now be revoked + const live = dbState.refreshTokens.filter((r) => r.revokedAt === null); + expect(live).toHaveLength(0); + }); + + it("rejects expired refresh tokens", async () => { + await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + }); + // Manually expire + dbState.refreshTokens[0].expiresAt = new Date(Date.now() - 1000); + const result = await rotateRefreshToken({ + refreshToken: "hlr_doesnt_matter", + policy: NATIVE_POLICY, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("not_found"); + }); + + it("rejects revoked refresh tokens", async () => { + const initial = await issueAccessAndRefresh({ + userId: "u1", + policy: NATIVE_POLICY, + source: "login.password", + }); + await revokeRefreshToken(initial.refreshToken); + const result = await rotateRefreshToken({ + refreshToken: initial.refreshToken, + policy: NATIVE_POLICY, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("revoked"); + }); +}); diff --git a/src/lib/auth/native-client.ts b/src/lib/auth/native-client.ts new file mode 100644 index 000000000..8d4028a94 --- /dev/null +++ b/src/lib/auth/native-client.ts @@ -0,0 +1,91 @@ +/** + * Native-client detection + token-policy resolver for v1.4 G4. + * + * Web browsers (User-Agent contains "Mozilla/") receive a 90-day Bearer + * token (the legacy behaviour). Native callers (iOS, iPadOS, n8n, + * Health-Connect, or any unrecognised UA) receive a short-lived 24-hour + * access token plus a rotating refresh token. + * + * UA spoofing risk: this is a defence-in-depth measure, not a security + * boundary — every issued token is still bound to the authenticated user + * via `createSession`. A web client that pretends to be native gets a + * shorter-lived token; that's strictly safer. A native client that + * pretends to be web only hurts itself (90d token without refresh). + * + * Explicit overrides: + * - `X-Client-Type: native` always forces native policy. + * - `X-Client-Type: web` always forces web policy. + */ + +export type ClientPolicy = "web" | "native"; + +export interface TokenPolicyDecision { + policy: ClientPolicy; + /** Lifetime of the issued ApiToken in days. */ + accessTokenDays: number; + /** Lifetime of the rotating refresh token in days (null for web). */ + refreshTokenDays: number | null; + /** Token name suffix to embed for traceability. */ + tokenLabel: string; +} + +const NATIVE_UA_PREFIXES = [ + "HealthLog-iOS", + "HealthLog-iPad", + "HealthLog-Watch", + "n8n", + "Health-Connect", +]; + +export function classifyClient(headers: Headers): ClientPolicy { + const explicit = headers.get("x-client-type")?.toLowerCase(); + if (explicit === "native") return "native"; + if (explicit === "web") return "web"; + + const ua = headers.get("user-agent") ?? ""; + // Match the brief: web UAs contain "Mozilla/" and keep the 90d token. + // Anything else (including blank UAs) is treated as native. + if (ua.includes("Mozilla/")) return "web"; + for (const prefix of NATIVE_UA_PREFIXES) { + if (ua.startsWith(prefix)) return "native"; + } + // Unrecognised UAs default to the safer (shorter) token. + return "native"; +} + +export function resolveTokenPolicy(headers: Headers): TokenPolicyDecision { + const policy = classifyClient(headers); + if (policy === "web") { + return { + policy, + accessTokenDays: 90, + refreshTokenDays: null, + tokenLabel: "web", + }; + } + return { + policy: "native", + accessTokenDays: 1, + refreshTokenDays: 60, + tokenLabel: "native", + }; +} + +/** True when the caller should receive a Bearer token in the response body. */ +export function shouldIssueBearerToken(headers: Headers): boolean { + // Explicit native opt-in OR a recognised native UA. Plain web sessions + // continue to authenticate via the session cookie alone (no token + // payload), which prevents the 90-day token from leaking out for the + // huge population of users who already have working browser auth. + const explicit = headers.get("x-client-type")?.toLowerCase(); + if (explicit === "native") return true; + if (explicit === "web") return false; + + const ua = headers.get("user-agent") ?? ""; + if (ua.startsWith("HealthLog-iOS") || ua.startsWith("HealthLog-iPad")) + return true; + for (const prefix of NATIVE_UA_PREFIXES) { + if (ua.startsWith(prefix)) return true; + } + return false; +} diff --git a/src/lib/auth/refresh-token.ts b/src/lib/auth/refresh-token.ts new file mode 100644 index 000000000..bbcfb6dc1 --- /dev/null +++ b/src/lib/auth/refresh-token.ts @@ -0,0 +1,224 @@ +/** + * Refresh-token issuance + rotation (v1.4 G4). + * + * One-time-use semantics: every successful refresh marks the consumed + * row's `usedAt`, sets `replacedById` to the new row, and revokes the + * paired access token. Reuse of an already-consumed token is treated as + * a stolen-token signal and revokes the entire token family (caller must + * log in again). + */ +import { randomBytes } from "node:crypto"; +import { prisma } from "@/lib/db"; +import { hashToken } from "@/lib/auth/hmac"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import type { TokenPolicyDecision } from "@/lib/auth/native-client"; + +export interface IssuedRefreshBundle { + accessToken: string; + accessTokenExpiresAt: Date; + refreshToken: string; + refreshTokenExpiresAt: Date; +} + +export interface IssueRefreshOpts { + userId: string; + policy: TokenPolicyDecision; + deviceId?: string | null; + userAgent?: string | null; + ipAddress?: string | null; + /** Token name suffix for the underlying ApiToken row. */ + source: string; +} + +function generateRefreshTokenSecret(): string { + return `hlr_${randomBytes(32).toString("hex")}`; +} + +/** + * Issue a fresh access token + refresh token pair. Used by: + * - login (password + passkey verify) for native callers + * - the /api/auth/refresh rotation endpoint + */ +export async function issueAccessAndRefresh( + opts: IssueRefreshOpts, +): Promise { + if (opts.policy.refreshTokenDays === null) { + throw new Error( + "issueAccessAndRefresh called for web policy (no refresh token)", + ); + } + + const access = await issueApiToken({ + userId: opts.userId, + name: `${opts.policy.tokenLabel} ${opts.source} ${new Date().toISOString()}`, + permissions: ["*"], + expiresInDays: opts.policy.accessTokenDays, + }); + + const refresh = generateRefreshTokenSecret(); + const refreshHash = hashToken(refresh); + const accessTokenHash = hashToken(access.token); + const expiresAt = new Date( + Date.now() + opts.policy.refreshTokenDays * 24 * 60 * 60 * 1000, + ); + + await prisma.refreshToken.create({ + data: { + userId: opts.userId, + tokenHash: refreshHash, + accessTokenHash, + deviceId: opts.deviceId ?? null, + expiresAt, + userAgent: opts.userAgent ?? null, + ipAddress: opts.ipAddress ?? null, + }, + }); + + return { + accessToken: access.token, + accessTokenExpiresAt: access.expiresAt, + refreshToken: refresh, + refreshTokenExpiresAt: expiresAt, + }; +} + +export type RotationFailureReason = + | "not_found" + | "expired" + | "already_used" + | "revoked"; + +export type RotationResult = + | { ok: true; bundle: IssuedRefreshBundle } + | { ok: false; reason: RotationFailureReason }; + +/** + * Atomically rotate a refresh token: validate, mark consumed, issue a new + * pair, revoke the previously-paired access token. Reuse of a consumed + * token revokes the whole family (defence against stolen refresh tokens). + */ +export async function rotateRefreshToken(input: { + refreshToken: string; + policy: TokenPolicyDecision; + deviceId?: string | null; + userAgent?: string | null; + ipAddress?: string | null; +}): Promise { + const hash = hashToken(input.refreshToken); + const row = await prisma.refreshToken.findUnique({ + where: { tokenHash: hash }, + }); + + if (!row) return { ok: false, reason: "not_found" }; + if (row.revokedAt) return { ok: false, reason: "revoked" }; + if (row.expiresAt.getTime() <= Date.now()) + return { ok: false, reason: "expired" }; + + if (row.usedAt) { + // Reuse-detection: a previously-consumed refresh token shouldn't be + // presented again. Treat as compromise. Defence-in-depth justifies the + // user-wide blast radius: an attacker who stole the token could rotate + // without an X-Device-Id header (or with a different one); device-scoped + // revocation would leave the attacker's family alive on a "no-device" or + // different-device branch. The legitimate user logging back in is the + // small price; an undetected stolen-token replay is the bigger problem. + const where = { userId: row.userId, revokedAt: null }; + const compromised = await prisma.refreshToken.findMany({ where }); + await prisma.refreshToken.updateMany({ + where, + data: { revokedAt: new Date() }, + }); + const accessHashes = compromised + .map((c) => c.accessTokenHash) + .filter((v): v is string => Boolean(v)); + if (accessHashes.length > 0) { + await prisma.apiToken.updateMany({ + where: { tokenHash: { in: accessHashes } }, + data: { revoked: true }, + }); + } + return { ok: false, reason: "already_used" }; + } + + // Issue the new pair first, THEN mark old consumed in a transaction. + // (Race: if two concurrent refreshes hit the same row, both will try to + // mark `usedAt`. We use updateMany with a `usedAt: null` guard so only + // one wins; the loser's new token row is orphaned but harmless because + // the loser's access token will be revoked alongside it.) + const bundle = await issueAccessAndRefresh({ + userId: row.userId, + policy: input.policy, + deviceId: row.deviceId ?? input.deviceId ?? null, + userAgent: input.userAgent ?? row.userAgent, + ipAddress: input.ipAddress ?? row.ipAddress, + source: "refresh", + }); + + // Find the row we just created so we can store its id as replacedById. + const newHash = hashToken(bundle.refreshToken); + const newRow = await prisma.refreshToken.findUnique({ + where: { tokenHash: newHash }, + select: { id: true }, + }); + + const updated = await prisma.refreshToken.updateMany({ + where: { id: row.id, usedAt: null }, + data: { + usedAt: new Date(), + replacedById: newRow?.id ?? null, + }, + }); + + if (updated.count === 0) { + // Lost the race — another concurrent refresh consumed this row. + // Revoke our just-issued tokens to avoid leaking an extra valid pair. + await prisma.refreshToken.updateMany({ + where: { tokenHash: newHash }, + data: { revokedAt: new Date() }, + }); + await prisma.apiToken.updateMany({ + where: { tokenHash: hashToken(bundle.accessToken) }, + data: { revoked: true }, + }); + return { ok: false, reason: "already_used" }; + } + + // Best-effort: revoke the access token paired with the consumed refresh, + // so any leaked access token can't outlive its refresh-token sibling. + if (row.accessTokenHash) { + await prisma.apiToken.updateMany({ + where: { tokenHash: row.accessTokenHash, revoked: false }, + data: { revoked: true }, + }); + } + + return { ok: true, bundle }; +} + +/** Revoke a specific refresh token (logout-on-device). + * Also revokes the paired access token so a leaked access token cannot + * outlive the refresh-token sibling that the user just killed. */ +export async function revokeRefreshToken( + refreshToken: string, +): Promise { + const hash = hashToken(refreshToken); + const row = await prisma.refreshToken.findUnique({ + where: { tokenHash: hash }, + select: { accessTokenHash: true, revokedAt: true }, + }); + if (!row || row.revokedAt) return false; + + const result = await prisma.refreshToken.updateMany({ + where: { tokenHash: hash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + if (result.count === 0) return false; + + if (row.accessTokenHash) { + await prisma.apiToken.updateMany({ + where: { tokenHash: row.accessTokenHash, revoked: false }, + data: { revoked: true }, + }); + } + return true; +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index bbc1f5388..913871e9a 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,6 +1,19 @@ /** * AES-256-GCM encryption for sensitive data at rest. - * Used for Withings OAuth tokens and other secrets. + * + * v1.4: supports key versioning + rotation. Multiple keys can coexist via + * `ENCRYPTION_KEYS` (JSON map of `{ "": "" }`) plus + * `ENCRYPTION_ACTIVE_KEY_ID` selecting which one to write with. Backwards + * compatible: if `ENCRYPTION_KEYS` is absent, the legacy single + * `ENCRYPTION_KEY` is used with synthetic id `"v1"`. + * + * Ciphertext format: + * - Versioned (new): "." + * - Legacy (v1): "base64(iv|authTag|ciphertext)" — still readable. + * + * The decryption path tries the versioned format first; if no `.` is present + * (or the prefix is not a known key id), it falls back to the legacy single + * key so that rows written before the rotation continue to decrypt. */ import { createCipheriv, @@ -13,83 +26,231 @@ import { getEvent } from "@/lib/logging/context"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; +const LEGACY_KEY_ID = "v1"; -function getKey(): Buffer { - const raw = process.env.ENCRYPTION_KEY; - if (!raw) { - throw new Error("ENCRYPTION_KEY env var must be set"); - } +interface KeyMaterial { + id: string; + key: Buffer; +} - // Require a proper 64-char hex string (= 32 bytes = 256 bits) +let cachedKeys: Map | null = null; +let cachedActiveId: string | null = null; +let cachedSignature: string | null = null; + +function envSignature(): string { + return [ + process.env.ENCRYPTION_KEYS ?? "", + process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "", + process.env.ENCRYPTION_KEY ?? "", + ].join("|"); +} + +function decodeKey(raw: string, label: string): Buffer { if (/^[0-9a-fA-F]{64}$/.test(raw)) { return Buffer.from(raw, "hex"); } - - // Accept shorter hex keys (>= 32 chars) for dev convenience only. - // In production this is a hard failure — a short key materially weakens - // the AES-256-GCM guarantee even with SHA-256 padding. + // base64 form (32 bytes / 256 bits) + if (/^[A-Za-z0-9+/=]+$/.test(raw)) { + try { + const buf = Buffer.from(raw, "base64"); + if (buf.length === 32) return buf; + } catch { + // fall through + } + } if (/^[0-9a-fA-F]+$/.test(raw) && raw.length >= 32) { if (process.env.NODE_ENV === "production") { throw new Error( - `ENCRYPTION_KEY must be exactly 64 hex characters (256 bits) in production. Current length: ${raw.length}. Generate one with: openssl rand -hex 32`, + `Encryption key '${label}' must be 64 hex characters (256 bits) in production. ` + + `Generate one with: openssl rand -hex 32`, ); } - // Dev/test: route the warning through Wide Events so it surfaces in - // structured logs instead of a raw console.warn that gets swallowed. - const msg = `ENCRYPTION_KEY should be exactly 64 hex characters (current length: ${raw.length}). Padding with SHA-256 for dev use only.`; + const msg = `Encryption key '${label}' should be exactly 64 hex characters (current length: ${raw.length}). Padding with SHA-256 for dev use only.`; const event = getEvent(); - if (event) { - event.addWarning(msg); - } else if (typeof console !== "undefined") { - console.warn("[crypto]", msg); - } + if (event) event.addWarning(msg); + else if (typeof console !== "undefined") console.warn("[crypto]", msg); const padded = raw + createHash("sha256").update(raw, "utf8").digest("hex"); return Buffer.from(padded.slice(0, 64), "hex"); } - throw new Error( - "ENCRYPTION_KEY must be a hex string of at least 32 characters (64 recommended). " + - "Generate one with: openssl rand -hex 32", + `Encryption key '${label}' must be hex (>= 32 chars) or base64 (32 bytes). ` + + `Generate one with: openssl rand -hex 32`, ); } -/** - * Encrypt a plaintext string. - * Returns format: base64(iv + authTag + ciphertext) - */ -export function encrypt(plaintext: string): string { - const key = getKey(); +function loadKeys(): { keys: Map; activeId: string } { + const sig = envSignature(); + if (cachedKeys && cachedActiveId && cachedSignature === sig) { + return { keys: cachedKeys, activeId: cachedActiveId }; + } + + const keys = new Map(); + let activeId: string | null = null; + + const rawMap = process.env.ENCRYPTION_KEYS; + if (rawMap && rawMap.trim() !== "") { + let parsed: unknown; + try { + parsed = JSON.parse(rawMap); + } catch (err) { + throw new Error( + `ENCRYPTION_KEYS must be valid JSON (e.g. {"v2":""}): ${(err as Error).message}`, + ); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("ENCRYPTION_KEYS must be a JSON object map"); + } + for (const [id, value] of Object.entries( + parsed as Record, + )) { + if (typeof value !== "string" || !id) { + throw new Error(`ENCRYPTION_KEYS entry '${id}' is not a string`); + } + if (!/^[A-Za-z0-9_-]{1,32}$/.test(id)) { + throw new Error( + `ENCRYPTION_KEYS id '${id}' must match [A-Za-z0-9_-]{1,32}`, + ); + } + keys.set(id, decodeKey(value, id)); + } + + activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null; + if (!activeId) { + if (keys.size === 1) { + activeId = keys.keys().next().value as string; + } else { + throw new Error( + "ENCRYPTION_ACTIVE_KEY_ID must be set when ENCRYPTION_KEYS has multiple entries", + ); + } + } + if (!keys.has(activeId)) { + throw new Error( + `ENCRYPTION_ACTIVE_KEY_ID='${activeId}' has no matching entry in ENCRYPTION_KEYS`, + ); + } + } + + // Always also include the legacy single-key under id `v1` if set, so old + // rows decrypt. If the operator supplies the same id explicitly, that wins. + const legacyRaw = process.env.ENCRYPTION_KEY; + if (legacyRaw && legacyRaw.trim() !== "") { + if (!keys.has(LEGACY_KEY_ID)) { + keys.set(LEGACY_KEY_ID, decodeKey(legacyRaw, LEGACY_KEY_ID)); + } + if (!activeId) activeId = LEGACY_KEY_ID; + } + + if (keys.size === 0 || !activeId) { + throw new Error( + "Encryption is not configured. Set ENCRYPTION_KEY or ENCRYPTION_KEYS+ENCRYPTION_ACTIVE_KEY_ID.", + ); + } + + cachedKeys = keys; + cachedActiveId = activeId; + cachedSignature = sig; + return { keys, activeId }; +} + +function getActiveKey(): KeyMaterial { + const { keys, activeId } = loadKeys(); + return { id: activeId, key: keys.get(activeId)! }; +} + +function getKeyById(id: string): Buffer | null { + const { keys } = loadKeys(); + return keys.get(id) ?? null; +} + +/** Test helper — drops the cached key map so env stubs take effect. */ +export function _resetCryptoCacheForTests(): void { + cachedKeys = null; + cachedActiveId = null; + cachedSignature = null; +} + +export function getActiveKeyId(): string { + return getActiveKey().id; +} + +function encryptWithKey(plaintext: string, key: Buffer): string { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); + const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString("base64"); +} - const encrypted = Buffer.concat([ - cipher.update(plaintext, "utf8"), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); - - // Pack: iv (12) + authTag (16) + ciphertext - const packed = Buffer.concat([iv, authTag, encrypted]); - return packed.toString("base64"); +function decryptWithKey(payload: string, key: Buffer): string { + const packed = Buffer.from(payload, "base64"); + const iv = packed.subarray(0, IV_LENGTH); + const tag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ct = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const dec = createDecipheriv(ALGORITHM, key, iv); + dec.setAuthTag(tag); + return Buffer.concat([dec.update(ct), dec.final()]).toString("utf8"); } /** - * Decrypt a previously encrypted string. + * Encrypt a plaintext string with the active key. + * Returns format: `.` + */ +export function encrypt(plaintext: string): string { + const { id, key } = getActiveKey(); + return `${id}.${encryptWithKey(plaintext, key)}`; +} + +/** + * Decrypt a previously encrypted string. Accepts both the versioned + * `.` format and the legacy bare-base64 format. + * + * Once the prefix is recognised as a versioned id we trust the key id. + * If decrypt fails under that key the error surfaces — silently retrying as + * legacy would mask data corruption. */ export function decrypt(encoded: string): string { - const key = getKey(); - const packed = Buffer.from(encoded, "base64"); + const dot = encoded.indexOf("."); + if (dot > 0 && dot < encoded.length - 1) { + const id = encoded.slice(0, dot); + const payload = encoded.slice(dot + 1); + if (/^[A-Za-z0-9_-]{1,32}$/.test(id)) { + const key = getKeyById(id); + if (!key) { + throw new Error( + `Encryption key id '${id}' is not configured. Add it to ` + + `ENCRYPTION_KEYS before decrypting rows written under that key.`, + ); + } + return decryptWithKey(payload, key); + } + } + // Legacy bare-base64 row. Refuse to decrypt under the active key — that + // would give an opaque GCM tag error AND succeed silently with junk if a + // key collision ever occurs. Require the operator to keep the v1 key in + // `ENCRYPTION_KEYS` until rotation has fully drained legacy rows. + const legacy = getKeyById(LEGACY_KEY_ID); + if (!legacy) { + 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 " + + "removing it.", + ); + } + return decryptWithKey(encoded, legacy); +} - const iv = packed.subarray(0, IV_LENGTH); - const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); - const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH); +/** Re-encrypt a row with the currently active key. Used by the rotation CLI. */ +export function reencryptToActive(encoded: string): string { + return encrypt(decrypt(encoded)); +} - const decipher = createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - const decrypted = Buffer.concat([ - decipher.update(ciphertext), - decipher.final(), - ]); - return decrypted.toString("utf8"); +/** Returns the key id portion of a versioned ciphertext, or null for legacy. */ +export function extractKeyId(encoded: string): string | null { + const dot = encoded.indexOf("."); + if (dot <= 0 || dot >= encoded.length - 1) return null; + const id = encoded.slice(0, dot); + if (!/^[A-Za-z0-9_-]{1,32}$/.test(id)) return null; + return id; } diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 067a2bc00..49d0473cf 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -188,7 +188,9 @@ export function withIdempotency< Args extends [Request | NextRequest, ...unknown[]], >( handler: (...args: Args) => Promise, - userIdResolver: (...args: Args) => Promise = defaultUserIdResolver, + userIdResolver: ( + ...args: Args + ) => Promise = defaultUserIdResolver, ): (...args: Args) => Promise { return async (...args: Args): Promise => { const request = args[0]; @@ -217,11 +219,12 @@ export function withIdempotency< if (isCachableStatus(response.status)) { // Defence-in-depth: never persist a body that carries a freshly-issued - // bearer token. Auth routes shouldn't be wrapped in withIdempotency to - // begin with, but if a future caller forgets, we refuse to leak. + // bearer or refresh token. Auth routes shouldn't be wrapped in + // withIdempotency to begin with, but if a future caller forgets we + // refuse to leak. `hlk_` = access tokens, `hlr_` = refresh tokens. const cloned = response.clone(); const text = await cloned.text(); - if (!text.includes("hlk_")) { + if (!text.includes("hlk_") && !text.includes("hlr_")) { await persistCached(ctx, response, text); } } diff --git a/src/lib/jobs/__tests__/offhost-backup.test.ts b/src/lib/jobs/__tests__/offhost-backup.test.ts new file mode 100644 index 000000000..1682f46c2 --- /dev/null +++ b/src/lib/jobs/__tests__/offhost-backup.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + encryptBackup, + decryptBackup, + loadOffhostConfig, + runOffhostBackup, + runOffhostRoundtripTest, +} from "../offhost-backup"; + +const ENC_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("offhost-backup envelope", () => { + it("encrypts and decrypts JSON round-trip", () => { + const key = Buffer.from(ENC_KEY, "hex"); + const payload = JSON.stringify({ hello: "world", n: 42 }); + const buf = encryptBackup(payload, key); + expect(buf.subarray(0, 5).toString("binary")).toBe("HLBK\x01"); + expect(decryptBackup(buf, key)).toBe(payload); + }); + + it("rejects tampered ciphertext", () => { + const key = Buffer.from(ENC_KEY, "hex"); + const buf = encryptBackup("data", key); + const tampered = Buffer.from(buf); + tampered[tampered.length - 1] ^= 0xff; + expect(() => decryptBackup(tampered, key)).toThrow(); + }); + + it("rejects bad magic / version", () => { + const key = Buffer.from(ENC_KEY, "hex"); + const buf = encryptBackup("data", key); + const bad = Buffer.from(buf); + bad[0] = 0; // corrupt magic + expect(() => decryptBackup(bad, key)).toThrow(/Invalid backup envelope/); + }); +}); + +describe("loadOffhostConfig", () => { + beforeEach(() => vi.unstubAllEnvs()); + + it("returns null if any of the required vars is missing", () => { + vi.stubEnv("BACKUP_S3_ENDPOINT", ""); + expect(loadOffhostConfig()).toBeNull(); + }); + + it("parses a complete config", () => { + vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example"); + vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups"); + vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA"); + vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret"); + vi.stubEnv("BACKUP_S3_REGION", "auto"); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY); + const cfg = loadOffhostConfig(); + expect(cfg).not.toBeNull(); + expect(cfg!.bucket).toBe("hl-backups"); + expect(cfg!.endpoint).toBe("https://r2.example"); + expect(cfg!.region).toBe("auto"); + expect(cfg!.encryptionKey.length).toBe(32); + expect(cfg!.retentionDays).toBe(30); + }); +}); + +function makeS3Mock() { + const store = new Map(); + return { + store, + putObject: vi.fn(async (k: string, b: Buffer) => { + store.set(k, Buffer.from(b)); + }), + getObject: vi.fn(async (k: string) => { + const v = store.get(k); + if (!v) throw new Error("not found"); + return v; + }), + headObject: vi.fn(async (k: string) => store.has(k)), + listObjects: vi.fn(async (prefix: string) => + Array.from(store.keys()) + .filter((k) => k.startsWith(prefix)) + .map((key) => ({ key })), + ), + deleteObject: vi.fn(async (k: string) => { + store.delete(k); + }), + }; +} + +describe("runOffhostBackup", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example"); + vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups"); + vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA"); + vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret"); + vi.stubEnv("BACKUP_S3_REGION", "auto"); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY); + }); + + it("uploads one object per user and never deletes existing ones", async () => { + const s3 = makeS3Mock(); + // Pre-seed an old object — the worker must NOT touch it. Retention is + // the bucket's lifecycle-policy job; the worker's IAM grant is + // intentionally PutObject + GetObject only. + s3.store.set("2020-01-01/user-old.json.enc", Buffer.from([0])); + + const prisma = { + user: { + findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), + }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + }; + + const now = new Date("2026-05-08T03:00:00Z"); + const report = await runOffhostBackup( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + s3, + now, + ); + expect(report.uploaded).toBe(2); + expect(report.failed).toBe(0); + expect(report.totalUsers).toBe(2); + expect(s3.store.has("2026-05-08/user-u1.json.enc")).toBe(true); + expect(s3.store.has("2026-05-08/user-u2.json.enc")).toBe(true); + // Stale object stays — worker has no DeleteObject side-effects. + expect(s3.store.has("2020-01-01/user-old.json.enc")).toBe(true); + expect(s3.deleteObject).not.toHaveBeenCalled(); + + const ct = s3.store.get("2026-05-08/user-u1.json.enc")!; + const decoded = decryptBackup(ct, Buffer.from(ENC_KEY, "hex")); + const parsed = JSON.parse(decoded); + expect(parsed.userId).toBe("u1"); + }); + + it("counts per-user failures without aborting the whole run", async () => { + const s3 = makeS3Mock(); + const prisma = { + user: { + findMany: vi.fn().mockResolvedValue([{ id: "u1" }, { id: "u2" }]), + }, + measurement: { + findMany: vi + .fn() + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error("db gone")), + }, + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const report = await runOffhostBackup( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma as any, + s3, + new Date("2026-05-08T00:00:00Z"), + ); + expect(report.uploaded).toBe(1); + expect(report.failed).toBe(1); + }); +}); + +describe("runOffhostRoundtripTest", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example"); + vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups"); + vi.stubEnv("BACKUP_S3_ACCESS_KEY", "AKIA"); + vi.stubEnv("BACKUP_S3_SECRET_KEY", "secret"); + vi.stubEnv("BACKUP_S3_REGION", "auto"); + vi.stubEnv("BACKUP_ENCRYPTION_KEY", ENC_KEY); + }); + + it("returns ok=true when the put+get round-trip succeeds", async () => { + const s3 = makeS3Mock(); + const r = await runOffhostRoundtripTest(s3); + expect(r.ok).toBe(true); + expect(r.bucket).toBe("hl-backups"); + expect(r.endpoint).toBe("https://r2.example"); + }); + + it("never leaks credentials in the returned report", async () => { + const s3 = makeS3Mock(); + const r = await runOffhostRoundtripTest(s3); + const json = JSON.stringify(r); + expect(json).not.toContain("AKIA"); + expect(json).not.toContain("secret"); + expect(json).not.toContain(ENC_KEY); + }); +}); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts new file mode 100644 index 000000000..4cde9d472 --- /dev/null +++ b/src/lib/jobs/offhost-backup.ts @@ -0,0 +1,319 @@ +/** + * Off-host encrypted backup uploader (v1.4 G1). + * + * Each user's daily JSON dump is encrypted with AES-256-GCM under a + * SEPARATE key (`BACKUP_ENCRYPTION_KEY`) so a leak of the application + * `ENCRYPTION_KEY` does NOT expose the off-host backups, and vice + * versa. Ciphertext is uploaded to an S3-compatible target (Cloudflare + * R2, AWS S3, MinIO, Backblaze B2 — anything that speaks the SigV4 + * protocol) using `@aws-sdk/client-s3`. + * + * Object key layout: + * //user-.json.enc + * + * Retention: the worker NEVER calls DeleteObject on backup keys. Operators + * MUST configure a bucket-level lifecycle rule (e.g. expire after + * `BACKUP_RETENTION_DAYS`). This keeps the IAM grant for the worker + * limited to PutObject + GetObject, so a compromised worker cannot wipe + * the backup history. See docs/ops/backup-restore.md. + */ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { getEvent } from "@/lib/logging/context"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +export interface OffhostBackupConfig { + endpoint: string; + bucket: string; + accessKey: string; + secretKey: string; + region: string; + encryptionKey: Buffer; + retentionDays: number; +} + +export class OffhostBackupNotConfiguredError extends Error { + constructor(message: string) { + super(message); + this.name = "OffhostBackupNotConfiguredError"; + } +} + +function decodeBackupKey(raw: string): Buffer { + if (/^[0-9a-fA-F]{64}$/.test(raw)) return Buffer.from(raw, "hex"); + if (/^[A-Za-z0-9+/=]+$/.test(raw)) { + const buf = Buffer.from(raw, "base64"); + if (buf.length === 32) return buf; + } + throw new Error( + "BACKUP_ENCRYPTION_KEY must be 64 hex chars or 32-byte base64", + ); +} + +export function loadOffhostConfig(): OffhostBackupConfig | null { + const endpoint = process.env.BACKUP_S3_ENDPOINT; + const bucket = process.env.BACKUP_S3_BUCKET; + const accessKey = process.env.BACKUP_S3_ACCESS_KEY; + const secretKey = process.env.BACKUP_S3_SECRET_KEY; + const encRaw = process.env.BACKUP_ENCRYPTION_KEY; + if (!endpoint || !bucket || !accessKey || !secretKey || !encRaw) return null; + + const retentionDays = (() => { + const raw = process.env.BACKUP_RETENTION_DAYS; + if (!raw) return 30; + const v = parseInt(raw, 10); + return Number.isFinite(v) && v >= 1 ? v : 30; + })(); + + return { + endpoint, + bucket, + accessKey, + secretKey, + region: process.env.BACKUP_S3_REGION ?? "auto", + encryptionKey: decodeBackupKey(encRaw), + retentionDays, + }; +} + +/** + * Encrypt a JSON payload with the dedicated backup key. + * + * Wire format (binary): + * magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext + */ +export function encryptBackup(plaintext: string, key: Buffer): Buffer { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([Buffer.from("HLBK\x01", "binary"), iv, tag, ct]); +} + +export function decryptBackup(buf: Buffer, key: Buffer): string { + const magic = buf.subarray(0, 4).toString("binary"); + const version = buf[4]; + if (magic !== "HLBK" || version !== 0x01) { + throw new Error("Invalid backup envelope (bad magic or version)"); + } + const iv = buf.subarray(5, 5 + IV_LENGTH); + const tag = buf.subarray(5 + IV_LENGTH, 5 + IV_LENGTH + TAG_LENGTH); + const ct = buf.subarray(5 + IV_LENGTH + TAG_LENGTH); + const dec = createDecipheriv(ALGORITHM, key, iv); + dec.setAuthTag(tag); + return Buffer.concat([dec.update(ct), dec.final()]).toString("utf8"); +} + +interface S3Like { + putObject(key: string, body: Buffer | Uint8Array): Promise; + getObject(key: string): Promise; + headObject(key: string): Promise; + listObjects( + prefix: string, + ): Promise>; + deleteObject(key: string): Promise; +} + +export async function getS3Client(cfg: OffhostBackupConfig): Promise { + // Dynamic import so unit tests + dev environments without the SDK don't fail. + const mod = (await import("@aws-sdk/client-s3").catch((err) => { + throw new Error( + `@aws-sdk/client-s3 is not installed (${(err as Error).message}). ` + + `Run: pnpm add @aws-sdk/client-s3`, + ); + })) as typeof import("@aws-sdk/client-s3"); + + const client = new mod.S3Client({ + endpoint: cfg.endpoint, + region: cfg.region, + forcePathStyle: true, + credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey }, + }); + + const collect = async (stream: unknown): Promise => { + const chunks: Buffer[] = []; + for await (const c of stream as AsyncIterable) { + chunks.push(Buffer.from(c as Uint8Array)); + } + return Buffer.concat(chunks); + }; + + return { + putObject: async (key, body) => { + await client.send( + new mod.PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: "application/octet-stream", + }), + ); + }, + getObject: async (key) => { + const out = await client.send( + new mod.GetObjectCommand({ Bucket: cfg.bucket, Key: key }), + ); + return collect(out.Body); + }, + headObject: async (key) => { + try { + await client.send( + new mod.HeadObjectCommand({ Bucket: cfg.bucket, Key: key }), + ); + return true; + } catch { + return false; + } + }, + listObjects: async (prefix) => { + const out = await client.send( + new mod.ListObjectsV2Command({ Bucket: cfg.bucket, Prefix: prefix }), + ); + return (out.Contents ?? []).map((c) => ({ + key: c.Key ?? "", + lastModified: c.LastModified, + })); + }, + deleteObject: async (key) => { + await client.send( + new mod.DeleteObjectCommand({ Bucket: cfg.bucket, Key: key }), + ); + }, + }; +} + +interface BackupRunReport { + config: { endpoint: string; bucket: string; region: string }; + uploaded: number; + failed: number; + failures: Array<{ userId: string; message: string }>; + totalUsers: number; +} + +export async function runOffhostBackup( + prisma: PrismaClient, + s3Override?: S3Like, + now: Date = new Date(), +): Promise { + const cfg = loadOffhostConfig(); + if (!cfg) { + throw new OffhostBackupNotConfiguredError( + "Off-host backup not configured. Set BACKUP_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY and BACKUP_ENCRYPTION_KEY.", + ); + } + const s3 = s3Override ?? (await getS3Client(cfg)); + const dateKey = now.toISOString().slice(0, 10); + + const users = await prisma.user.findMany({ select: { id: true } }); + let uploaded = 0; + let failed = 0; + const failures: Array<{ userId: string; message: string }> = []; + const evt = getEvent(); + for (const user of users) { + try { + const [measurements, medications, intakeEvents, moodEntries] = + await Promise.all([ + prisma.measurement.findMany({ where: { userId: user.id } }), + prisma.medication.findMany({ + where: { userId: user.id }, + include: { schedules: true }, + }), + prisma.medicationIntakeEvent.findMany({ where: { userId: user.id } }), + prisma.moodEntry.findMany({ where: { userId: user.id } }), + ]); + + const payload = JSON.stringify({ + exportedAt: now.toISOString(), + userId: user.id, + measurements, + medications, + intakeEvents, + moodEntries, + }); + + const ciphertext = encryptBackup(payload, cfg.encryptionKey); + const key = `${dateKey}/user-${user.id}.json.enc`; + await s3.putObject(key, ciphertext); + uploaded++; + } catch (err) { + failed++; + const message = (err as Error).message ?? "unknown"; + failures.push({ userId: user.id, message: message.slice(0, 200) }); + // Surface per-user failure detail so an operator can tell WHICH user + // failed and WHY without scraping stdout. + evt?.addWarning( + `offhost-backup user ${user.id} failed: ${message.slice(0, 200)}`, + ); + } + } + + return { + config: { + endpoint: cfg.endpoint, + bucket: cfg.bucket, + region: cfg.region, + }, + uploaded, + failed, + failures, + totalUsers: users.length, + }; +} + +export interface RoundtripReport { + endpoint: string; + bucket: string; + region: string; + putLatencyMs: number; + getLatencyMs: number; + ok: boolean; + error?: string; +} + +/** + * Test-button helper: write + read a tiny object so the admin UI can + * confirm the bucket + creds work. Never returns the credentials. + */ +export async function runOffhostRoundtripTest( + s3Override?: S3Like, +): Promise { + const cfg = loadOffhostConfig(); + if (!cfg) { + throw new OffhostBackupNotConfiguredError( + "Off-host backup is not configured.", + ); + } + const s3 = s3Override ?? (await getS3Client(cfg)); + const key = `_healthcheck/${Date.now()}.bin`; + const body = Buffer.from([0x42]); + const t0 = Date.now(); + try { + await s3.putObject(key, body); + const putLatencyMs = Date.now() - t0; + const t1 = Date.now(); + const got = await s3.getObject(key); + const getLatencyMs = Date.now() - t1; + await s3.deleteObject(key).catch(() => {}); + return { + endpoint: cfg.endpoint, + bucket: cfg.bucket, + region: cfg.region, + putLatencyMs, + getLatencyMs, + ok: got.length === 1 && got[0] === 0x42, + }; + } catch (err) { + return { + endpoint: cfg.endpoint, + bucket: cfg.bucket, + region: cfg.region, + putLatencyMs: -1, + getLatencyMs: -1, + ok: false, + error: (err as Error).message, + }; + } +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index f07e67544..3b5f1624c 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -41,6 +41,8 @@ import { getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; import { withBackgroundEvent } from "@/lib/logging/background"; +import { assertSubsystemEnabled } from "@/lib/process-type"; +import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; import { getUserTodayBounds as getUserTodayBoundsUtil, getDayOfWeekInTz as getDayOfWeekInTzUtil, @@ -97,6 +99,12 @@ const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup"; const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin) const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup"; const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin) +const OFFHOST_BACKUP_QUEUE = "data-backup-offhost"; +// 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old +// rows are gone before they're snapshotted, but before the existing +// in-DB DATA_BACKUP at 03:00 (Sundays only) so the off-host copy is +// always at-or-ahead of the local one. +const OFFHOST_BACKUP_CRON = "30 2 * * *"; interface ReminderCheckPayload { triggeredAt: string; @@ -156,6 +164,10 @@ interface AuditLogCleanupPayload { triggeredAt: string; } +interface OffhostBackupPayload { + triggeredAt: string; +} + // Re-export timezone utilities under local names for backward compatibility const getUserTodayBounds = getUserTodayBoundsUtil; const getDayOfWeekInTz = getDayOfWeekInTzUtil; @@ -903,6 +915,32 @@ async function handleAuditLogCleanup(jobs: Job[]) { }); } +async function handleOffhostBackup(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.offhost_backup", async (evt) => { + const p = getWorkerPrisma(); + try { + const report = await runOffhostBackup(p); + evt.addMeta("offhost_backup_uploaded", report.uploaded); + evt.addMeta("offhost_backup_failed", report.failed); + evt.addMeta("offhost_backup_total_users", report.totalUsers); + evt.addMeta("offhost_backup_endpoint", report.config.endpoint); + evt.addMeta("offhost_backup_bucket", report.config.bucket); + // Per-user failure detail is also emitted as warnings inside + // runOffhostBackup; echo a structured digest for at-a-glance triage. + if (report.failures.length > 0) { + evt.addMeta( + "offhost_backup_failures", + JSON.stringify(report.failures.slice(0, 10)), + ); + } + } catch (err) { + // Not configured ⇒ skip silently with a warning, not an error. + evt.addWarning(`offhost-backup skipped/failed: ${err}`); + } + }); +} + async function handleDataBackup(jobs: Job[]) { void jobs; await withBackgroundEvent("job.data_backup", async (evt) => { @@ -1030,6 +1068,10 @@ function workerLog(level: "info" | "error", msg: string, err?: unknown): void { } export async function startReminderWorker() { + // v1.4 G3: refuse to boot if the operator marked this container as + // web-only via HEALTHLOG_PROCESS_TYPE. + assertSubsystemEnabled("worker"); + if (!DATABASE_URL) { workerLog("error", "CRITICAL: DATABASE_URL is not set, refusing to start"); return; @@ -1113,6 +1155,7 @@ export async function startReminderWorker() { RATE_LIMIT_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_QUEUE, + OFFHOST_BACKUP_QUEUE, ]; for (const q of allQueues) { @@ -1134,6 +1177,7 @@ export async function startReminderWorker() { [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON], [IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON], [AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON], + [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], ]; for (const [name, cron] of schedules) { @@ -1211,6 +1255,11 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleAuditLogCleanup, ); + await boss.work( + OFFHOST_BACKUP_QUEUE, + { localConcurrency: 1 }, + handleOffhostBackup, + ); return boss; } diff --git a/src/lib/process-type.ts b/src/lib/process-type.ts new file mode 100644 index 000000000..efb62074b --- /dev/null +++ b/src/lib/process-type.ts @@ -0,0 +1,61 @@ +/** + * Process-type gate for the v1.4 web/worker split. + * + * The same Docker image runs both the Next.js HTTP server and the pg-boss + * worker. Operators set `HEALTHLOG_PROCESS_TYPE` to declare what THIS + * container is supposed to be: + * + * - `all` (default) — both web + worker in one container (legacy). + * - `web` — HTTP only; the worker MUST run elsewhere. + * - `worker` — pg-boss only; the HTTP server MUST run elsewhere. + * + * A safety gate refuses to start the wrong subsystem so split deployments + * don't accidentally double-run reminder workers (which would still be + * safe due to pg-boss claim semantics, but doubles DB load and confuses + * telemetry). + */ + +export type ProcessType = "web" | "worker" | "all"; + +export function getProcessType(): ProcessType { + const raw = process.env.HEALTHLOG_PROCESS_TYPE; + // Treat empty string and unset alike — the docker-compose default + // interpolation gives an empty string when the var isn't set. + if (!raw || raw.trim() === "") return "all"; + const normalized = raw.toLowerCase(); + if (normalized === "web" || normalized === "worker" || normalized === "all") { + return normalized; + } + throw new Error( + `Invalid HEALTHLOG_PROCESS_TYPE='${raw}'. Expected: web | worker | all.`, + ); +} + +export function shouldRunWeb(): boolean { + const t = getProcessType(); + return t === "web" || t === "all"; +} + +export function shouldRunWorker(): boolean { + const t = getProcessType(); + return t === "worker" || t === "all"; +} + +/** + * Throw when the calling subsystem is disabled by HEALTHLOG_PROCESS_TYPE. + * Used at the top of the worker entry-point so an accidental + * `HEALTHLOG_PROCESS_TYPE=web node reminder-worker.js` exits immediately + * instead of silently running queues twice. + */ +export function assertSubsystemEnabled(subsystem: "web" | "worker"): void { + if (subsystem === "web" && !shouldRunWeb()) { + throw new Error( + `Refusing to start web subsystem: HEALTHLOG_PROCESS_TYPE=${getProcessType()}`, + ); + } + if (subsystem === "worker" && !shouldRunWorker()) { + throw new Error( + `Refusing to start worker subsystem: HEALTHLOG_PROCESS_TYPE=${getProcessType()}`, + ); + } +} diff --git a/src/proxy.ts b/src/proxy.ts index 7d631c09f..2ecf2f895 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,7 +1,12 @@ import { NextResponse, type NextRequest } from "next/server"; +import { shouldRunWeb } from "@/lib/process-type"; /** * Paths that do NOT require a session cookie (public pages + external webhooks). + * + * `/api/version` is intentionally public — both the in-app About page and the + * compose healthcheck rely on it. `/api/health` stays public for the same + * reason. */ const PUBLIC_PATHS = [ "/auth/", @@ -11,6 +16,7 @@ const PUBLIC_PATHS = [ "/api/auth/passkey/login-options", "/api/auth/passkey/login-verify", "/api/health", + "/api/version", "/api/notifications/vapid", "/api/monitoring/", "/api/send", @@ -46,6 +52,20 @@ const LEGACY_REDIRECTS: Record = { export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; + // Worker-only container: refuse HTTP traffic with a clear hint instead of + // serving requests that would either crash (no DB pool ready for writes from + // the worker) or duplicate work that the dedicated web container is doing. + if (!shouldRunWeb()) { + return NextResponse.json( + { + data: null, + error: + "This container runs the worker only — point HTTP at the web service.", + }, + { status: 503, headers: { "X-HealthLog-Process-Type": "worker" } }, + ); + } + // 301 redirects for renamed routes const redirect = LEGACY_REDIRECTS[pathname]; if (redirect) { @@ -55,11 +75,20 @@ export function proxy(request: NextRequest) { // Demo mode: block all mutations except login if (process.env.DEMO_MODE === "true") { const method = request.method.toUpperCase(); - const isMutation = method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; + const isMutation = + method !== "GET" && method !== "HEAD" && method !== "OPTIONS"; const isApi = pathname.startsWith("/api/"); - if (isApi && isMutation && !DEMO_MUTATION_ALLOWLIST.some((p) => pathname === p)) { + if ( + isApi && + isMutation && + !DEMO_MUTATION_ALLOWLIST.some((p) => pathname === p) + ) { return NextResponse.json( - { data: null, error: "Demo mode: modifications are disabled", meta: { demo: true } }, + { + data: null, + error: "Demo mode: modifications are disabled", + meta: { demo: true }, + }, { status: 403 }, ); } @@ -77,15 +106,14 @@ export function proxy(request: NextRequest) { } // Generate or propagate x-request-id for request correlation - const requestId = - request.headers.get("x-request-id") || crypto.randomUUID(); + const requestId = request.headers.get("x-request-id") || crypto.randomUUID(); // 128 bits of random, base64-encoded. randomUUID().toString() only carries // ~122 bits and has a predictable structure that base64-encodes to a // partially guessable pattern. - const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString( - "base64", - ); + const nonce = Buffer.from( + crypto.getRandomValues(new Uint8Array(16)), + ).toString("base64"); const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set("x-request-id", requestId);