Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/audit/withAuditContext.ts
T

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;
}