diff --git a/.changeset/cors-preflight.md b/.changeset/cors-preflight.md new file mode 100644 index 00000000..02aaf745 --- /dev/null +++ b/.changeset/cors-preflight.md @@ -0,0 +1,10 @@ +--- +"@cleverbrush/server": minor +--- + +Add opt-in server-wide CORS through `ServerBuilder.useCors()` and +`ServerCorsOptions`. Handle route-aware preflights before authentication while +preserving the normal pipeline for actual requests. Support exact origins, +origin predicates, explicit request/response header policies, credentials and +preflight cache duration. Reject disallowed origins before handlers and finalize +CORS headers per physical response, including errors and cache/idempotency replays. diff --git a/docs/framework-feature-candidates.md b/docs/framework-feature-candidates.md index 2039415f..5e1282c7 100644 --- a/docs/framework-feature-candidates.md +++ b/docs/framework-feature-candidates.md @@ -1,6 +1,6 @@ # Framework feature candidates -Status: F01–F03 merged; F04–F05 implemented on the storage feature branch for PR review; F06–F07 remain proposed. +Status: F01–F05 merged; F06 implemented on the CORS feature branch for PR review; F07 remains proposed. Assessment date: 2026-10-02. This is an unprioritized list of reusable Framework capabilities and correctness @@ -204,7 +204,7 @@ features are separate future candidates. **Priority:** -**Existing support and gap.** Applications can set response headers in middleware, +**Original assessment.** Applications can set response headers in middleware, but [route matching](../libs/server/src/Server.ts) occurs first. An HTTP probe sent an OPTIONS preflight to a POST route and received `405`; ordinary middleware never ran. Application middleware alone therefore cannot handle that preflight. @@ -215,9 +215,11 @@ for the actual request. Apply appropriate CORS headers to successful and error responses. Support configured origins, methods, allowed/exposed headers, and credential behavior. -**Public API implications.** Add server configuration or a first-class CORS helper -with documented execution order. Avoid silently changing the execution order of -existing ordinary middleware. +**Public API.** `ServerBuilder.useCors(options)` and the exported +`ServerCorsOptions` provide a server-wide policy with static origins or synchronous/ +asynchronous origin predicates, preflight methods and headers, exposed headers, +credentials and browser preflight cache duration. Ordinary middleware order is +unchanged. **Acceptance criteria.** @@ -228,6 +230,12 @@ existing ordinary middleware. - Actual protected requests still require authentication. - Same-origin applications without CORS configuration remain unaffected. +**Implementation:** Added the CORS stage before routing and authentication, +route-aware preflight responses, rejection of disallowed origins before handlers, +and consistent headers on success/error responses and cache/idempotency replays. +Includes real HTTP and type tests, built-in health/batch route support, and updated +documentation. See the [CORS guide](../libs/server/README.md#cors). + **Review notes:** ## F07 — Consistent polymorphic ORM writes diff --git a/libs/server/README.md b/libs/server/README.md index 9c2970d9..ef34ec7d 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -25,6 +25,7 @@ fields; the framework never derives a field name from an exception's message. - **Action results** — `ActionResult.ok()`, `.created()`, `.noContent()`, `.redirect()`, `.file()`, `.stream()`, `.raw()`, `.status()` — no manual `res.write()` / `res.end()` unless you explicitly opt in. - **Content negotiation** — pluggable `ContentTypeHandler` registry; JSON and `application/x-www-form-urlencoded` registered by default; honours the `Accept` request header. - **Middleware pipeline** — `server.use(middleware)` for global middleware; per-endpoint middleware via `handle(ep, handler, { middlewares })`. +- **Opt-in CORS** — `server.useCors()` handles route-aware preflights before authentication, with explicit origins or asynchronous origin predicates. - **DI integration** — `endpoint.inject({ db: IDbContext })` resolves services per-request from a `@cleverbrush/di` container. - **Authentication & authorization** — `server.useAuthentication()` / `server.useAuthorization()` wired to `@cleverbrush/auth` schemes and policies. - **RFC 9457 Problem Details** — validation errors and `HttpError` subclasses are serialized as `application/problem+json`. @@ -37,6 +38,87 @@ fields; the framework never derives a field name from an exception's message. - **Modular implementations** — `implement(api)` derives server-configured scopes, keeps separate handler files strongly typed, and checks full contract coverage at final registration. - **Typed error policies** — `errorMap()` and `withErrors()` translate known handler exceptions without repeated catch blocks or widening endpoint responses. +## CORS + +Enable CORS with an explicit server-wide policy: + +```ts +import { createServer } from '@cleverbrush/server'; + +const server = createServer().useCors({ + origin: ['https://app.example.com', 'http://localhost:5173'], + methods: ['GET', 'POST', 'PATCH', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], + exposedHeaders: ['WWW-Authenticate', 'X-Request-Id'], + credentials: true, + maxAgeSeconds: 600 +}); +``` + +`ServerCorsOptions` is exported from `@cleverbrush/server`. The policy is +validated and copied before listening. Origins are exact serialized URL origins +(scheme, host and optional port, without a path or trailing slash). The special +origin string `null` can be included explicitly for opaque browser origins. + +| Option | Behavior / default | +| --- | --- | +| `origin` | Required: an exact origin, readonly origin list, `'*'`, or `(origin: string) => boolean \| Promise`. Empty lists deny all origins. | +| `methods` | Optional preflight allowlist, intersected with the registered routes. By default, any method registered for the requested URL may be preflighted. | +| `allowedHeaders` | Explicit preflight request-header names; case-insensitive, default `[]`. Include `Authorization` or custom auth headers when needed. | +| `exposedHeaders` | Additional response-header names browsers may read; default `[]`. | +| `credentials` | Default `false`. With `true`, an exact accepted origin is returned. `origin: '*'` with credentials is rejected at startup. | +| `maxAgeSeconds` | Non-negative integer browser preflight cache duration, default `0`. | + +Method and header lists use explicit names; wildcard entries are not supported. +`origin: '*'` explicitly permits all valid origins, including opaque `null` +origins, without credential support. CORS is disabled until `useCors` is called. + +For domains determined at request time, use a predicate backed by application +configuration or a domain registry: + +```ts +server.useCors({ + origin: async origin => tenantDomains.isAllowed(origin), + allowedHeaders: ['Content-Type', 'Authorization'] +}); +``` + +The predicate is awaited once per physical HTTP request carrying a valid `Origin`, +including preflights; it is not called for requests without `Origin`. Results are +not cached by the server. Browser preflight caching follows `maxAgeSeconds`. +Returning `false` rejects the request with `403` before authentication or handlers. +Throwing or rejecting returns a generic `500` without exposing the callback error. +Calling `useCors` again replaces the policy for subsequently started servers. + +### Execution order and responses + +CORS runs before routing, body parsing, DI scopes and ordinary middleware, +regardless of where `useCors` appears in the builder chain. An `OPTIONS` request +with both `Origin` and `Access-Control-Request-Method` is a preflight. Accepted +preflights return an empty `204` without running authentication or handlers. +Only registered HTTP routes and enabled health/batch endpoints are eligible. +Malformed preflights return `400`, unknown routes `404`, unsupported route methods +`405`, and policy denials `403`. Denied preflights have no CORS permission headers. +Ordinary `OPTIONS` requests still use registered handlers and normal routing. + +Actual requests from accepted origins follow the existing middleware and +authentication pipeline. CORS headers accompany successful and error responses, +including authentication challenges, validation failures and routing errors. +Disallowed or malformed origins receive `403` before handlers run. Requests +without `Origin` retain normal processing and receive no CORS permission headers. +Authentication remains responsible for resource access; the method/header lists +govern browser preflight permission, not ordinary HTTP routing. + +When enabled, the CORS stage owns its six standard response headers and finalizes +them as headers are sent, including raw/streamed responses and cache/idempotency +replays. Configure CORS through this API rather than writing competing CORS headers +in middleware. Existing `Vary` values are preserved and merged with `Origin`, plus +the requested method/header fields on preflights. Cached CORS permissions are not +reused for another origin. Virtual batch subrequests retain their usual auth +pipeline; CORS applies to the outer HTTP request. WebSocket upgrades are outside +this policy. Ordinary middleware, including middleware-based tracing, does not run +for CORS short-circuits. + ## Large APIs and shared error handling `implement` adds server-only configuration and complete handler registration to diff --git a/libs/server/src/Cors.test-d.ts b/libs/server/src/Cors.test-d.ts new file mode 100644 index 00000000..c7693d69 --- /dev/null +++ b/libs/server/src/Cors.test-d.ts @@ -0,0 +1,41 @@ +import { expectTypeOf, it } from 'vitest'; +import { + createServer, + ServerBuilder, + type ServerCorsOptions +} from './index.js'; + +it('supports exact, readonly and asynchronous origin policies through the public builder', () => { + const options = { + origin: ['https://app.example.test'], + methods: ['POST'], + allowedHeaders: ['authorization'], + exposedHeaders: ['x-request-id'], + credentials: true, + maxAgeSeconds: 600 + } as const satisfies ServerCorsOptions; + expectTypeOf( + createServer().useCors(options) + ).toEqualTypeOf(); + new ServerBuilder().useCors({ origin: '*' }); + new ServerBuilder().useCors({ origin: 'https://app.example.test' }); + new ServerBuilder().useCors({ + origin: value => value.endsWith('.example.test') + }); + new ServerBuilder().useCors({ + origin: async value => { + expectTypeOf(value).toEqualTypeOf(); + return true; + } + }); + // @ts-expect-error An explicit origin policy is required. + new ServerBuilder().useCors({}); + // @ts-expect-error CORS cannot be enabled without configuration. + new ServerBuilder().useCors(); + new ServerBuilder().useCors({ + // @ts-expect-error Origin callbacks decide permission with a boolean. + origin: async () => 'https://app.example.test' + }); + // @ts-expect-error Allowed headers are an explicit list, not a string policy. + new ServerBuilder().useCors({ origin: '*', allowedHeaders: '*' }); +}); diff --git a/libs/server/src/Cors.test.ts b/libs/server/src/Cors.test.ts new file mode 100644 index 00000000..c919429c --- /dev/null +++ b/libs/server/src/Cors.test.ts @@ -0,0 +1,139 @@ +import { IncomingMessage, ServerResponse } from 'node:http'; +import { Socket } from 'node:net'; +import { describe, expect, it, vi } from 'vitest'; +import { CorsPolicy, type ServerCorsOptions } from './Cors.js'; + +function exchange(headers: IncomingMessage['headers'] = {}) { + const req = new IncomingMessage(new Socket()); + req.method = 'OPTIONS'; + req.url = '/records'; + req.headers = headers; + return { req, res: new ServerResponse(req) }; +} + +const origin = 'https://app.example.test'; +const match = () => ({ status: 200 as const }); + +describe('CORS policy configuration', () => { + it.each([ + {}, + { origin: true }, + { origin: '' }, + { origin: 'https://app.example.test/' }, + { origin: 'https://user:password@app.example.test' }, + { origin: ['*'] }, + { origin: ['https://app.example.test', 42] }, + { origin: '*', credentials: true }, + { origin, credentials: 'true' }, + { origin, methods: ['*'] }, + { origin, methods: ['POST, DELETE'] }, + { origin, methods: ['POST\n'] }, + { origin, allowedHeaders: ['*'] }, + { origin, allowedHeaders: 'authorization' }, + { origin, exposedHeaders: ['x-test\r\nx-other'] }, + { origin, exposedHeaders: ['*'] }, + { origin, exposedHeaders: ['x-test\n'] }, + { origin, maxAgeSeconds: -1 }, + { origin, maxAgeSeconds: 1.5 }, + { origin, maxAgeSeconds: Infinity }, + { origin, maxAgeSeconds: NaN }, + { origin, maxAgeSeconds: null }, + { origin, maxAgeSeconds: '600' } + ])('rejects invalid configuration %j', options => { + expect(() => new CorsPolicy(options as ServerCorsOptions)).toThrow( + TypeError + ); + }); + + it('copies configured arrays so later mutation cannot expand permission', async () => { + const options = { + origin: [origin], + methods: ['POST'], + allowedHeaders: ['Authorization'], + exposedHeaders: ['X-Request-Id'] + }; + const policy = new CorsPolicy(options); + options.origin.push('https://untrusted.example.test'); + options.methods.push('DELETE'); + options.allowedHeaders.push('x-other'); + options.exposedHeaders.push('x-secret'); + const allowed = exchange({ + origin, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'authorization' + }); + expect(await policy.handle(allowed.req, allowed.res, match)).toBe(true); + expect(allowed.res.statusCode).toBe(204); + for (const headers of [ + { origin: 'https://untrusted.example.test' }, + { origin, 'access-control-request-method': 'DELETE' }, + { + origin, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'x-other' + } + ]) { + const { req, res } = exchange(headers); + await policy.handle(req, res, match); + expect(res.statusCode).toBe(403); + } + const actual = exchange({ origin }); + actual.req.method = 'GET'; + expect(await policy.handle(actual.req, actual.res, match)).toBe(false); + actual.res.end(); + expect(actual.res.getHeader('access-control-expose-headers')).toBe( + 'x-request-id' + ); + }); +}); + +describe('CORS policy parsing', () => { + it.each([ + '', + 'https://app.example.test, https://other.example.test', + 'https://app.example.test/path', + 'null https://app.example.test' + ])('rejects malformed Origin without calling a predicate: %s', async value => { + const predicate = vi.fn(() => true); + const { req, res } = exchange({ origin: value }); + await new CorsPolicy({ origin: predicate }).handle(req, res, match); + expect(res.statusCode).toBe(403); + expect(predicate).not.toHaveBeenCalled(); + expect(res.getHeader('access-control-allow-origin')).toBeUndefined(); + }); + it.each([ + { 'access-control-request-method': '' }, + { 'access-control-request-method': 'POST, DELETE' }, + { 'access-control-request-method': '*' }, + { + 'access-control-request-method': 'POST', + 'access-control-request-headers': '' + }, + { + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'x-one,,x-two' + }, + { + 'access-control-request-method': 'POST', + 'access-control-request-headers': '*' + }, + { + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'x bad' + } + ])('rejects malformed preflight fields %j', async headers => { + const { req, res } = exchange({ origin, ...headers }); + const route = vi.fn(match); + await new CorsPolicy({ origin }).handle(req, res, route); + expect(res.statusCode).toBe(400); + expect(route).not.toHaveBeenCalled(); + expect(res.getHeader('access-control-allow-origin')).toBeUndefined(); + }); + it('treats a non-boolean callback result as a configuration failure', async () => { + const { req, res } = exchange({ origin }); + const policy = new CorsPolicy({ origin: (() => 'yes') as any }); + await policy.handle(req, res, match); + expect(res.statusCode).toBe(500); + expect(res.getHeader('access-control-allow-origin')).toBeUndefined(); + }); +}); diff --git a/libs/server/src/Cors.ts b/libs/server/src/Cors.ts new file mode 100644 index 00000000..8e398ae9 --- /dev/null +++ b/libs/server/src/Cors.ts @@ -0,0 +1,313 @@ +import type { + IncomingMessage, + OutgoingHttpHeaders, + ServerResponse +} from 'node:http'; +import { + createProblemDetails, + PROBLEM_JSON_CONTENT_TYPE, + serializeProblemDetails +} from './ProblemDetails.js'; + +/** Server-wide CORS policy, enabled explicitly with ServerBuilder.useCors(). */ +export interface ServerCorsOptions { + /** Exact serialized origins, '*', or a predicate evaluated once per HTTP request with Origin. */ + origin: + | string + | readonly string[] + | ((origin: string) => boolean | Promise); + /** Optional preflight allowlist, restricted to registered routes. No wildcards. */ + methods?: readonly string[]; + /** Permitted preflight header names (case-insensitive). Defaults to none; no wildcards. */ + allowedHeaders?: readonly string[]; + /** Additional response headers browsers may read. Defaults to none; no wildcards. */ + exposedHeaders?: readonly string[]; + /** Allow browser credentials. Defaults to false; incompatible with origin: '*'. */ + credentials?: boolean; + /** Browser preflight cache duration in seconds. Defaults to zero. */ + maxAgeSeconds?: number; +} + +type OriginPredicate = (origin: string) => boolean | Promise; +type RouteStatus = { + status: 200 | 400 | 404 | 405; + allowedMethods?: readonly string[]; +}; + +const INVALID_TOKEN_CHARACTER = /[^!#$%&'*+\-.^_`|~\da-zA-Z]/; +const MANAGED_HEADERS = new Set([ + 'access-control-allow-origin', + 'access-control-allow-credentials', + 'access-control-allow-methods', + 'access-control-allow-headers', + 'access-control-expose-headers', + 'access-control-max-age' +]); + +function isOrigin(value: unknown): value is string { + if (typeof value !== 'string') return false; + if (value === 'null') return true; + try { + const origin = new URL(value).origin; + return origin !== 'null' && origin === value; + } catch { + return false; + } +} + +function isToken(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + !INVALID_TOKEN_CHARACTER.test(value) + ); +} + +function names(values: readonly string[] | undefined, field: string): string[] { + if (values === undefined) return []; + if ( + !Array.isArray(values) || + values.some(value => !isToken(value) || value === '*') + ) { + throw new TypeError(`CORS ${field} must contain explicit HTTP tokens`); + } + return [...new Set(values.map(value => value.toLowerCase()))]; +} + +function varyValue(values: unknown[], required: readonly string[]): string { + const tokens = new Map(); + for (const value of [...values, ...required]) { + if (value === undefined) continue; + for (const token of String(value).split(',')) { + const trimmed = token.trim(); + if (trimmed === '*') return '*'; + if (trimmed && !tokens.has(trimmed.toLowerCase())) { + tokens.set(trimmed.toLowerCase(), trimmed); + } + } + } + return [...tokens.values()].join(', '); +} + +/** Finalize only the physical response; never mutate headers held by a cache. */ +function finalizeHeaders( + res: ServerResponse, + corsHeaders: Record, + vary: readonly string[] +): void { + const writeHead = res.writeHead; + res.writeHead = function ( + this: ServerResponse, + status: number, + ...args: any[] + ) { + // Preserve both writeHead(status, headers) and (status, message, headers). + const headerIndex = + typeof args[0] === 'string' || args.length > 1 ? 1 : 0; + const headers = args[headerIndex] as + | OutgoingHttpHeaders + | string[] + | undefined; + const values: unknown[] = [this.getHeader('vary')]; + const keep = (name: string, value: unknown) => { + const lower = name.toLowerCase(); + if (lower === 'vary') values.push(value); + return lower !== 'vary' && !MANAGED_HEADERS.has(lower); + }; + if (Array.isArray(headers)) { + const copy: string[] = []; + for (let i = 0; i < headers.length; i += 2) { + if (keep(headers[i], headers[i + 1])) { + copy.push(headers[i], headers[i + 1]); + } + } + args[headerIndex] = copy; + } else if (headers !== undefined) { + args[headerIndex] = Object.fromEntries( + Object.entries(headers).filter(([name, value]) => + keep(name, value) + ) + ); + } + for (const name of MANAGED_HEADERS) this.removeHeader(name); + for (const [name, value] of Object.entries(corsHeaders)) { + this.setHeader(name, value); + } + this.setHeader('vary', varyValue(values, vary)); + return writeHead.call(this, status, ...args); + } as ServerResponse['writeHead']; +} + +function problem( + res: ServerResponse, + status: number, + allow?: readonly string[] +): true { + res.writeHead(status, { + 'content-type': PROBLEM_JSON_CONTENT_TYPE, + ...(allow ? { allow: allow.join(', ') } : {}) + }); + res.end(serializeProblemDetails(createProblemDetails(status))); + return true; +} + +/** @internal Compiled CORS policy; deliberately separate from route middleware. */ +export class CorsPolicy { + readonly #origin: '*' | ReadonlySet | OriginPredicate; + readonly #methods?: ReadonlySet; + readonly #allowedHeaders: ReadonlySet; + readonly #exposedHeaders: string; + readonly #credentials: boolean; + readonly #maxAgeSeconds: number; + + constructor(options: ServerCorsOptions) { + const origin = options?.origin; + if (origin === '*') this.#origin = '*'; + else if (typeof origin === 'function') this.#origin = origin; + else { + const origins = typeof origin === 'string' ? [origin] : origin; + if ( + !Array.isArray(origins) || + origins.some(value => !isOrigin(value)) + ) { + throw new TypeError( + 'CORS origin must be a serialized origin, list, wildcard or predicate' + ); + } + this.#origin = new Set(origins); + } + if ( + options.credentials !== undefined && + typeof options.credentials !== 'boolean' + ) { + throw new TypeError('CORS credentials must be a boolean'); + } + this.#credentials = options.credentials ?? false; + if (this.#credentials && this.#origin === '*') { + throw new TypeError( + 'CORS credentials require explicit origins or a predicate' + ); + } + this.#maxAgeSeconds = + options.maxAgeSeconds === undefined ? 0 : options.maxAgeSeconds; + if ( + !Number.isSafeInteger(this.#maxAgeSeconds) || + this.#maxAgeSeconds < 0 + ) { + throw new TypeError( + 'CORS maxAgeSeconds must be a non-negative safe integer' + ); + } + const methods = names(options.methods, 'methods'); + if (options.methods !== undefined) { + this.#methods = new Set( + methods.map(method => method.toUpperCase()) + ); + } + this.#allowedHeaders = new Set( + names(options.allowedHeaders, 'allowedHeaders') + ); + this.#exposedHeaders = names( + options.exposedHeaders, + 'exposedHeaders' + ).join(', '); + } + + /** Returns true when CORS has ended the response, false to run the usual pipeline. */ + async handle( + req: IncomingMessage, + res: ServerResponse, + matchRoute: (method: string, path: string) => RouteStatus + ): Promise { + const origin = req.headers.origin; + const preflight = + req.method?.toUpperCase() === 'OPTIONS' && + origin !== undefined && + req.headers['access-control-request-method'] !== undefined; + const headers: Record = {}; + finalizeHeaders( + res, + headers, + preflight + ? [ + 'Origin', + 'Access-Control-Request-Method', + 'Access-Control-Request-Headers' + ] + : ['Origin'] + ); + if (origin === undefined) return false; + if (!isOrigin(origin)) return problem(res, 403); + + let allowed: boolean; + try { + allowed = + this.#origin === '*' || + (typeof this.#origin === 'function' + ? await this.#origin(origin) + : this.#origin.has(origin)); + if (typeof allowed !== 'boolean') return problem(res, 500); + } catch { + return problem(res, 500); + } + if (!allowed) return problem(res, 403); + + if (preflight) { + const method = req.headers['access-control-request-method']; + const requested = req.headers['access-control-request-headers']; + if ( + !isToken(method) || + method === '*' || + (requested !== undefined && typeof requested !== 'string') + ) { + return problem(res, 400); + } + const requestedHeaders = + requested === undefined + ? [] + : requested + .split(',') + .map(name => name.trim().toLowerCase()); + if (requestedHeaders.some(name => !isToken(name) || name === '*')) { + return problem(res, 400); + } + let path: string; + try { + path = new URL( + req.url ?? '/', + `http://${req.headers.host ?? 'localhost'}` + ).pathname; + } catch { + return problem(res, 400); + } + const result = matchRoute(method.toUpperCase(), path); + if (result.status !== 200) + return problem(res, result.status, result.allowedMethods); + if ( + (this.#methods && !this.#methods.has(method.toUpperCase())) || + requestedHeaders.some(name => !this.#allowedHeaders.has(name)) + ) { + return problem(res, 403); + } + headers['access-control-allow-methods'] = method; + if (requestedHeaders.length) { + headers['access-control-allow-headers'] = [ + ...new Set(requestedHeaders) + ].join(', '); + } + headers['access-control-max-age'] = String(this.#maxAgeSeconds); + } else if (this.#exposedHeaders) { + headers['access-control-expose-headers'] = this.#exposedHeaders; + } + headers['access-control-allow-origin'] = + this.#origin === '*' ? '*' : origin; + if (this.#credentials) + headers['access-control-allow-credentials'] = 'true'; + if (preflight) { + res.writeHead(204); + res.end(); + return true; + } + return false; + } +} diff --git a/libs/server/src/Server.cors.test.ts b/libs/server/src/Server.cors.test.ts new file mode 100644 index 00000000..90df54bc --- /dev/null +++ b/libs/server/src/Server.cors.test.ts @@ -0,0 +1,682 @@ +import { Readable } from 'node:stream'; +import { type AuthenticationScheme, Principal } from '@cleverbrush/auth'; +import { number, object, string } from '@cleverbrush/schema'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ActionResult } from './ActionResult.js'; +import { endpoint } from './Endpoint.js'; +import { HttpError } from './HttpError.js'; +import { idempotency } from './middlewares/Idempotency.js'; +import { cacheResponse } from './middlewares/ResponseCache.js'; +import { route } from './route.js'; +import { type Server, ServerBuilder } from './Server.js'; + +const origin = 'https://app.example.test'; +const otherOrigin = 'https://other.example.test'; +const servers: Server[] = []; + +async function start(builder: ServerBuilder) { + const server = await builder.listen(0, '127.0.0.1'); + servers.push(server); + return `http://127.0.0.1:${server.address!.port}`; +} + +async function send(url: string, init?: RequestInit) { + const response = await fetch(url, init); + return { + status: response.status, + headers: response.headers, + body: await response.text() + }; +} + +function preflight(url: string, method = 'POST', headers?: string) { + return send(url, { + method: 'OPTIONS', + headers: { + origin, + 'access-control-request-method': method, + ...(headers === undefined + ? {} + : { 'access-control-request-headers': headers }) + } + }); +} + +function authentication() { + const scheme: AuthenticationScheme = { + name: 'token', + authenticate: vi.fn(async ctx => + ctx.headers.authorization === 'Bearer token' + ? { + succeeded: true, + principal: new Principal(true, { id: 'reader' }) + } + : { succeeded: false } + ), + challenge: () => ({ + headerName: 'WWW-Authenticate', + headerValue: 'Bearer' + }) + }; + return { defaultScheme: 'token', schemes: [scheme] }; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.close())); + vi.restoreAllMocks(); +}); + +describe('HTTP CORS preflights', () => { + it('leaves routing, middleware and headers unchanged when disabled', async () => { + const handler = vi.fn(() => ({ ok: true })); + const middleware = vi.fn(async (_ctx, next) => next()); + const url = await start( + new ServerBuilder() + .use(middleware) + .handle(endpoint.post('/records'), handler) + ); + const before = await preflight(`${url}/records`); + expect(before.status).toBe(405); + expect(before.headers.get('allow')).toBe('POST'); + expect(before.headers.get('access-control-allow-origin')).toBeNull(); + expect(before.headers.get('vary')).toBeNull(); + expect(middleware).not.toHaveBeenCalled(); + const actual = await send(`${url}/records`, { + method: 'POST', + headers: { origin } + }); + expect(actual.status).toBe(200); + expect(actual.headers.get('access-control-allow-origin')).toBeNull(); + expect(middleware).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('accepts preflight before auth but still authenticates actual protected requests', async () => { + const auth = authentication(); + const handler = vi.fn(() => + ActionResult.ok({ ok: true }, { 'x-request-id': 'request-1' }) + ); + const middleware = vi.fn(async (_ctx, next) => next()); + const url = await start( + new ServerBuilder() + .use(middleware) + .useAuthentication(auth) + .useAuthorization() + .useCors({ + origin, + methods: ['POST'], + allowedHeaders: ['Authorization', 'Content-Type'], + exposedHeaders: ['X-Request-Id', 'WWW-Authenticate'], + credentials: true, + maxAgeSeconds: 600 + }) + .handle(endpoint.post('/records').authorize(), handler) + ); + const before = await preflight( + `${url}/records`, + 'POST', + 'AUTHORIZATION, content-TYPE, authorization' + ); + expect(before.status).toBe(204); + expect(before.body).toBe(''); + expect(before.headers.get('access-control-allow-origin')).toBe(origin); + expect(before.headers.get('access-control-allow-credentials')).toBe( + 'true' + ); + expect(before.headers.get('access-control-allow-methods')).toBe('POST'); + expect(before.headers.get('access-control-allow-headers')).toBe( + 'authorization, content-type' + ); + expect(before.headers.get('access-control-max-age')).toBe('600'); + expect(before.headers.get('access-control-expose-headers')).toBeNull(); + expect(before.headers.get('vary')).toBe( + 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers' + ); + expect(auth.schemes[0].authenticate).not.toHaveBeenCalled(); + expect(middleware).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + const denied = await send(`${url}/records`, { + method: 'POST', + headers: { origin } + }); + expect(denied.status).toBe(401); + expect(denied.headers.get('access-control-allow-origin')).toBe(origin); + expect(denied.headers.get('www-authenticate')).toBe('Bearer'); + expect(denied.headers.get('access-control-expose-headers')).toBe( + 'x-request-id, www-authenticate' + ); + expect(handler).not.toHaveBeenCalled(); + const accepted = await send(`${url}/records`, { + method: 'POST', + headers: { origin, authorization: 'Bearer token' } + }); + expect(accepted.status).toBe(200); + expect(accepted.headers.get('access-control-allow-credentials')).toBe( + 'true' + ); + expect(accepted.headers.get('access-control-allow-methods')).toBeNull(); + expect(accepted.headers.get('x-request-id')).toBe('request-1'); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('rejects disallowed origins before authentication and handlers on actual and preflight requests', async () => { + const auth = authentication(); + const handler = vi.fn(() => ({ ok: true })); + const url = await start( + new ServerBuilder() + .useCors({ origin }) + .useAuthentication(auth) + .useAuthorization() + .handle(endpoint.post('/records').authorize(), handler) + ); + for (const method of ['POST', 'OPTIONS']) { + const response = await send(`${url}/records`, { + method, + headers: { + origin: otherOrigin, + 'access-control-request-method': 'POST', + authorization: 'Bearer token' + } + }); + expect(response.status).toBe(403); + expect( + response.headers.get('access-control-allow-origin') + ).toBeNull(); + expect( + response.headers.get('access-control-allow-methods') + ).toBeNull(); + expect(JSON.parse(response.body).status).toBe(403); + } + expect(auth.schemes[0].authenticate).not.toHaveBeenCalled(); + expect(handler).not.toHaveBeenCalled(); + }); + + it('restricts preflight methods and headers without changing ordinary routing', async () => { + const url = await start( + new ServerBuilder() + .useCors({ + origin, + methods: ['post'], + allowedHeaders: ['content-type'] + }) + .handle(endpoint.post('/records'), () => ({})) + .handle(endpoint.delete('/records'), () => ({})) + ); + const deniedMethod = await preflight(`${url}/records`, 'DELETE'); + const deniedHeader = await preflight( + `${url}/records`, + 'POST', + 'authorization' + ); + for (const response of [deniedMethod, deniedHeader]) { + expect(response.status).toBe(403); + expect( + response.headers.get('access-control-allow-origin') + ).toBeNull(); + } + const allowed = await preflight( + `${url}/records`, + 'POST', + 'Content-Type' + ); + expect(allowed.status).toBe(204); + expect(allowed.headers.get('access-control-max-age')).toBe('0'); + expect( + allowed.headers.get('access-control-allow-credentials') + ).toBeNull(); + expect( + ( + await send(`${url}/records`, { + method: 'DELETE', + headers: { origin } + }) + ).status + ).toBe(200); + }); + + it('matches typed routes and distinguishes malformed URLs, missing paths and unsupported methods', async () => { + const url = await start( + new ServerBuilder() + .useCors({ origin }) + .handle( + endpoint + .resource('/records') + .post(route({ id: number().coerce() })`/${p => p.id}`), + () => ({}) + ) + ); + expect((await preflight(`${url}/records/12?view=full`)).status).toBe( + 204 + ); + for (const [path, method, status] of [ + ['/records/12', 'DELETE', 405], + ['/records/12', 'HEAD', 405], + ['/records/not-a-number', 'POST', 404], + ['/missing', 'POST', 404], + ['/records/%ZZ', 'POST', 400] + ] as const) { + const response = await preflight(`${url}${path}`, method); + expect(response.status).toBe(status); + expect( + response.headers.get('access-control-allow-origin') + ).toBeNull(); + if (status === 405) + expect(response.headers.get('allow')).toBe('POST'); + } + }); + + it('preserves ordinary OPTIONS handlers and intercepts only recognized preflights', async () => { + const handler = vi.fn(() => ActionResult.status(202)); + const url = await start( + new ServerBuilder() + .useCors({ origin }) + .handle(endpoint.options('/records'), handler) + .handle(endpoint.post('/records'), () => ({})) + ); + expect( + ( + await send(`${url}/records`, { + method: 'OPTIONS', + headers: { origin } + }) + ).status + ).toBe(202); + expect( + ( + await send(`${url}/records`, { + method: 'OPTIONS', + headers: { 'access-control-request-method': 'POST' } + }) + ).status + ).toBe(202); + expect((await preflight(`${url}/records`)).status).toBe(204); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it('includes enabled health and batch routes using their actual configured paths', async () => { + const url = await start( + new ServerBuilder() + .useCors({ origin, allowedHeaders: ['content-type'] }) + .withHealthcheck() + .useBatching({ path: '/batch' }) + ); + expect((await preflight(`${url}/health`, 'GET')).status).toBe(204); + expect( + (await preflight(`${url}/batch`, 'POST', 'content-type')).status + ).toBe(204); + expect((await preflight(`${url}/__batch`)).status).toBe(404); + const wrong = await preflight(`${url}/health`, 'POST'); + expect(wrong.status).toBe(405); + expect(wrong.headers.get('allow')).toBe('GET'); + expect( + (await send(`${url}/health`, { headers: { origin } })).headers.get( + 'access-control-allow-origin' + ) + ).toBe(origin); + const invalidBatch = await send(`${url}/batch`, { + method: 'POST', + headers: { origin, 'content-type': 'application/json' }, + body: '{}' + }); + expect(invalidBatch.status).toBe(400); + expect(invalidBatch.headers.get('access-control-allow-origin')).toBe( + origin + ); + }); +}); + +describe('HTTP CORS origin policies', () => { + it.each([ + false, + true + ])('evaluates %s async predicates on every request and skips absent origins', async asynchronous => { + let allowed = true; + const predicate = vi.fn((value: string) => + asynchronous + ? Promise.resolve(allowed && value === origin) + : allowed && value === origin + ); + const handler = vi.fn(() => ({})); + const url = await start( + new ServerBuilder() + .useCors({ origin: predicate }) + .handle(endpoint.get('/records'), handler) + ); + expect((await preflight(`${url}/records`, 'GET')).status).toBe(204); + expect( + (await send(`${url}/records`, { headers: { origin } })).status + ).toBe(200); + allowed = false; + expect((await preflight(`${url}/records`, 'GET')).status).toBe(403); + expect( + (await send(`${url}/records`, { headers: { origin } })).status + ).toBe(403); + const absent = await send(`${url}/records`); + expect(absent.status).toBe(200); + expect(absent.headers.get('access-control-allow-origin')).toBeNull(); + expect(absent.headers.get('vary')).toBe('Origin'); + expect(predicate).toHaveBeenCalledTimes(4); + expect(handler).toHaveBeenCalledTimes(2); + }); + + it.each([ + false, + true + ])('fails closed with a generic 500 on %s async callback errors', async asynchronous => { + const handler = vi.fn(() => ({})); + const predicate = () => { + const error = new HttpError(418, 'private origin lookup failure'); + if (asynchronous) return Promise.reject(error); + throw error; + }; + const url = await start( + new ServerBuilder() + .useCors({ origin: predicate }) + .handle(endpoint.post('/records'), handler) + ); + for (const response of [ + await preflight(`${url}/records`), + await send(`${url}/records`, { + method: 'POST', + headers: { origin } + }) + ]) { + expect(response.status).toBe(500); + expect( + response.headers.get('access-control-allow-origin') + ).toBeNull(); + expect(response.body).not.toContain('private'); + } + expect(handler).not.toHaveBeenCalled(); + }); + + it('supports explicit public wildcard and opaque-origin policies', async () => { + const wildcard = await start( + new ServerBuilder() + .useCors({ origin: '*' }) + .handle(endpoint.get('/records'), () => ({})) + ); + const response = await send(`${wildcard}/records`, { + headers: { origin } + }); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + expect( + response.headers.get('access-control-allow-credentials') + ).toBeNull(); + const opaque = await start( + new ServerBuilder() + .useCors({ origin: ['null'] }) + .handle(endpoint.get('/records'), () => ({})) + ); + expect( + ( + await send(`${opaque}/records`, { headers: { origin: 'null' } }) + ).headers.get('access-control-allow-origin') + ).toBe('null'); + expect( + (await send(`${opaque}/records`, { headers: { origin } })).status + ).toBe(403); + }); + + it('validates policy before opening a listening socket', async () => { + await expect( + new ServerBuilder() + .useCors({ origin: '*', credentials: true }) + .listen(0, '127.0.0.1') + ).rejects.toThrow('credentials require'); + }); + + it('evaluates only the physical batch origin while subrequests retain authentication', async () => { + const predicate = vi.fn((value: string) => value === origin); + const handler = vi.fn(() => ({ ok: true })); + const url = await start( + new ServerBuilder() + .useCors({ origin: predicate }) + .useBatching() + .useAuthentication(authentication()) + .useAuthorization() + .handle(endpoint.get('/private').authorize(), handler) + ); + const response = await send(`${url}/__batch`, { + method: 'POST', + headers: { origin, 'content-type': 'application/json' }, + body: JSON.stringify({ + requests: [ + { + method: 'GET', + url: '/private', + headers: { origin: otherOrigin } + }, + { + method: 'GET', + url: '/private', + headers: { + origin: otherOrigin, + authorization: 'Bearer token' + } + } + ] + }) + }); + expect(response.status).toBe(200); + expect(response.headers.get('access-control-allow-origin')).toBe( + origin + ); + const { responses } = JSON.parse(response.body); + expect(responses.map((item: any) => item.status)).toEqual([401, 200]); + for (const item of responses) + expect(item.headers['access-control-allow-origin']).toBeUndefined(); + expect(predicate).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); + +describe('HTTP CORS response headers', () => { + it('preserves CORS on validation, authorization, route and server errors', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const url = await start( + new ServerBuilder() + .useCors({ origin }) + .useAuthentication(authentication()) + .useAuthorization() + .handle( + endpoint.post('/records').body(object({ name: string() })), + () => ({}) + ) + .handle(endpoint.get('/admin').authorize('admin'), () => ({})) + .handle(endpoint.get('/limited'), () => { + throw new HttpError(429, 'Too Many Requests'); + }) + .handle(endpoint.get('/failure'), () => { + throw new Error('private handler failure'); + }) + ); + for (const [path, init, status] of [ + [ + '/records', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }, + 400 + ], + [ + '/records', + { + method: 'POST', + headers: { 'content-type': 'application/x-unknown' }, + body: 'body' + }, + 415 + ], + ['/records', { method: 'GET' }, 405], + ['/missing', {}, 404], + ['/admin', {}, 401], + ['/admin', { headers: { authorization: 'Bearer token' } }, 403], + ['/limited', {}, 429], + ['/failure', {}, 500] + ] as const) { + const response = await send(`${url}${path}`, { + ...init, + headers: { origin, ...('headers' in init ? init.headers : {}) } + }); + expect(response.status).toBe(status); + expect(response.headers.get('access-control-allow-origin')).toBe( + origin + ); + expect(response.headers.get('vary')).toBe('Origin'); + expect(response.body).not.toContain('private handler failure'); + } + }); + + it.each([ + 'implicit', + 'object', + 'array', + 'message-object', + 'message-array' + ])('merges Vary and preserves cookies with native %s header writes', async mode => { + const url = await start( + new ServerBuilder() + .useCors({ origin }) + .handle(endpoint.get('/records'), () => + ActionResult.raw((_req, res) => { + res.setHeader('vary', 'Accept-Encoding, origin'); + res.setHeader('access-control-allow-origin', '*'); + res.setHeader( + 'access-control-allow-credentials', + 'true' + ); + const headers = { + Vary: 'Accept, ORIGIN', + 'Set-Cookie': ['a=1', 'b=2'], + 'Access-Control-Allow-Origin': otherOrigin + }; + const raw = [ + 'Vary', + 'Accept, ORIGIN', + 'Set-Cookie', + 'a=1', + 'Set-Cookie', + 'b=2', + 'Access-Control-Allow-Origin', + otherOrigin + ]; + if (mode === 'object') res.writeHead(202, headers); + else if (mode === 'array') res.writeHead(202, raw); + else if (mode === 'message-object') + res.writeHead(202, 'Accepted', headers); + else if (mode === 'message-array') + res.writeHead(202, 'Accepted', raw); + else { + res.statusCode = 202; + res.setHeader('set-cookie', ['a=1', 'b=2']); + res.setHeader( + 'vary', + 'Accept-Encoding, origin, Accept' + ); + } + res.end('raw'); + }) + ) + ); + const response = await send(`${url}/records`, { headers: { origin } }); + expect(response.status).toBe(202); + expect(response.body).toBe('raw'); + expect(response.headers.get('access-control-allow-origin')).toBe( + origin + ); + expect( + response.headers.get('access-control-allow-credentials') + ).toBeNull(); + expect(response.headers.get('vary')).toBe( + 'Accept-Encoding, origin, Accept' + ); + expect(response.headers.getSetCookie()).toEqual(['a=1', 'b=2']); + }); + + it('preserves Vary wildcard and CORS on streamed results', async () => { + const url = await start( + new ServerBuilder() + .useCors({ origin }) + .use(async (ctx, next) => { + ctx.response.setHeader('vary', '*'); + await next(); + }) + .handle(endpoint.get('/stream'), () => + ActionResult.stream( + Readable.from(['streamed']), + 'text/plain' + ) + ) + ); + const response = await send(`${url}/stream`, { headers: { origin } }); + expect(response.body).toBe('streamed'); + expect(response.headers.get('vary')).toBe('*'); + expect(response.headers.get('access-control-allow-origin')).toBe( + origin + ); + }); + + it.each([ + 'cache', + 'idempotency' + ])('recomputes CORS on %s replay without mutating stored headers', async mode => { + let calls = 0; + const savedHeaders = Object.freeze({ + 'content-type': 'text/plain', + Vary: 'Accept', + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true' + }); + const url = await start( + new ServerBuilder() + .useCors({ origin: [origin, otherOrigin] }) + .handle( + mode === 'cache' + ? endpoint.get('/records').cacheTag('records') + : endpoint.post('/records'), + () => + ActionResult.raw((_req, res) => { + calls++; + res.writeHead(200, savedHeaders); + res.end('saved body'); + }), + { + middlewares: [ + mode === 'cache' ? cacheResponse() : idempotency() + ] + } + ) + ); + for (const value of [origin, otherOrigin, undefined]) { + const response = await send(`${url}/records`, { + method: mode === 'cache' ? 'GET' : 'POST', + headers: { + ...(value ? { origin: value } : {}), + 'x-idempotency-key': 'same-key' + } + }); + expect(response.body).toBe('saved body'); + expect(response.headers.get('access-control-allow-origin')).toBe( + value ?? null + ); + expect( + response.headers.get('access-control-allow-credentials') + ).toBeNull(); + expect(response.headers.get('vary')).toBe('Accept, Origin'); + } + const denied = await send(`${url}/records`, { + method: mode === 'cache' ? 'GET' : 'POST', + headers: { + origin: 'https://denied.example.test', + 'x-idempotency-key': 'same-key' + } + }); + expect(denied.status).toBe(403); + expect(denied.headers.get('access-control-allow-origin')).toBeNull(); + expect(savedHeaders.Vary).toBe('Accept'); + expect(calls).toBe(1); + }); +}); diff --git a/libs/server/src/Server.ts b/libs/server/src/Server.ts index ee9546da..195af3c4 100644 --- a/libs/server/src/Server.ts +++ b/libs/server/src/Server.ts @@ -17,6 +17,7 @@ import { ServiceCollection, type ServiceProvider } from '@cleverbrush/di'; import { type WebSocket, WebSocketServer } from 'ws'; import { ActionResult, JsonResult } from './ActionResult.js'; import { ContentNegotiator } from './ContentNegotiator.js'; +import { CorsPolicy, type ServerCorsOptions } from './Cors.js'; import type { EndpointBuilder, Handler, HandlerMapping } from './Endpoint.js'; import { HttpError } from './HttpError.js'; import { MiddlewarePipeline } from './MiddlewarePipeline.js'; @@ -120,6 +121,7 @@ export class ServerBuilder { #authzConfig: AuthorizationConfig | null = null; #healthcheck = false; #batchConfig: ServerBatchingOptions | null = null; + #corsOptions?: ServerCorsOptions; constructor(options: ServerOptions = {}) { this.#options = options; @@ -136,14 +138,29 @@ export class ServerBuilder { } /** - * Add a global middleware that runs for every request. - * Middleware is executed in the order it is added. + * Add middleware to the matched-request pipeline after authentication. + * Middleware is executed in the order it is added. CORS short-circuits + * and unmatched routes do not enter this pipeline. */ use(middleware: Middleware): this { this.#globalMiddlewares.push(middleware); return this; } + /** + * Enable server-wide CORS before routing and authentication. + * Accepted preflights return 204 without running ordinary middleware. + * Requests with disallowed origins return 403 before handlers run. + * Configuration is validated and copied when the server starts listening. + */ + useCors(options: ServerCorsOptions): this { + if (options === undefined) { + throw new TypeError('CORS requires an explicit origin policy'); + } + this.#corsOptions = options; + return this; + } + /** * Register an additional content type handler for content negotiation. * JSON is registered by default. @@ -155,8 +172,8 @@ export class ServerBuilder { /** * Enable authentication with one or more schemes. - * Registers a global middleware that authenticates every request and - * sets `ctx.principal`. + * Registers middleware that authenticates protected requests and sets + * `ctx.principal`. CORS preflights are handled before this middleware. */ useAuthentication(config: AuthenticationConfig): this { this.#authConfig = config; @@ -362,7 +379,8 @@ export class ServerBuilder { this.#healthcheck, this.#subscriptionRegistrations.length > 0, this.#batchConfig, - this.#options.maxBodySize + this.#options.maxBodySize, + this.#corsOptions ); const listenPort = port ?? this.#options.port ?? 3000; @@ -390,6 +408,7 @@ export class Server { readonly #hasSubscriptions: boolean; readonly #batchConfig: ServerBatchingOptions | null; readonly #maxBodySize: number; + readonly #cors?: CorsPolicy; #httpServer: http.Server | https.Server | null = null; #wss: WebSocketServer | null = null; readonly #activeConnections: Set = new Set(); @@ -402,7 +421,8 @@ export class Server { healthcheck = false, hasSubscriptions = false, batchConfig: ServerBatchingOptions | null = null, - maxBodySize: number = DEFAULT_MAX_BODY_SIZE + maxBodySize: number = DEFAULT_MAX_BODY_SIZE, + corsOptions?: ServerCorsOptions ) { this.#router = router; this.#serviceProvider = serviceProvider; @@ -412,6 +432,8 @@ export class Server { this.#hasSubscriptions = hasSubscriptions; this.#batchConfig = batchConfig; this.#maxBodySize = maxBodySize; + this.#cors = + corsOptions === undefined ? undefined : new CorsPolicy(corsOptions); } /** @@ -526,8 +548,17 @@ export class Server { async #handleRequest( req: http.IncomingMessage, - res: http.ServerResponse + res: http.ServerResponse, + physicalRequest = true ): Promise { + if ( + physicalRequest && + this.#cors && + (await this.#cors.handle(req, res, (method, path) => + this.#matchCorsRoute(method, path) + )) + ) + return; const scope = this.#serviceProvider.createScope(); try { @@ -809,6 +840,37 @@ export class Server { } } + // ----------------------------------------------------------------------- + // CORS target lookup includes built-in routes without changing normal routing. + // ----------------------------------------------------------------------- + + #matchCorsRoute( + method: string, + path: string + ): { + status: 200 | 400 | 404 | 405; + allowedMethods?: string[]; + } { + const builtins: string[] = []; + if (this.#healthcheck && path === '/health') builtins.push('GET'); + if ( + this.#batchConfig && + path === (this.#batchConfig.path ?? '/__batch') + ) { + builtins.push('POST'); + } + if (builtins.includes(method)) return { status: 200 }; + const result = this.#router.match(method, path); + if (result.badRequest) return { status: 400 }; + if (result.match) return { status: 200 }; + const allowedMethods = [ + ...new Set([...builtins, ...(result.allowedMethods ?? [])]) + ]; + return allowedMethods.length + ? { status: 405, allowedMethods } + : { status: 404 }; + } + // ----------------------------------------------------------------------- // Batch request handler // ----------------------------------------------------------------------- @@ -877,7 +939,8 @@ export class Server { await this.#handleRequest( virtualReq as unknown as http.IncomingMessage, - virtualRes as unknown as http.ServerResponse + virtualRes as unknown as http.ServerResponse, + false ); return virtualRes.toResult(); diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index a7cd4275..a0f0dfb2 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -22,6 +22,7 @@ export { formUrlEncodedContentTypeHandler, jsonContentTypeHandler } from './ContentNegotiator.js'; +export type { ServerCorsOptions } from './Cors.js'; export { type ApiContract, type ApiGroup, diff --git a/websites/docs/app/examples/page.tsx b/websites/docs/app/examples/page.tsx index d6307c42..b1134fb9 100644 --- a/websites/docs/app/examples/page.tsx +++ b/websites/docs/app/examples/page.tsx @@ -272,7 +272,10 @@ const form = useSchemaForm(CreateTodoBodySchema); configureDI(svc, config)) .useAuthentication({ defaultScheme: 'jwt', diff --git a/websites/docs/app/otel/page.tsx b/websites/docs/app/otel/page.tsx index 1f1416e0..d7b6b1be 100644 --- a/websites/docs/app/otel/page.tsx +++ b/websites/docs/app/otel/page.tsx @@ -111,16 +111,21 @@ export const otel = setupOtel({ import { createServer } from '@cleverbrush/server'; const server = createServer() - .use(tracingMiddleware({ excludePaths: ['/health'] })) // first! - .use(corsMiddleware);`) + .useCors({ + origin: 'https://app.example.com', + allowedHeaders: ['Content-Type', 'Authorization', 'traceparent', 'tracestate'] + }) + .use(tracingMiddleware({ excludePaths: ['/health'] }));`) }} />

