UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

223 lines (206 loc) 6.44 kB
import type { InferOutput, StandardSchema } from "../../contracts/index.js"; import { AuthUnauthorizedError } from "../../ports/index.js"; import type { HttpRequestLike, RouteHook } from "../types.js"; type MaybePromise<T> = T | Promise<T>; /** * 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<string, unknown>; }; /** * 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<HeadersSchema> : Record<string, string>; /** * 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<Ctx, HeadersSchema>, ) => MaybePromise<AddedCtx | null>; }; /** * Route-scoped auth hook set. */ export type AuthRouteHooks<Ctx, AddedCtx extends object & { gate?: never }> = { /** * Mark a route as intentionally public. */ public: () => RouteHook<Ctx, Record<string, never>>; /** * Resolve auth when present and add optional auth fields to the handler ctx. */ optional: () => RouteHook<Ctx, Partial<AddedCtx>>; /** * Require auth and add authenticated fields to the handler ctx. */ required: () => RouteHook<Ctx, AddedCtx>; }; function rawRequestHeaders(req: HttpRequestLike): Record<string, string> { const record: Record<string, string> = {}; 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<ParsedAuthHeaders> { 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<AppContext>()({ * 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<Ctx>() { return < AddedCtx extends object & { gate?: never }, HeadersSchema extends StandardSchema | undefined = undefined, >( options: AuthHooksOptions<Ctx, AddedCtx, HeadersSchema>, ): AuthRouteHooks<Ctx, AddedCtx> => { const name = options.name ?? "auth"; const toAuthArgs = ( args: Parameters<RouteHook<Ctx, object>["resolve"]>[0], headers: unknown, ): AuthHookArgs<Ctx, HeadersSchema> => ({ req: args.req, ctx: args.ctx, contract: args.contract, path: args.path, query: args.query, headers, body: args.body, }) as AuthHookArgs<Ctx, HeadersSchema>; 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; }, }), }; }; }