/** * Cross-Origin Resource Sharing (CORS) for the Weft server. * * The server supports browser clients such as external dashboards and the * documented browser runtime (Service Worker + IndexedDB). When those run on a * different origin than the API, the browser enforces CORS — and with no policy * configured it blocks every cross-origin call. This module supplies an * opt-in, **safe by default** policy: when `serve()` is called without a `cors` * option, the server emits no `Access-Control-*` headers at all (same-origin * only). It never defaults to `Access-Control-Allow-Origin: *`. * * Three pieces wire into the request pipeline: * - {@link buildPreflightResponse} answers `OPTIONS` preflight requests * before authentication runs (browsers never send credentials on * preflight, so auth-gating it would break CORS). * - {@link decorateResponseWithCors} adds the response headers for actual * (non-preflight) requests. * - {@link isOriginAllowed} gates WebSocket upgrades, which CORS does not * otherwise protect. * * Origins are compared as canonical origin tuples (`scheme://host[:port]`) * via the URL parser, so case, default-port elision, and trailing slashes do * not cause spurious mismatches. The literal `Origin: null` (sandboxed iframe, * `file://`) never matches an allowlist entry. * * @module server/runtime/cors */ /** * Operator-supplied CORS policy. Attach to `serve({ cors })`. Omitting it is * the safe default — the server emits no `Access-Control-*` headers and only * same-origin browser requests succeed; it never defaults to a wildcard origin. * * @example * ```ts * import { serve, type CorsOptions } from '@lostgradient/weft/server'; * import { Engine, MemoryStorage } from '@lostgradient/weft'; * * const cors: CorsOptions = { * allowedOrigins: ['https://dashboard.example.com'], * credentials: true, * }; * * await using engine = new Engine({ storage: new MemoryStorage() }); * await using server = serve({ engine, port: 0, cors }); * void server; * ``` */ export interface CorsOptions { /** * Origins permitted to make cross-origin requests. Either an explicit * allowlist (compared as canonical origins) or a predicate that receives * the raw `Origin` header value. The single-element sentinel `['*']` means * "any origin" and is only legal when `credentials` is not `true`. * * Omitting this (or an empty array) allows no cross-origin requests. * * Note: the array form canonicalizes both the allowlist and the incoming * origin (so case, default-port elision, and trailing slashes do not matter). * The predicate form receives the **raw** `Origin` header value with no * canonicalization — apply `canonicalizeOrigin` yourself if you need the same * normalization for case-insensitive or default-port comparisons. */ readonly allowedOrigins?: ReadonlyArray | ((origin: string) => boolean); /** Methods advertised in preflight responses. Defaults to the common verbs plus `OPTIONS`. */ readonly allowedMethods?: ReadonlyArray; /** Request headers a client may send. Defaults to `Authorization, Content-Type, Last-Event-ID, Cache-Control`. */ readonly allowedHeaders?: ReadonlyArray; /** Response headers exposed to client scripts via `Access-Control-Expose-Headers`. */ readonly exposedHeaders?: ReadonlyArray; /** Whether credentialed requests are allowed. When `true`, the origin is never wildcarded. */ readonly credentials?: boolean; /** Preflight cache lifetime in seconds (`Access-Control-Max-Age`). Defaults to 600. */ readonly maxAgeSeconds?: number; } /** * A CorsOptions normalized into the exact strings the response builders emit, * so the per-request hot path does no defaulting or array joining. */ export type ResolvedCorsPolicy = { readonly matchOrigin: (origin: string) => boolean; readonly allowsAnyOrigin: boolean; /** Pre-joined `Access-Control-Allow-Methods` value emitted in preflight responses. */ readonly allowedMethodsHeader: string; /** Uppercased method names, for O(1) validation of the preflight's requested method. */ readonly allowedMethodSet: ReadonlySet; /** Pre-joined `Access-Control-Allow-Headers` value emitted in preflight responses. */ readonly allowedHeadersHeader: string; /** Lowercased header names, for O(1) validation of the preflight's requested headers. */ readonly allowedHeaderSet: ReadonlySet; readonly exposedHeadersHeader: string | null; readonly credentials: boolean; readonly maxAgeSeconds: number; }; /** * Canonicalize an origin string to its `scheme://host[:port]` tuple. Returns * `null` for the literal `"null"`, empty input, or anything the URL parser * rejects — those must never match an allowlist entry. Default ports are * elided by the URL parser, so `https://x:443` and `https://x` compare equal. */ export declare function canonicalizeOrigin(value: string): string | null; /** * Resolve a `CorsOptions` (already validated by {@link validateCorsOptions}) * into the precomputed policy used per request. When `auth` is configured the * caller passes `requireAuthorizationHeader: true` so `Authorization` is * always advertised in `Access-Control-Allow-Headers`. */ export declare function resolveCorsPolicy(options: CorsOptions, requireAuthorizationHeader?: boolean): ResolvedCorsPolicy; /** * Whether a request's `Origin` header is permitted by the policy. The literal * `Origin: null` (sandboxed iframe, `file://`, some redirects) is rejected * unconditionally — even under a wildcard policy — so a sandboxed page can * never be treated as an allowed origin. */ export declare function isOriginAllowed(policy: ResolvedCorsPolicy, origin: string | null): boolean; /** True when the request is a CORS preflight (an `OPTIONS` with the request-method hint). */ export declare function isPreflightRequest(request: Request): boolean; /** * Build the preflight (`OPTIONS`) response. Always returns a bounded 204 so * the path is cheap and stateless — it emits `Access-Control-*` headers only * when the origin is allowed AND the requested method and headers are within * policy; otherwise the 204 carries no CORS headers and the browser blocks the * real request. `Vary` covers all three request dimensions a shared cache * could key on, and `Cache-Control: no-store` keeps a proxy from reusing one * origin's decision for another. */ export declare function buildPreflightResponse(policy: ResolvedCorsPolicy, request: Request): Response; /** * Add CORS response headers to an actual (non-preflight) response when the * request carried an allowed `Origin`. Mutates and returns the same response. * Always sets `Vary: Origin` (appending to any existing value) so caches do * not serve one origin's headers to another. */ export declare function decorateResponseWithCors(policy: ResolvedCorsPolicy, request: Request, response: Response): Response; /** * Validate a `CorsOptions` at `serve()` time so misconfigurations fail before * the port binds. Throws `Error` on: * - `credentials: true` combined with a wildcard origin (illegal per spec); * - a wildcard origin paired with an `Authorization` allowed-header (lets any * origin read responses to bearer-token requests — almost never intended). * * `authConfigured` must mirror what `serve()` passes as `requireAuthorizationHeader` * to {@link resolveCorsPolicy}: when `auth` is set, `Authorization` is auto-added * to the effective allowed-headers, so the wildcard + `Authorization` check has to * account for that even when the operator did not list it explicitly. Validating * against `options.allowedHeaders` alone would let `cors: { allowedOrigins: ['*'], * allowedHeaders: ['Content-Type'] }` pass under `auth` and then resolve to a policy * that allows bearer tokens under a wildcard origin. */ export declare function validateCorsOptions(options: CorsOptions, authConfigured?: boolean): void;