import type { Context, Next } from "hono"; import { resolveHeaderLocale } from "../i18n/request-locale"; import { LOCALE_HEADER_NAME } from "./api-constants"; import { type RequestContextData, requestContext } from "./request-context"; const REQUEST_ID_HEADER = "X-Request-ID"; const CORRELATION_ID_HEADER = "X-Correlation-ID"; // requestId/correlationId flow unvalidated into append-only event-store // metadata (e.g. sessions:revoke-all-for-user) — cap shape at the trust // boundary so a client can't smuggle an oversized/control-char payload into // permanent, replayed, DSGVO-exported storage via a client-set header. const SAFE_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/; function sanitizeClientId(value: string | undefined): string | undefined { return value !== undefined && SAFE_ID_RE.test(value) ? value : undefined; } /** * Builds the RequestContextData record for a Hono request — requestId * (client-supplied + sanitized, or generated), correlationId (mirrors * requestId unless the client set its own), the underlying abort signal, * and the client IP/User-Agent. Extracted out of `requestIdMiddleware` so * call-sites that invoke a handler outside that middleware's `next()` * chain (e.g. server.ts's httpRoute→systemQuery mount) can still populate * the same AsyncLocalStorage record via `requestContext.run(...)`. */ export function buildRequestContextData(c: Context): RequestContextData { const requestId = sanitizeClientId(c.req.header(REQUEST_ID_HEADER)) ?? requestContext.generateId(); const correlationId = sanitizeClientId(c.req.header(CORRELATION_ID_HEADER)) ?? requestId; // Hono exposes the underlying Fetch Request — its `signal` aborts // when the client disconnects (mobile back-press, tab close). We // propagate it through requestContext so framework internals can // honour cancellation at long-running checkpoints. Older Hono / // adapter combos may not populate `c.req.raw.signal`; conditional // spread keeps `signal: undefined` out of the stored record so // downstream `signal?` checks behave as if no signal exists. const signal = c.req.raw?.signal; // Client IP for per-IP rate limiting. Trust `x-forwarded-for` when // present (proxy/CDN) — first hop is the originating client. Adapter- // specific socket-address fallback (bun, node) is not standardized // in Hono; deployments behind a proxy should always set xff. Without // either we leave `ip` undefined and skip ip-bucketed checks rather // than fabricate one. const xff = c.req.header("x-forwarded-for"); const ip = xff?.split(",")[0]?.trim(); const userAgent = c.req.header("user-agent"); // Runs before auth-middleware, so this reaches public routes too (e.g. // signup-request) — that's the whole point: the active UI locale must // survive to anonymous callers, not just authenticated ones. const locale = resolveHeaderLocale({ headerLocale: c.req.header(LOCALE_HEADER_NAME), acceptLanguage: c.req.header("accept-language"), }); return { requestId, correlationId, ...(signal ? { signal } : {}), ...(ip && ip.length > 0 ? { ip } : {}), ...(userAgent !== undefined ? { userAgent } : {}), ...(locale !== undefined ? { locale } : {}), }; } /** * Assigns a requestId + correlationId to every request and wraps execution * in AsyncLocalStorage. Runs BEFORE auth — both ids are available even for * 401 responses. * * correlationId defaults to the requestId if the client didn't set * `x-correlation-id` — clients that don't care about cross-service tracing * still get sensible single-request correlation for free. */ export function requestIdMiddleware() { return async (c: Context, next: Next) => { const data = buildRequestContextData(c); c.header(REQUEST_ID_HEADER, data.requestId); c.header(CORRELATION_ID_HEADER, data.correlationId); c.set("requestId", data.requestId); await requestContext.run(data, () => next()); }; }