Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cors-preflight.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 13 additions & 5 deletions docs/framework-feature-candidates.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions libs/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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<boolean>`. 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
Expand Down
41 changes: 41 additions & 0 deletions libs/server/src/Cors.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<ServerBuilder>();
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<string>();
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: '*' });
});
139 changes: 139 additions & 0 deletions libs/server/src/Cors.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading