Files
HealthLog/src/lib/api-handler.ts
T
Marc-André Bombeck 9bc3b0b101 fix(mcp): validate the write preview so it matches the commit outcome
The confirm:false preview echoed the would-be record without running the
type / range / instant validation, so a preview could show a value the
commit then refused. Run the same shared validation in the preview and
surface wouldFail with the error + reason the commit would return, so a
preview and its commit never disagree.

Also note at the REST read-method allowlist that the MCP-audience guard
assumes GET/HEAD/OPTIONS are side-effect-free — a future side-effecting
GET must revisit that audience binding.
2026-06-28 02:00:44 +02:00

657 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { WideEventBuilder } from "./logging/event-builder";
import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
import { auditLog } from "./auth/audit";
import { resolveBearerToken, BearerAuthError } from "./auth/bearer";
import { AssistantDisabledError } from "./feature-flags";
import { ConsentRequiredError } from "./ai/consent-guard";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
* with any other method (POST / PUT / PATCH / DELETE) is a write.
*
* The MCP-audience guard below assumes these methods are side-effect-free:
* an MCP-bound token is admitted on them. A future side-effecting GET (or HEAD)
* would silently widen that token's reach over REST — do NOT add one without
* revisiting the MCP audience binding here.
*/
const READ_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Whether a token is MCP-audience-bound (H1). The MCP OAuth bridge and the
* connector settings card mint tokens whose ONLY grants are `health:read` —
* and, when the user consents to logging, `health:read health:write`. Either
* shape is bound to the MCP surface: the `/mcp` resolver accepts it and so do
* safe (read) REST methods, but it must NEVER reach a REST write/delete. The
* `health:write` grant admits writes ONLY in-process over `/mcp` (the confirmed
* write tools), never over REST — so a write-scoped MCP token is exactly as
* audience-bound on this edge as a read-only one. A token carrying any broader
* or legacy grant (`*`, `medication:ingest`, …) is NOT MCP-audience-bound and
* keeps its existing reach.
*/
export function isMcpAudienceToken(permissions: readonly string[]): boolean {
if (permissions.length === 0) return false;
return permissions.every(
(p) => p === SCOPE_HEALTH_READ || p === SCOPE_HEALTH_WRITE,
);
}
/**
* Custom error class for HTTP errors with status codes.
* Throw inside apiHandler-wrapped routes to return a JSON error response.
*/
export class HttpError extends Error {
constructor(
public statusCode: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
/**
* Wraps an API route handler with Wide Event logging.
* Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage,
* catches errors, and emits the event on completion.
*
* No CSRF check — HealthLog does not use CSRF tokens.
* Auth annotation happens in routes via requireAuth().
*/
// Read a property from a request-like value without invoking native
// private-field getters (NextRequest.method / .url / .headers access
// `this.#state` and crash with `Cannot read private member #state from
// an object whose class did not declare it` when the request is a
// Proxy or a synthetic placeholder — Next 16 passes such placeholders
// to `force-static` route handlers during dev). We probe defensively
// and fall back to safe defaults so logging instrumentation never
// crashes the handler.
//
// The catch is narrowed to two well-known shapes: the V8 private-field
// TypeError, and the `Cannot read properties of undefined/null` shape
// raised when the wrapper is invoked without a request (vitest tests
// frequently invoke handlers as `GET()` with no args, mirroring the
// shape Next.js exercises for the static-export pass). Any other
// exception is a real bug in the read callback or in a downstream
// header parser and must surface — swallowing it would hide
// regressions in production instrumentation.
function isTolerableRequestProbeError(err: unknown): boolean {
if (!(err instanceof TypeError)) return false;
const msg = err.message ?? "";
return (
// V8 — current
msg.includes("private member") ||
// V8 — alternative
msg.includes("private field") ||
// Bun / older V8
msg.includes("private name") ||
// No request handed in at all (vitest direct-invoke or
// force-static placeholder reduced to undefined / null).
// Covers both modern `Cannot read properties of undefined
// (reading 'X')` and the older `Cannot read property 'X' of
// undefined` wordings.
/Cannot read propert(?:y|ies)\b.*\bof (?:undefined|null)\b/.test(msg)
);
}
function safeRequestProp<R>(
request: unknown,
read: (req: NextRequest) => R,
fallback: R,
): R {
try {
return read(request as NextRequest);
} catch (err) {
if (isTolerableRequestProbeError(err)) {
// v1.4.27 B7 / BL-P1-3 — surface every fallback so a real read
// regression cannot hide behind the tolerated-error narrowing.
// Vitest direct-invoke and the force-static placeholder path are
// the two known-quiet shapes; anything else worth a look should
// show up in the dev console + the run log.
if (process.env.NODE_ENV !== "test") {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[api-handler] safeRequestProp fallback — tolerable error: ${msg}`,
);
}
return fallback;
}
throw err;
}
}
/** @internal — exposed for unit tests of the narrow-catch contract. */
export const __testables = {
safeRequestProp,
isTolerableRequestProbeError,
};
// Next.js route handlers come in two shapes — `(request)` for static routes
// and `(request, { params })` for dynamic ones. The variadic generic is the
// only signature TS accepts that covers both at the call site. The `any[]`
// here is constrained by the bound (T must return Promise<Response>) so it
// does not loosen handler bodies — only their parameter list.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
handler: T,
): T {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const wrapped = async (...args: any[]): Promise<Response> => {
const request = args[0] as NextRequest;
const requestUrl = safeRequestProp(request, (r) => r.url, "");
const url = (() => {
try {
return new URL(requestUrl);
} catch {
// No usable URL (e.g. force-static placeholder) — fall back to
// a synthetic origin so the rest of the pipeline can still
// attach a pathname.
return new URL("http://localhost/");
}
})();
const evt = new WideEventBuilder("http");
// Propagate x-request-id if present
const incomingRequestId = safeRequestProp(
request,
(r) => r.headers.get("x-request-id"),
null,
);
if (incomingRequestId) evt.setRequestId(incomingRequestId);
evt.setHttp({
method: safeRequestProp(request, (r) => r.method, "GET"),
path: url.pathname,
route: url.pathname,
status: 200,
user_agent:
safeRequestProp(request, (r) => r.headers.get("user-agent"), null) ??
undefined,
ip:
safeRequestProp(
request,
(r) => r.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
null,
) ||
safeRequestProp(request, (r) => r.headers.get("x-real-ip"), null) ||
undefined,
});
return eventStorage.run(evt, async () => {
let response: Response | undefined;
try {
response = await handler(...args);
} catch (error) {
if (error instanceof AssistantDisabledError) {
// v1.4.31 — operator has disabled the assistant surface.
// The 403 + `errorCode: "assistant.disabled.<surface>"`
// envelope is locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
// Older iOS clients that don't know the errorCode surface
// this as a generic 403; v1.4.31+ clients can branch on the
// errorCode to render an inline operator-disabled notice.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof ConsentRequiredError) {
// v1.12.1 — server-side consent gate before external-LLM PHI
// egress on the operator's server-managed key. Mirrors the
// AssistantDisabledError envelope (403 + meta.errorCode) so the
// iOS client renders an inline "grant consent" notice instead of
// a generic failure.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: 403 },
);
} else if (error instanceof StepUpRequiredError) {
// v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode
// envelope shape as the assistant/consent gates so the client can
// branch on the stable code and launch a re-verification flow.
// Checked before the generic HttpError branch because
// StepUpRequiredError extends it.
evt.setError(error);
response = NextResponse.json(
{
data: null,
error: error.message,
meta: { errorCode: error.errorCode },
},
{ status: error.statusCode },
);
} else if (error instanceof HttpError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: error.message },
{ status: error.statusCode },
);
} else if (error instanceof SyntaxError) {
evt.setError(error);
response = NextResponse.json(
{ data: null, error: "Invalid JSON body" },
{ status: 400 },
);
} else {
evt.setError(error);
// Report to GlitchTip (fire-and-forget)
reportToGlitchtip(error, request, evt).catch(() => {});
response = NextResponse.json(
{ data: null, error: "Interner Serverfehler" },
{ status: 500 },
);
}
} finally {
const status = (response as Response | undefined)?.status ?? 500;
evt.finish(status);
try {
emitIfSampled(evt.toJSON());
} catch {
/* logging must never crash the handler */
}
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
return nr;
});
};
return wrapped as T;
}
/**
* Authenticated request context. Returned by both session-cookie and Bearer-token
* authentication paths. The `session.id` is the session-record id for cookie auth
* and the `ApiToken` id for bearer auth — callers must not assume the id refers
* to a Session row.
*/
export type AuthContext = {
session: { id: string; expiresAt: Date };
user: User;
};
/**
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
*
* Auth precedence (cookie-first, never both):
* 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored).
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
* 3. Neither → 401.
*
* @param requiredPermission Optional permission scope. Only enforced for Bearer
* auth — cookie sessions always pass (full user access). When set and missing
* from `ApiToken.permissions`, throws HttpError(403).
*/
export async function requireAuth(
requiredPermission?: string,
): Promise<AuthContext> {
// 1. Session cookie path — unchanged.
const sessionData = await getSession();
if (sessionData) {
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
return sessionData;
}
// 2. Bearer-token path.
// `headers()` is only valid inside a Next.js request scope. Outside one
// (e.g. during direct unit tests of legacy routes that pre-date Bearer auth)
// we treat the absence of a header context as "no Bearer present" and fall
// through to the unauthenticated case below.
let authHeader: string | null = null;
try {
const headerList = await headers();
authHeader = headerList.get("authorization");
} catch {
authHeader = null;
}
if (authHeader?.startsWith("Bearer ")) {
return await authenticateBearer(authHeader.slice(7), requiredPermission);
}
// 3. No credentials.
throw new HttpError(401, "Not authenticated");
}
/**
* Authenticate a raw Bearer token against `ApiToken`.
*
* The validation itself lives in the transport-agnostic `resolveBearerToken`
* (`./auth/bearer`) — the single source of truth shared with the MCP wire. This
* wrapper adds the HTTP-edge concerns: the `auth.bearer.*` audit trail for both
* outcomes and the Wide-Event `auth_method: "bearer"` annotation, then maps the
* result onto the `AuthContext` contract (`session.id` carries the token id).
*
* Authorisation contract (unchanged): a route that declares no
* `requiredPermission` accepts any valid token; one that declares a scope
* accepts wildcard (`["*"]`) tokens and narrow-scope tokens that list it.
*/
async function authenticateBearer(
rawToken: string,
requiredPermission: string | undefined,
): Promise<AuthContext> {
let resolution;
try {
resolution = await resolveBearerToken(rawToken, requiredPermission);
} catch (err) {
if (err instanceof BearerAuthError) {
auditLog("auth.bearer.failure", {
userId: err.userId ?? null,
details: {
reason: err.reason,
...(err.tokenId ? { tokenId: err.tokenId } : {}),
...(err.reason === "insufficient_permissions"
? { required: requiredPermission }
: {}),
},
}).catch(() => {});
const message =
err.statusCode === 403
? "Insufficient permissions"
: err.reason === "expired"
? "Token expired"
: "Invalid token";
throw new HttpError(err.statusCode, message);
}
throw err;
}
const { user, tokenId, expiresAt, permissions } = resolution;
// H1 — audience binding at the resource server. An MCP-audience token
// (`health:read`, or `health:read health:write`) is bound to the `/mcp`
// surface; it may reach `/mcp` (a separate resolver that never runs this
// edge) and safe REST reads, but a write/delete over REST is outside its
// audience and is refused — INCLUDING a write-scoped token, whose writes are
// confined to the in-process `/mcp` tools and never granted over REST. Fail
// closed when the method is unknown
// (no event context) since every real REST request runs inside apiHandler,
// which always sets the method — an unknown method means we cannot prove a
// read, so we deny. This is RFC 8707 audience binding on the credential the
// client actually holds, not only during the OAuth exchange.
if (isMcpAudienceToken(permissions)) {
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
if (!READ_HTTP_METHODS.has(method)) {
auditLog("auth.bearer.failure", {
userId: user.id,
details: {
reason: "mcp_audience_write_blocked",
tokenId,
method: method || "unknown",
},
}).catch(() => {});
throw new HttpError(403, "Insufficient permissions");
}
}
auditLog("auth.bearer.success", {
userId: user.id,
details: { tokenId },
}).catch(() => {});
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: user.id,
user_role: user.role,
auth_method: "bearer",
});
}
return {
session: { id: tokenId, expiresAt },
user,
};
}
/**
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
* Automatically annotates the Wide Event with auth context.
*/
export async function requireAdmin(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
if (sessionData.user.role !== "ADMIN") {
throw new HttpError(403, "Admin access required");
}
return sessionData;
}
/**
* v1.23 — require a cookie-backed session, refusing Bearer tokens.
*
* Cookie-only by the same structural argument as `requireAdmin`: it resolves
* the session via `getSession()` (which reads only the session cookie) and
* never falls through to the Bearer branch. The second-factor management
* surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use
* this so an API token — even a wildcard one — can never enrol or tear down
* MFA on the account it belongs to. MFA management is a browser-only action.
*/
export async function requireCookieAuth(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
return sessionData;
}
/**
* Error thrown when a step-up gate is not satisfied. Carries `errorCode` so
* the route can surface a stable machine code (`auth.stepup.required`) the
* client branches on to launch a re-verification flow rather than parsing
* prose.
*/
export class StepUpRequiredError extends HttpError {
constructor(
public errorCode: string = "auth.stepup.required",
message = "Recent second-factor verification required",
) {
super(401, message);
this.name = "StepUpRequiredError";
}
}
/**
* Default step-up freshness window (5 minutes) for sensitive mutations.
* Within the 5–15 min band OWASP recommends; tight end because the gated
* actions (disable MFA, regenerate codes, and later key rotation / export)
* are destructive.
*/
export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60;
export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
/**
* v1.23 — step-up gate. Passes only for a COOKIE session whose
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an
* active second factor (`totpConfirmedAt`). Throws `StepUpRequiredError`
* (401, `errorCode: "auth.stepup.required"`) otherwise.
*
* Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the
* resolution path is `getSession()` (cookie-only) and there is no Bearer
* fall-through. A token transport carries no `mfaVerifiedAt` and cannot
* acquire one, so the boundary is structural, not a softenable runtime check.
*
* Consumed in Phase M by MFA disable + recovery-code regeneration; later
* waves gate account deletion, key rotation, and passphrase export on it.
*/
export async function requireFreshMfa(
maxAgeSeconds: number,
): Promise<FreshMfaContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
// The user must actually have a second factor active. A single-factor
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
// unreachable for it by design (the management UI gates enrolment first).
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
if (!sessionData.user.totpConfirmedAt) {
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: sessionData.user.id },
});
if (webauthnKeyCount === 0) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
}
// Read the freshness stamp off the live session row — `getSession`'s
// projection intentionally omits it.
const row = await prisma.session.findUnique({
where: { id: sessionData.session.id },
select: { mfaVerifiedAt: true },
});
const verifiedAt = row?.mfaVerifiedAt ?? null;
if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) {
throw new StepUpRequiredError();
}
return { ...sessionData, mfaVerifiedAt: verifiedAt };
}
/**
* v1.23 — conditional step-up for destructive account actions.
*
* Resolves the caller with the standard `requireAuth()` (cookie OR Bearer).
* For an account WITHOUT a confirmed second factor the caller passes straight
* through — a single-factor user is intentionally unaffected, so account
* deletion / data reset keeps its existing typed-confirmation-only contract.
* For an account WITH MFA active (`totpConfirmedAt` set) it additionally runs
* `requireFreshMfa`, which is cookie-only by construction: an MFA-enrolled
* account's Bearer transport carries no `mfaVerifiedAt` and therefore cannot
* satisfy step-up, surfacing `StepUpRequiredError` (401,
* `errorCode: "auth.stepup.required"`) so the UI launches a re-verification.
*
* Gating only the MFA-enrolled cohort keeps the boundary structural: a
* hijacked live cookie session for an MFA user cannot nuke the record without
* a fresh factor, while users who never opted into MFA are not forced through
* a flow they have no way to complete.
*/
export async function requireFreshMfaIfEnrolled(
maxAgeSeconds: number,
): Promise<AuthContext> {
const auth = await requireAuth();
// Either factor enrols the account: a confirmed TOTP secret OR a registered
// WebAuthn security key. A webauthn-only user must clear step-up too, so the
// destructive-action boundary tracks `requireFreshMfa`'s either-factor rule.
let enrolled = Boolean(auth.user.totpConfirmedAt);
if (!enrolled) {
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: auth.user.id },
});
enrolled = webauthnKeyCount > 0;
}
if (enrolled) {
await requireFreshMfa(maxAgeSeconds);
}
return auth;
}
/**
* Report unhandled errors to GlitchTip (fire-and-forget).
* Uses dynamic import to avoid circular dependencies and startup cost.
*/
async function reportToGlitchtip(
error: unknown,
request: NextRequest,
evt: WideEventBuilder,
): Promise<void> {
const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = await Promise.all([
import("@/lib/monitoring-settings"),
import("@/lib/monitoring/glitchtip"),
]);
const settings = await getGlitchtipSettings();
if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return;
const err =
error instanceof Error
? error
: new Error(typeof error === "string" ? error : "Unknown error");
// Skip expected errors from bot scanners (malformed JSON bodies)
if (err instanceof SyntaxError) return;
// Audit H-B7 / phase P2: strip the query string before forwarding to
// GlitchTip. Withings legacy callbacks ship `?secret=…` (see C-3) and
// OAuth callbacks ship `?code=…&state=…`; if any of those error we
// don't want their secrets in someone's incident UI.
const rawUrl = safeRequestProp(request, (r) => r.url, "");
let scrubbedUrl = rawUrl;
try {
const u = new URL(rawUrl);
u.search = "";
scrubbedUrl = u.toString();
} catch {
// Invalid URL — fall through with the raw value (only happens in
// degenerate test fixtures).
}
await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
input: {
environment: settings.glitchtipEnvironment || "production",
// Defence in depth: even though the WideEventBuilder already
// redacts on `setError()`, the GlitchTip path imports `err`
// directly. Apply the same redaction here so a Telegram bot
// token or external Bearer cannot leak via the incident UI.
message: redactSecrets(err.message),
level: "error",
type: err.name || "Error",
stack: redactOptional(err.stack),
url: scrubbedUrl,
sourceTag: "healthlog-api-handler",
requestId: evt.getRequestId(),
},
});
}