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:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 14:33:39 +02:00
1 parent 6e74612efc
commit d7778c1085
33 files changed
+3983 -129

No files matched your search

+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
+87
View File
@@ -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).
+107
View File
@@ -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.
+6
View File
@@ -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";
+53
View File
@@ -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.
+1
View File
@@ -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",
+1193
View File
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;
+42
View File
@@ -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 {
+128
View File
@@ -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);
});
+246
View File
@@ -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);
});
+36
View File
@@ -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,
+61 -9
View File
@@ -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,
+63 -9
View File
@@ -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,
+101
View File
@@ -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(),
});
});
+4
View File
@@ -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");
+100 -35
View File
@@ -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/);
});
});
+53
View File
@@ -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");
});
});
+91
View File
@@ -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;
}
+224
View File
@@ -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
View File
@@ -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;
}
+7 -4
View File
@@ -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);
});
});
+319
View File
@@ -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,
};
}
}
+49
View File
@@ -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;
}
+61
View File
@@ -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
View File
@@ -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);