mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(v1.4): multi-tenant prep — off-host backup, encryption-key versioning, worker/web split, native-Bearer UA gating (#135)
* feat(crypto): G2 — encryption-key versioning + rotation CLI
Adds backwards-compatible support for ENCRYPTION_KEYS (JSON map of
{ "id": "<key>" }) plus ENCRYPTION_ACTIVE_KEY_ID. Ciphertext format is now
"<keyId>.<base64payload>"; 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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=<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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e74612efc
commit
d7778c1085
33 files changed
+3983
-129
No files matched your search
@@ -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 <new-id>` to re-encrypt every
|
||||
# existing row under the new key. See docs/ops/encryption-key-rotation.md.
|
||||
# ENCRYPTION_KEYS='{"v1":"<old-key>","v2":"<new-key>"}'
|
||||
# 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 <userId> <YYYY-MM-DD>`.
|
||||
# See docs/ops/backup-restore.md for the format spec + step-by-step.
|
||||
# BACKUP_S3_ENDPOINT="https://<account>.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
|
||||
+6
-1
@@ -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
|
||||
|
||||
+57
-11
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
<bucket>/YYYY-MM-DD/user-<userId>.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://<account>.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).
|
||||
@@ -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) | `<keyId>.<base64(iv \|\| tag \|\| ciphertext)>` | All new writes |
|
||||
| Legacy (v1.3.x) | `<base64(iv \|\| tag \|\| ciphertext)>` | Existing rows until rotated |
|
||||
|
||||
`<keyId>` 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="<old key, unchanged>"
|
||||
ENCRYPTION_KEYS='{"v1":"<old key>","v2":"<new key>"}'
|
||||
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":"<new key>"}'
|
||||
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":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
Generated
+1193
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* scripts/restore-backup.ts <s3-key> [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=<hex64> \
|
||||
* 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<string, unknown>;
|
||||
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<typeof validateBackupShape>;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const key = process.argv[2];
|
||||
const out = process.argv[3] ?? "./restored.json";
|
||||
// Optional positional arg: --user-id=<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 <s3-key> [output-file] [--user-id=<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);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* scripts/rotate-encryption-key.ts <new-key-id>
|
||||
*
|
||||
* 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":"<old>","v2":"<new>"}' \
|
||||
* 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<T extends { id: string }>(
|
||||
table: string,
|
||||
field: string,
|
||||
rows: T[],
|
||||
fieldGetter: (row: T) => string | null,
|
||||
updater: (id: string, ciphertext: string) => Promise<void>,
|
||||
): Promise<RotationResult> {
|
||||
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<keyof (typeof users)[number]> = [
|
||||
"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<string, unknown>)[field as string] as string | null,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { [field]: ciphertext } as Record<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
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<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
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<string, string>,
|
||||
});
|
||||
},
|
||||
);
|
||||
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<string, string>,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, unknown>>(request);
|
||||
const { data: body, error: jsonError } =
|
||||
await safeJson<Record<string, unknown>>(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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.<base64>"
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
classifyClient,
|
||||
resolveTokenPolicy,
|
||||
shouldIssueBearerToken,
|
||||
} from "../native-client";
|
||||
|
||||
function h(pairs: Record<string, string>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> }) => {
|
||||
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<string, unknown> }) => {
|
||||
return dbState.refreshTokens.filter((r) =>
|
||||
Object.entries(where).every(
|
||||
([k, v]) => (r as unknown as Record<string, unknown>)[k] === v,
|
||||
),
|
||||
);
|
||||
}),
|
||||
updateMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
let count = 0;
|
||||
for (const row of dbState.refreshTokens) {
|
||||
const matches = Object.entries(where).every(
|
||||
([k, v]) => (row as unknown as Record<string, unknown>)[k] === v,
|
||||
);
|
||||
if (matches) {
|
||||
Object.assign(row, data);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return { count };
|
||||
},
|
||||
),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<IssuedRefreshBundle> {
|
||||
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<RotationResult> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
+213
-52
@@ -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 `{ "<id>": "<base64-or-hex-key>" }`) 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): "<keyId>.<base64(iv|authTag|ciphertext)>"
|
||||
* - 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<string, Buffer> | 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<string, Buffer>; activeId: string } {
|
||||
const sig = envSignature();
|
||||
if (cachedKeys && cachedActiveId && cachedSignature === sig) {
|
||||
return { keys: cachedKeys, activeId: cachedActiveId };
|
||||
}
|
||||
|
||||
const keys = new Map<string, Buffer>();
|
||||
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":"<hex>"}): ${(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<string, unknown>,
|
||||
)) {
|
||||
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: `<keyId>.<base64(iv+authTag+ciphertext)>`
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
const { id, key } = getActiveKey();
|
||||
return `${id}.${encryptWithKey(plaintext, key)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a previously encrypted string. Accepts both the versioned
|
||||
* `<id>.<payload>` 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;
|
||||
}
|
||||
@@ -188,7 +188,9 @@ export function withIdempotency<
|
||||
Args extends [Request | NextRequest, ...unknown[]],
|
||||
>(
|
||||
handler: (...args: Args) => Promise<Response>,
|
||||
userIdResolver: (...args: Args) => Promise<string | null> = defaultUserIdResolver,
|
||||
userIdResolver: (
|
||||
...args: Args
|
||||
) => Promise<string | null> = defaultUserIdResolver,
|
||||
): (...args: Args) => Promise<Response> {
|
||||
return async (...args: Args): Promise<Response> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, Buffer>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
* <bucket>/<YYYY-MM-DD>/user-<userId>.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<void>;
|
||||
getObject(key: string): Promise<Buffer>;
|
||||
headObject(key: string): Promise<boolean>;
|
||||
listObjects(
|
||||
prefix: string,
|
||||
): Promise<Array<{ key: string; lastModified?: Date }>>;
|
||||
deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export async function getS3Client(cfg: OffhostBackupConfig): Promise<S3Like> {
|
||||
// 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<Buffer> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream as AsyncIterable<Uint8Array>) {
|
||||
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<BackupRunReport> {
|
||||
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<RoundtripReport> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AuditLogCleanupPayload>[]) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOffhostBackup(jobs: Job<OffhostBackupPayload>[]) {
|
||||
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<DataBackupPayload>[]) {
|
||||
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<OffhostBackupPayload>(
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleOffhostBackup,
|
||||
);
|
||||
|
||||
return boss;
|
||||
}
|
||||
|
||||
@@ -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()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+36
-8
@@ -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<string, string> = {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user