mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The `type` query parameter on GET /api/insights/derived carried the eleven
VITALS_BASELINE vitals and described itself as being for that metric only.
SAME_TIME_BASELINE takes a `type` too, and its four cumulative day metrics
were absent from the enum, so a client generated against the spec could not
send them at all. The parameter now publishes the union of both registry
sets and the description says which values belong to which metric. A guard
reads the enum back out of the assembled document and pins it to the
registry, so a type added to either engine reaches the contract with it.
The idempotency wrapper's two 409 arms sent `error` as `{ message }`. That
was the only object-shaped error the application emitted; every other route
sends the documented `{ data, error: <string> }` envelope, which is also
what the spec has always promised. The code was wrong, not the spec. Both
arms now build their response through one helper that sends the string, so
every route under withIdempotency serves the envelope its clients decode.
The hero score-ring comment still described MED_COMPLIANCE as pooled 7-day
adherence. It has been today's dose progress since v1.27.8.
459 lines
16 KiB
TypeScript
459 lines
16 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";
|
|
import { isP2002 } from "@/lib/prisma-errors";
|
|
import { decrypt, encrypt } from "@/lib/crypto";
|
|
|
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
/**
|
|
* Claim TTL — how long an in-flight "pending" row is honoured before a
|
|
* retry is allowed to re-run the handler. Bounds the blast radius of a
|
|
* crashed handler that never wrote its result: the key self-heals after
|
|
* this window instead of being locked for the full 24h response TTL.
|
|
* Sized above the longest realistic write-handler latency.
|
|
*/
|
|
const PENDING_TTL_MS = 2 * 60 * 1000;
|
|
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
|
|
/**
|
|
* Sentinel `responseStatus` for a claimed-but-not-yet-completed row. A
|
|
* real HTTP status is never 0, so `findCached` can distinguish an
|
|
* in-flight claim from a cached response without a schema change.
|
|
*/
|
|
const PENDING_STATUS = 0;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Outcome of a key lookup:
|
|
* - `{ kind: "replay" }` — a completed response is cached; replay it.
|
|
* - `{ kind: "pending" }` — another request holds an in-flight claim
|
|
* for this key; the caller must NOT run the
|
|
* handler again.
|
|
* - `null` — no live row; the caller may claim + run.
|
|
*/
|
|
type CacheLookup =
|
|
{ kind: "replay"; response: NextResponse } | { kind: "pending" } | null;
|
|
|
|
/**
|
|
* Look up the state of a (userId, key, method, path) tuple. Distinguishes
|
|
* a completed cached response (replay) from an in-flight claim (pending)
|
|
* via the `PENDING_STATUS` sentinel.
|
|
*/
|
|
async function findCached(ctx: IdempotencyContext): Promise<CacheLookup> {
|
|
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 (completed row past its TTL, or a crashed claim past the
|
|
// pending window) — purge and fall through so the retry re-runs.
|
|
await prisma.idempotencyKey
|
|
.delete({ where: { id: row.id } })
|
|
.catch(() => {});
|
|
return null;
|
|
}
|
|
|
|
if (row.responseStatus === PENDING_STATUS) {
|
|
// A concurrent request claimed this key and is still running its
|
|
// side-effect. Signal the caller to refuse rather than double-execute.
|
|
return { kind: "pending" };
|
|
}
|
|
|
|
let parsed: unknown = null;
|
|
try {
|
|
// Rows written before the body was encrypted are plaintext JSON. Those
|
|
// expire within the 24h replay window, so rather than migrate them, try
|
|
// ciphertext first and fall back to a direct parse; the fallback becomes
|
|
// dead within a day of deploy and is safe to remove after that.
|
|
parsed = JSON.parse(decryptCachedBody(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 {
|
|
kind: "replay",
|
|
response: NextResponse.json(parsed, {
|
|
status: row.responseStatus,
|
|
headers: {
|
|
"X-Idempotent-Replay": "true",
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Atomically claim a key by inserting a `PENDING_STATUS` row under the
|
|
* `(userId, key, method, path)` unique constraint BEFORE the handler
|
|
* runs. Returns `true` when this caller won the claim, `false` when a
|
|
* concurrent request already holds it (unique-constraint collision) so
|
|
* the wrapper can refuse with 409 instead of double-executing the
|
|
* side-effect. The claim carries a short `PENDING_TTL_MS` so a crashed
|
|
* handler self-heals.
|
|
*/
|
|
async function claimKey(ctx: IdempotencyContext): Promise<boolean> {
|
|
try {
|
|
await prisma.idempotencyKey.create({
|
|
data: {
|
|
userId: ctx.userId,
|
|
key: ctx.key,
|
|
method: ctx.method,
|
|
path: ctx.path,
|
|
responseStatus: PENDING_STATUS,
|
|
responseBody: "",
|
|
expiresAt: new Date(Date.now() + PENDING_TTL_MS),
|
|
},
|
|
});
|
|
return true;
|
|
} catch (err) {
|
|
// Another request inserted the claim first — refuse this one.
|
|
if (isP2002(err)) return false;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release a claim that produced a non-cachable result (or threw) so a
|
|
* later retry gets a fresh attempt rather than a stuck pending row.
|
|
*/
|
|
async function releaseClaim(ctx: IdempotencyContext): Promise<void> {
|
|
await prisma.idempotencyKey
|
|
.deleteMany({
|
|
where: {
|
|
userId: ctx.userId,
|
|
key: ctx.key,
|
|
method: ctx.method,
|
|
path: ctx.path,
|
|
responseStatus: PENDING_STATUS,
|
|
},
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Encrypt a cached response body at rest.
|
|
*
|
|
* The replay cache stores the response verbatim, and the PHI-returning creates
|
|
* echo their own decrypted DTO — cycle day-log free text, reproductive-intent
|
|
* fields, mood notes, allergy reactions. That is exactly what the `*Encrypted`
|
|
* columns exist to protect, and it was sitting in cleartext for 24 hours in a
|
|
* column that lands in every backup. The secret-shaped-body guard did not catch
|
|
* it because health data is not secret-SHAPED.
|
|
*/
|
|
function encryptCachedBody(body: string): string | null {
|
|
if (body.length === 0) return body;
|
|
try {
|
|
return encrypt(body);
|
|
} catch {
|
|
// The crypto loader is deliberately fail-closed: no key, malformed key map
|
|
// or unknown key id all throw rather than silently writing plaintext. The
|
|
// replay cache is an accelerator, not a correctness guarantee, so the right
|
|
// response is to skip caching — never to store the body unprotected, and
|
|
// never to fail the caller's write over a cache we could not populate.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read a cached body back, tolerating rows written before encryption.
|
|
*
|
|
* `decrypt` throws on anything that is not a well-formed envelope, so a legacy
|
|
* plaintext row falls through to being returned as-is. Those rows age out
|
|
* within the 24h replay window.
|
|
*/
|
|
function decryptCachedBody(stored: string): string {
|
|
if (stored.length === 0) return stored;
|
|
try {
|
|
return decrypt(stored);
|
|
} catch {
|
|
return stored;
|
|
}
|
|
}
|
|
|
|
async function persistCached(
|
|
ctx: IdempotencyContext,
|
|
response: Response,
|
|
preReadBody?: string,
|
|
): Promise<void> {
|
|
const body =
|
|
preReadBody ??
|
|
(await response
|
|
.clone()
|
|
.text()
|
|
.catch(() => ""));
|
|
|
|
const storedBody = encryptCachedBody(body);
|
|
if (storedBody === null) {
|
|
// Could not encrypt — drop the claim rather than cache the body in the
|
|
// clear. A later retry misses and re-runs the handler, which is the same
|
|
// behaviour as any other cache miss.
|
|
annotate({ meta: { idempotency_cache_skipped: "encryption_unavailable" } });
|
|
await releaseClaim(ctx);
|
|
return;
|
|
}
|
|
|
|
// Promote the claimed pending row to the completed response, extending
|
|
// the TTL from the short claim window to the full 24h replay window.
|
|
// The claim is held under the unique constraint, so this caller owns
|
|
// the row — a plain update, not an upsert.
|
|
await prisma.idempotencyKey
|
|
.updateMany({
|
|
where: {
|
|
userId: ctx.userId,
|
|
key: ctx.key,
|
|
method: ctx.method,
|
|
path: ctx.path,
|
|
},
|
|
data: {
|
|
responseStatus: response.status,
|
|
responseBody: storedBody,
|
|
expiresAt: new Date(Date.now() + TTL_MS),
|
|
},
|
|
})
|
|
.catch(() => {
|
|
// The claim row vanished (e.g. purged as stale) — a future replay
|
|
// will simply miss and re-run; never throw out of the cache path.
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
/**
|
|
* The 409 both in-flight arms return.
|
|
*
|
|
* `error` is a STRING, matching the envelope `@/lib/api-response` defines
|
|
* for every other route (`{ data: null, error: <prose>, meta? }`). Until
|
|
* v1.35.1 these two literals sent `error` as `{ message }` — the only
|
|
* object-shaped error the app ever emitted, inherited by every route under
|
|
* `withIdempotency`, and never what the published contract promised. A
|
|
* client decoding the documented envelope now gets what it was generated
|
|
* against.
|
|
*
|
|
* Built once rather than twice so the two arms cannot drift apart again.
|
|
* The response is hand-built rather than routed through `apiError` because
|
|
* the 409 carries the `X-Idempotent-Replay` header.
|
|
*/
|
|
function inflightConflictResponse(): Response {
|
|
return NextResponse.json(
|
|
{
|
|
data: null,
|
|
error: "A request with this Idempotency-Key is already in progress",
|
|
},
|
|
{ status: 409, headers: { "X-Idempotent-Replay": "false" } },
|
|
);
|
|
}
|
|
|
|
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?.kind === "replay") return cached.response;
|
|
if (cached?.kind === "pending") {
|
|
// A concurrent request is mid-flight on this exact key. Refuse
|
|
// rather than run the side-effect a second time. The client should
|
|
// retry after the in-flight request lands, at which point the
|
|
// completed row replays.
|
|
annotate({
|
|
action: { name: "idempotency.inflight_conflict" },
|
|
meta: { method: ctx.method, path: ctx.path },
|
|
});
|
|
return inflightConflictResponse();
|
|
}
|
|
|
|
// Claim the key before running the handler. If a racing request beats
|
|
// us to the insert, treat it as the in-flight conflict above — only
|
|
// one caller may execute the side-effect for a given key.
|
|
const won = await claimKey(ctx);
|
|
if (!won) {
|
|
annotate({
|
|
action: { name: "idempotency.inflight_conflict" },
|
|
meta: { method: ctx.method, path: ctx.path },
|
|
});
|
|
return inflightConflictResponse();
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await handler(...args);
|
|
} catch (err) {
|
|
// Handler threw — release the claim so a retry isn't locked out for
|
|
// the full pending window, then re-throw to the error envelope.
|
|
await releaseClaim(ctx);
|
|
throw err;
|
|
}
|
|
|
|
const noStore = response.headers
|
|
.get("Cache-Control")
|
|
?.split(",")
|
|
.some((directive) => directive.trim().toLowerCase() === "no-store");
|
|
if (isCachableStatus(response.status) && !noStore) {
|
|
// Defence-in-depth: never persist a body that carries a freshly-issued
|
|
// bearer / refresh token or a third-party AI provider key. Auth and
|
|
// settings routes shouldn't be wrapped in withIdempotency to begin
|
|
// with, but if a future caller forgets we refuse to leak.
|
|
// `hlk_` = our access tokens
|
|
// `hlr_` = our refresh tokens
|
|
// `hls_` = clinician share-link tokens (v1.11)
|
|
// `hlv_` = registration invite tokens (v1.16 — the admin mint
|
|
// response is the one place the raw token appears)
|
|
// `sk-…` = OpenAI / Anthropic keys (full token form, not the
|
|
// raw substring — a 422 body explaining "task-id…"
|
|
// or any other word containing `sk-` would otherwise
|
|
// silently break idempotency for benign retries).
|
|
const SECRET_PATTERN =
|
|
/(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/;
|
|
const cloned = response.clone();
|
|
const text = await cloned.text();
|
|
if (!SECRET_PATTERN.test(text)) {
|
|
await persistCached(ctx, response, text);
|
|
} else {
|
|
// Secret-shaped body — drop the claim so the key isn't left
|
|
// pending (it was never going to cache).
|
|
await releaseClaim(ctx);
|
|
}
|
|
} else {
|
|
// Non-cachable status or an explicit `no-store` response releases the
|
|
// claim so a retry gets a fresh attempt.
|
|
await releaseClaim(ctx);
|
|
}
|
|
|
|
return response;
|
|
};
|
|
}
|