Files
HealthLog/src/lib/idempotency.ts
T
Marc-André BombeckandClaude Opus 4.7 b9ade5b519 fix(api): address review findings — N+1, idempotency cache safety, device hijack guard
Three fixes from the 5-way review on PR #117:

1. Insights cards: replace per-medication N+1 query with single batched
   findMany + JS bucketing. For users with many active medications this
   collapses 10+ sequential round-trips into one.

2. Idempotency cache: only persist 2xx/3xx and validation 4xx responses.
   401/403/408/429/5xx are now skipped — a stale 401 (token expired
   mid-flight) or transient 5xx must not be replayed for 24h. Adds a
   defence-in-depth check that refuses to cache any body containing
   "hlk_" so a future caller wrapping an auth route in withIdempotency
   cannot leak a freshly-issued bearer token through the cache.

3. Devices: reject cross-user token re-registration with 409. Prior
   behaviour silently transferred ownership on findUnique-by-token,
   which would let anyone who learns/guesses an APNs token redirect
   another user's pushes. Audit-logs the denial with reason.

Tests adjusted: device-hijack test now expects 409, plus a new test
for same-user re-registration. 279/279 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:08:53 +02:00

174 lines
5.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 { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
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.
});
}
/**
* 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.
*
* 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>,
): (...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);
// Cache only client-stable responses. Replaying a stale 401/403
// (token expired mid-flight) or a 5xx would lock the user into a
// bogus result for the TTL window. 4xx-validation responses are
// intentionally cached so the same broken request doesn't hit the
// DB twice — but auth/throttle/server faults must not poison.
const cachable =
response.status < 400 ||
(response.status >= 400 &&
response.status < 500 &&
response.status !== 401 &&
response.status !== 403 &&
response.status !== 408 &&
response.status !== 429);
if (cachable) {
// 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.
const cloned = response.clone();
const text = await cloned.text();
if (!text.includes("hlk_")) {
await persistCached(ctx, response, text);
}
}
return response;
};
}