Files
HealthLog/src/lib/jobs/offhost-backup.ts
T
Marc-André BombeckandMarc-André Bombeck d7778c1085 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>
2026-05-08 14:33:39 +02:00

320 lines
9.6 KiB
TypeScript

/**
* 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,
};
}
}