71 lines
2.5 KiB
TypeScript
71 lines
2.5 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
|
/**
|
|
* Phase 10 Task #67 — Audit auto-capture (React-Router web).
|
|
*
|
|
* The Hono API has its own AsyncLocalStorage-backed audit context (see
|
|
* apps/api/src/middleware/audit-context.ts). The React-Router runtime
|
|
* has no global middleware hook for loader/action invocation, so we
|
|
* expose a thin wrapper that loaders + actions can opt into via
|
|
* `withAuditContext(args, async () => { ... })`.
|
|
*
|
|
* The helper extracts actor IP / user agent / request id from the
|
|
* inbound Request headers and binds them into a request-scoped store
|
|
* so any `createAuditEvent` invocation deeper in the call stack can
|
|
* read them.
|
|
*/
|
|
|
|
export type WebAuditContext = {
|
|
actorIp: string | null;
|
|
actorUserAgent: string | null;
|
|
requestId: string | null;
|
|
};
|
|
|
|
const webAuditContextStore = new AsyncLocalStorage<WebAuditContext>();
|
|
|
|
const FORWARDED_HEADERS = ["cf-connecting-ip", "x-real-ip", "x-forwarded-for"] as const;
|
|
|
|
export function extractWebActorIp(request: Request): string | null {
|
|
for (const name of FORWARDED_HEADERS) {
|
|
const raw = request.headers.get(name);
|
|
if (typeof raw !== "string") continue;
|
|
const first = raw.split(",")[0]?.trim();
|
|
if (first && first.length > 0 && first.length <= 64) return first;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function extractWebActorUserAgent(request: Request): string | null {
|
|
const raw = request.headers.get("user-agent");
|
|
if (typeof raw !== "string") return null;
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length === 0) return null;
|
|
return trimmed.length > 512 ? trimmed.slice(0, 512) : trimmed;
|
|
}
|
|
|
|
export function extractWebRequestId(request: Request): string | null {
|
|
const raw = request.headers.get("x-request-id");
|
|
if (typeof raw !== "string") return null;
|
|
const trimmed = raw.trim();
|
|
return trimmed.length === 0 || trimmed.length > 128 ? null : trimmed;
|
|
}
|
|
|
|
/**
|
|
* Wrap a loader/action body with an audit context bound from the inbound
|
|
* request. Falls through transparently when no Request is available.
|
|
*/
|
|
export async function withAuditContext<T>(args: { request: Request } | { request?: Request }, fn: () => Promise<T>): Promise<T> {
|
|
const request = (args as { request?: Request }).request;
|
|
if (!request) return fn();
|
|
const ctx: WebAuditContext = {
|
|
actorIp: extractWebActorIp(request),
|
|
actorUserAgent: extractWebActorUserAgent(request),
|
|
requestId: extractWebRequestId(request),
|
|
};
|
|
return webAuditContextStore.run(ctx, fn);
|
|
}
|
|
|
|
export function getWebAuditContext(): WebAuditContext | null {
|
|
return webAuditContextStore.getStore() ?? null;
|
|
}
|