mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
* 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>
235 lines
7.1 KiB
TypeScript
235 lines
7.1 KiB
TypeScript
/**
|
|
* Idempotency-Key support for write endpoints (POST/PUT/PATCH/DELETE).
|
|
*
|
|
* Mobile clients send `Idempotency-Key: <uuid>`. The first request runs
|
|
* the handler normally and the response (status + body) is cached. Any
|
|
* retry within the TTL (24h) for the same `(userId, key, method, path)`
|
|
* tuple returns the cached envelope as a 200 (carrying the original
|
|
* status inside the JSON if needed) — no second side-effect.
|
|
*/
|
|
import type { NextRequest } from "next/server";
|
|
import { headers } from "next/headers";
|
|
import { NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/db";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
|
|
const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/;
|
|
|
|
export interface IdempotencyContext {
|
|
userId: string;
|
|
key: string;
|
|
method: string;
|
|
path: string;
|
|
}
|
|
|
|
function getIdempotencyKey(request: Request | NextRequest): string | null {
|
|
const raw = request.headers.get("idempotency-key");
|
|
if (!raw) return null;
|
|
const trimmed = raw.trim();
|
|
if (!KEY_REGEX.test(trimmed)) return null;
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* Look up a cached response for a (userId, key, method, path) tuple.
|
|
* Returns the cached NextResponse or null.
|
|
*/
|
|
async function findCached(
|
|
ctx: IdempotencyContext,
|
|
): Promise<NextResponse | null> {
|
|
const row = await prisma.idempotencyKey.findUnique({
|
|
where: {
|
|
userId_key_method_path: {
|
|
userId: ctx.userId,
|
|
key: ctx.key,
|
|
method: ctx.method,
|
|
path: ctx.path,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!row) return null;
|
|
if (row.expiresAt <= new Date()) {
|
|
// Stale — purge and fall through.
|
|
await prisma.idempotencyKey
|
|
.delete({ where: { id: row.id } })
|
|
.catch(() => {});
|
|
return null;
|
|
}
|
|
|
|
let parsed: unknown = null;
|
|
try {
|
|
parsed = JSON.parse(row.responseBody);
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
|
|
annotate({
|
|
action: { name: "idempotency.replay" },
|
|
meta: { method: ctx.method, path: ctx.path },
|
|
});
|
|
|
|
// Replay original status to keep client behaviour byte-identical.
|
|
return NextResponse.json(parsed, {
|
|
status: row.responseStatus,
|
|
headers: {
|
|
"X-Idempotent-Replay": "true",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function persistCached(
|
|
ctx: IdempotencyContext,
|
|
response: Response,
|
|
preReadBody?: string,
|
|
): Promise<void> {
|
|
const body =
|
|
preReadBody ??
|
|
(await response
|
|
.clone()
|
|
.text()
|
|
.catch(() => ""));
|
|
|
|
await prisma.idempotencyKey
|
|
.create({
|
|
data: {
|
|
userId: ctx.userId,
|
|
key: ctx.key,
|
|
method: ctx.method,
|
|
path: ctx.path,
|
|
responseStatus: response.status,
|
|
responseBody: body,
|
|
expiresAt: new Date(Date.now() + TTL_MS),
|
|
},
|
|
})
|
|
.catch(() => {
|
|
// Concurrent insert with the same key — the other writer wins.
|
|
// Ignore because a future replay will hit their cached value.
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether a response with the given HTTP status should be cached for
|
|
* idempotent replay.
|
|
*
|
|
* Cached: any 2xx/3xx, plus 4xx-validation (so the same broken request
|
|
* doesn't re-execute side-effects). Specifically NOT cached:
|
|
* 401 — token may have expired between the original call and the retry
|
|
* 403 — likewise authorization can change
|
|
* 408 — caller timed out, retry deserves a fresh attempt
|
|
* 429 — caller hit a rate-limit, retry deserves a fresh window check
|
|
* 5xx — server fault, retry must not be locked into a bogus result
|
|
*
|
|
* Exported so the do-not-cache contract is unit-tested independently of
|
|
* the database-backed wrapper.
|
|
*/
|
|
export function isCachableStatus(status: number): boolean {
|
|
if (status < 400) return true;
|
|
if (status >= 500) return false;
|
|
if (status === 401 || status === 403 || status === 408 || status === 429) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Default resolver: cookie session first, then Bearer token. The Bearer
|
|
* fallback is what makes idempotency actually fire for native iOS / n8n
|
|
* /external clients — without it, every Bearer-authed retry was running
|
|
* the handler again and creating duplicate measurements (audit C-4).
|
|
*
|
|
* Exported for unit testing; production callers should let
|
|
* `withIdempotency()` pick this up automatically via its default arg.
|
|
*/
|
|
export async function defaultUserIdResolver(): Promise<string | null> {
|
|
const session = await getSession().catch(() => null);
|
|
if (session) return session.user.id;
|
|
|
|
let authHeader: string | null = null;
|
|
try {
|
|
const headerList = await headers();
|
|
authHeader = headerList.get("authorization");
|
|
} catch {
|
|
authHeader = null;
|
|
}
|
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
|
|
const tokenHashValue = hashToken(authHeader.slice(7));
|
|
const apiToken = await prisma.apiToken
|
|
.findUnique({
|
|
where: { tokenHash: tokenHashValue },
|
|
select: { userId: true, revoked: true, expiresAt: true },
|
|
})
|
|
.catch(() => null);
|
|
|
|
if (!apiToken || apiToken.revoked) return null;
|
|
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) return null;
|
|
return apiToken.userId;
|
|
}
|
|
|
|
/**
|
|
* Wrap a write handler so a repeat call with the same `Idempotency-Key`
|
|
* (and same userId/method/path) returns the originally cached response.
|
|
*
|
|
* The wrapped handler is responsible for authentication itself — this
|
|
* helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only
|
|
* once `userIdResolver` returns a non-null value. The default resolver
|
|
* supports both cookie sessions and Bearer-token clients; pass a custom
|
|
* resolver only for routes that authenticate via something exotic.
|
|
*
|
|
* No-op when the header is missing or the value is malformed.
|
|
*/
|
|
export function withIdempotency<
|
|
Args extends [Request | NextRequest, ...unknown[]],
|
|
>(
|
|
handler: (...args: Args) => Promise<Response>,
|
|
userIdResolver: (
|
|
...args: Args
|
|
) => Promise<string | null> = defaultUserIdResolver,
|
|
): (...args: Args) => Promise<Response> {
|
|
return async (...args: Args): Promise<Response> => {
|
|
const request = args[0];
|
|
if (!SUPPORTED_METHODS.has(request.method)) {
|
|
return handler(...args);
|
|
}
|
|
|
|
const key = getIdempotencyKey(request);
|
|
if (!key) return handler(...args);
|
|
|
|
const userId = await userIdResolver(...args);
|
|
if (!userId) return handler(...args);
|
|
|
|
const url = new URL(request.url);
|
|
const ctx: IdempotencyContext = {
|
|
userId,
|
|
key,
|
|
method: request.method,
|
|
path: url.pathname,
|
|
};
|
|
|
|
const cached = await findCached(ctx);
|
|
if (cached) return cached;
|
|
|
|
const response = await handler(...args);
|
|
|
|
if (isCachableStatus(response.status)) {
|
|
// Defence-in-depth: never persist a body that carries a freshly-issued
|
|
// 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_") && !text.includes("hlr_")) {
|
|
await persistCached(ctx, response, text);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
};
|
|
}
|