/** * Single wrapper that gives every API route the same shape: * * - generates a request id (`req_`) and threads it through the logger * - resolves auth via requireAuth() and (by default) the active companyId * - emits one structured `info` log on completion with duration * - converts any thrown value into the canonical error envelope via * errorResponse(); the request id appears in the response body and the * X-Request-Id response header. Unhandled errors are logged ("op failed") * with the resolved { requestId, operation, userId, companyId } context. * * Usage: * export const POST = withRouteContext('invoice.send', async (req, ctx) => { * // ctx.requestId, ctx.log, ctx.user, ctx.supabase, ctx.companyId * const result = await sendInvoice(...) * return NextResponse.json({ data: result }) * }) * * For dynamic routes the second parameter is the Next.js params promise: * export const POST = withRouteContext('invoice.send', async (req, ctx, { params }) => { * const { id } = await params * ... * }) */ import type { SupabaseClient, User } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { requireAuth } from '@/lib/auth/require-auth' import { requireWritePermission } from '@/lib/auth/require-write' import { getActiveCompanyId } from '@/lib/company/context' import { createLogger, type Logger } from '@/lib/logger' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' export interface RouteContext { /** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id header. */ requestId: string /** Logger pre-bound with { requestId, userId, companyId, operation }. */ log: Logger /** Authenticated user. Always present: wrapper short-circuits with 401 otherwise. */ user: User /** Authenticated Supabase client (request-scoped, RLS active). */ supabase: SupabaseClient /** * Resolved active company id. The wrapper short-circuits with * COMPANY_CONTEXT_MISSING before invoking the handler when no company is * resolved, so handlers can treat this as guaranteed non-null. Routes that * need to opt out of the guarantee (e.g. onboarding) shouldn't use * withRouteContext. * * Membership invariant: `getActiveCompanyId` only returns a company the * authenticated user is a current member of (it validates * `company_members` and excludes archived companies). The handler may * therefore treat `companyId` as "a company the caller is authorized to * read", and routes that mutate state additionally enforce a non-viewer * role via `requireWrite: true`. ASVS V8.2.1 / SOC 2 CC6.3. */ companyId: string } interface RouteContextOptions { /** * Defaults to false. When true, the wrapper rejects callers whose role in * the active company is `viewer` (or who have no membership). Mirrors the * existing requireWritePermission() helper so mutating routes can drop two * lines of boilerplate. */ requireWrite?: boolean } // Next.js 16 always passes a `{ params: Promise<...> }` second arg to route // handlers: including on non-dynamic routes, where it's `Promise<{}>`. The // generic defaults to that empty shape so static routes type-check without // having to declare any params at the call site. // eslint-disable-next-line @typescript-eslint/no-empty-object-type type DynamicParams = { params: Promise> } | { params: Promise<{}> } type RouteHandler

> }> = ( request: Request, ctx: RouteContext, params: P, ) => Promise function generateRequestId(): string { // crypto.randomUUID is available in Node 20+/edge runtimes used by Next.js. return `req_${crypto.randomUUID()}` } export function withRouteContext

> }>( operation: string, handler: RouteHandler

, options: RouteContextOptions = {}, ): (request: Request, params: P) => Promise { const { requireWrite = false } = options return async function wrapped(request: Request, params: P): Promise { const requestId = generateRequestId() const start = Date.now() const log = createLogger(`api/${operation}`, { requestId, operation }) // Upgraded as request context resolves, so an unhandled throw in the catch // below is logged with the richest available { userId, companyId } context // (audit trail / OWASP V16), not just { requestId, operation }. let errLog = log try { const authStart = Date.now() const auth = await requireAuth() const authMs = Date.now() - authStart if (auth.error) { log.warn('auth failed', { status: auth.error.status }) // Pass through requireAuth's response unchanged for backwards-compat // with existing route tests; only inject the request id header so // support can still trace the request. if (!auth.error.headers.get('X-Request-Id')) { auth.error.headers.set('X-Request-Id', requestId) } return auth.error } const { user, supabase } = auth const userLog = log.child({ userId: user.id }) errLog = userLog let companyId: string | null = null const companyStart = Date.now() try { companyId = await getActiveCompanyId(supabase, user.id) } catch (err) { userLog.error('failed to resolve active company', err as Error) } const companyMs = Date.now() - companyStart if (!companyId) { return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId }) } if (requireWrite) { // Delegate to the existing helper so tests that already mock it // continue to work. The helper returns its own 403 NextResponse; // we wrap it in our request-id header for traceability. The // company id resolved above is handed over so the helper does not // repeat the resolve_active_company round trip (measured ~40 ms p50 // on prod) on every write route. const writeCheck = await requireWritePermission(supabase, user.id, { companyId }) if (!writeCheck.ok) { userLog.warn('write permission denied') if (!writeCheck.response.headers.get('X-Request-Id')) { writeCheck.response.headers.set('X-Request-Id', requestId) } return writeCheck.response } } const ctx: RouteContext = { requestId, log: userLog.child({ companyId }), user, supabase, companyId, } errLog = ctx.log const handlerStart = Date.now() const response = await handler(request, ctx, params) const handlerMs = Date.now() - handlerStart if (response instanceof Response && !response.headers.get('X-Request-Id')) { response.headers.set('X-Request-Id', requestId) } // Per-phase breakdown, visible in browser devtools (Timing tab) and in // the op-completed log: separates the wrapper's own overhead (auth // round trip + company resolution) from the handler's real work, so // latency regressions can be attributed without guessing. if (response instanceof Response && !response.headers.get('Server-Timing')) { response.headers.set( 'Server-Timing', `auth;dur=${authMs}, company;dur=${companyMs}, handler;dur=${handlerMs}`, ) } ctx.log.info('op completed', { durationMs: Date.now() - start, status: response.status, authMs, companyMs, handlerMs, }) return response } catch (err) { // Resolve the envelope first so the log level can follow the mapped // status — thrown 4xx domain errors are expected outcomes (warn), only // 5xx are runtime errors. errorResponse logs the error itself at the // same threshold. const response = errorResponse(err, errLog, { requestId }) const meta = { durationMs: Date.now() - start, status: response.status } if (response.status < 500) errLog.warn('op failed', err as Error, meta) else errLog.error('op failed', err as Error, meta) return response } } }