- A SpanKind.SERVER span is opened per request, - named from the endpoint metadata (operationId{' '} - or METHOD route), and tagged with HTTP - semantic-convention attributes. Inbound W3C{' '} + CORS runs before ordinary middleware; its preflight and + rejection responses do not enter this tracing middleware. A{' '} + SpanKind.SERVER span is opened for requests + reaching the middleware, named from the endpoint metadata ( + operationId or METHOD route), and + tagged with HTTP semantic-convention attributes. Inbound W3C{' '} traceparent headers are extracted automatically.

diff --git a/websites/docs/app/server/page.tsx b/websites/docs/app/server/page.tsx index 6b085820..54f174dc 100644 --- a/websites/docs/app/server/page.tsx +++ b/websites/docs/app/server/page.tsx @@ -406,6 +406,68 @@ server.handle(GetUser, ({ params }) => { +
+

CORS and preflight requests

+

+ Enable a server-wide policy with useCors(). + CORS runs before routing and authentication, + independently of ordinary middleware registration order. +

+
+                         tenantDomains.isAllowed(origin),
+    allowedHeaders: ['Content-Type', 'Authorization']
+});`)
+                            }}
+                        />
+                    
+

+ Origins are exact URL origins without a path or trailing + slash. An explicit origin: '*' enables + public access and cannot be combined with credentials. + Predicates may be synchronous or asynchronous and run + once per HTTP request carrying Origin; results are not + cached by the server. Disallowed origins return 403 + before handlers run; predicate failures return a generic + 500. Requests without Origin continue through the normal + pipeline. +

+

+ Accepted preflights return an empty 204 without + authentication. Actual protected requests still require + authentication. Optional methods restricts + preflights to an allowlist of registered route methods. + Allowed and exposed header lists default to empty, + credentials to false, and preflight cache duration to + zero. Header names are case-insensitive; method/header + wildcards are not supported. +

+

+ Successful and error responses share the CORS policy, + including cached, raw and streamed results. Existing + Vary headers are preserved. CORS includes enabled health + and batch routes; virtual batch subrequests retain their + usual authentication. Ordinary OPTIONS requests retain + normal routing. CORS short-circuits do not run ordinary + middleware, and WebSocket upgrades are outside this + policy. +

+ + CORS options, defaults and execution order + +
+ {/* ── Middleware ───────────────────────────────────── */}

Middleware