import type { InferOutput, StandardSchema } from "../../contracts/index.js"; import { AuthUnauthorizedError } from "../../ports/index.js"; import type { HttpRequestLike, RouteHook } from "../types.js"; type MaybePromise = T | Promise; /** * Arguments passed to auth route-hook callbacks. */ export type AuthHookArgs< Ctx, HeadersSchema extends StandardSchema | undefined = undefined, > = { /** * Framework-neutral request. */ req: HttpRequestLike; /** * Current route handler context. */ ctx: Ctx; /** * Matched contract metadata and schemas. */ contract: { metadata?: Record; }; /** * Parsed path parameters. */ path: unknown; /** * Parsed query parameters. */ query: unknown; /** * Hook-owned request headers. * * When the auth hooks declare a `headers` schema, this is that schema's * output parsed from the raw lowercase request header record. Without a * schema it is the raw lowercase header record itself, so auth resolution * never depends on each route's contract header schema. */ headers: HeadersSchema extends StandardSchema ? InferOutput : Record; /** * Parsed request body. */ body: unknown; }; /** * Options for route-scoped auth hooks. * * Auth additions must not include `gate`: the server re-attaches the gate * declared by the context blueprint after every hook, so elevated identities * are authorized against the updated context automatically. */ export type AuthHooksOptions< Ctx, AddedCtx extends object & { gate?: never }, HeadersSchema extends StandardSchema | undefined = undefined, > = { /** * Hook name prefix used in diagnostics. */ name?: string; /** * Optional Standard Schema for the credential headers this hook reads. * * The schema is validated against the raw lowercase request header record * before `resolve` runs. On `required()` hooks a schema failure rejects the * request with a framework-owned 401; on `optional()` hooks a schema failure * skips auth resolution; `public()` hooks never parse headers. */ headers?: HeadersSchema; /** * Resolve authenticated context additions for the current request. * * Return `null` when the request is unauthenticated. Required hooks will * reject that request; optional hooks will add no auth context. */ resolve: ( args: AuthHookArgs, ) => MaybePromise; }; /** * Route-scoped auth hook set. */ export type AuthRouteHooks = { /** * Mark a route as intentionally public. */ public: () => RouteHook>; /** * Resolve auth when present and add optional auth fields to the handler ctx. */ optional: () => RouteHook>; /** * Require auth and add authenticated fields to the handler ctx. */ required: () => RouteHook; }; function rawRequestHeaders(req: HttpRequestLike): Record { const record: Record = {}; req.headers.forEach((value, key) => { record[key.toLowerCase()] = value; }); return record; } type ParsedAuthHeaders = { ok: true; headers: unknown } | { ok: false }; async function parseAuthHeaders( schema: StandardSchema | undefined, req: HttpRequestLike, ): Promise { const raw = rawRequestHeaders(req); if (!schema) { return { ok: true, headers: raw }; } const result = await schema["~standard"].validate(raw); if (result.issues) { return { ok: false }; } return { ok: true, headers: result.value }; } /** * Create route-scoped authentication hooks. * * The outer call binds the app context; the inner call takes auth options and * infers the added context from `resolve`: * * ```ts * const auth = createAuthHooks()({ * resolve: ({ ctx }) => (ctx.auth ? { user: ctx.auth.user } : null), * }); * ``` * * Use `auth.required()` on routes that require an authenticated actor and * `auth.optional()` where handlers can use auth when present. The returned * route hooks enrich handler `ctx`; business authorization still belongs in * feature policies or use cases. * * Declare a `headers` schema when credentials live in request headers. The * hook validates the raw lowercase header record itself, so `resolve` receives * typed headers without contract casts and a `required()` hook rejects * missing or malformed credentials with a framework-owned 401. * * @returns A function that takes auth options and returns public, optional, * and required route-hook factories. */ export function createAuthHooks() { return < AddedCtx extends object & { gate?: never }, HeadersSchema extends StandardSchema | undefined = undefined, >( options: AuthHooksOptions, ): AuthRouteHooks => { const name = options.name ?? "auth"; const toAuthArgs = ( args: Parameters["resolve"]>[0], headers: unknown, ): AuthHookArgs => ({ req: args.req, ctx: args.ctx, contract: args.contract, path: args.path, query: args.query, headers, body: args.body, }) as AuthHookArgs; return { public: () => ({ name: `${name}.public`, resolve: () => undefined, }), optional: () => ({ name: `${name}.optional`, resolve: async (args) => { const parsed = await parseAuthHeaders(options.headers, args.req); if (!parsed.ok) { return undefined; } const additions = await options.resolve( toAuthArgs(args, parsed.headers), ); return additions ?? undefined; }, }), required: () => ({ name: `${name}.required`, resolve: async (args) => { const parsed = await parseAuthHeaders(options.headers, args.req); if (!parsed.ok) { throw new AuthUnauthorizedError(); } const additions = await options.resolve( toAuthArgs(args, parsed.headers), ); if (!additions) { throw new AuthUnauthorizedError(); } return additions; }, }), }; }; }