@cleverbrush/server 0.0.0-beta-20260413195755

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # @cleverbrush/server
2
+
3
+ [![CI](https://github.com/cleverbrush/framework/actions/workflows/ci.yml/badge.svg)](https://github.com/cleverbrush/framework/actions/workflows/ci.yml)
4
+ [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](../../LICENSE)
5
+
6
+ A schema-first HTTP server framework for Node.js. Combines [`@cleverbrush/schema`](../schema) for request validation, [`@cleverbrush/di`](../di) for dependency injection, and [`@cleverbrush/auth`](../auth) for authentication — all wired together through a fluent builder API.
7
+
8
+ ## Features
9
+
10
+ - **Fluent endpoint builder** — `endpoint.get('/users').body(schema).query(schema).authorize()` with fully typed handler context.
11
+ - **Action results** — `ActionResult.ok()`, `.created()`, `.noContent()`, `.redirect()`, `.file()`, `.stream()`, `.status()` — no manual `res.write()` / `res.end()`.
12
+ - **Content negotiation** — pluggable `ContentTypeHandler` registry; JSON registered by default; honours the `Accept` request header.
13
+ - **Middleware pipeline** — `server.use(middleware)` for global middleware; per-endpoint middleware via `handle(ep, handler, { middlewares })`.
14
+ - **DI integration** — `endpoint.inject({ db: IDbContext })` resolves services per-request from a `@cleverbrush/di` container.
15
+ - **Authentication & authorization** — `server.useAuthentication()` / `server.useAuthorization()` wired to `@cleverbrush/auth` schemes and policies.
16
+ - **RFC 9457 Problem Details** — validation errors and `HttpError` subclasses are serialized as `application/problem+json`.
17
+ - **Type-safe routes** — `route()` builds typed path parameters using `ParseStringSchemaBuilder` segments.
18
+ - **OpenAPI-ready** — `getRegistrations()` exposes endpoint metadata for `@cleverbrush/server-openapi`.
19
+ - **Health check** — optional `/health` endpoint via `server.withHealthcheck()`.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @cleverbrush/server @cleverbrush/schema
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ts
30
+ import { ServerBuilder, endpoint, ActionResult } from '@cleverbrush/server';
31
+ import { object, string, number } from '@cleverbrush/schema';
32
+
33
+ const CreateUserBody = object({ name: string(), age: number() });
34
+
35
+ const createUser = endpoint
36
+ .post('/api/users')
37
+ .body(CreateUserBody);
38
+
39
+ const server = new ServerBuilder();
40
+
41
+ server.handle(createUser, ({ body }) => {
42
+ // body is fully typed: { name: string; age: number }
43
+ return ActionResult.created({ id: 1, ...body }, '/api/users/1');
44
+ });
45
+
46
+ await server.listen(3000);
47
+ ```
48
+
49
+ ## Defining Endpoints
50
+
51
+ ### HTTP Methods
52
+
53
+ Use the `endpoint` singleton to start a builder chain:
54
+
55
+ ```ts
56
+ import { endpoint } from '@cleverbrush/server';
57
+
58
+ const getUser = endpoint.get('/api/users/:id');
59
+ const postUser = endpoint.post('/api/users');
60
+ const putUser = endpoint.put('/api/users/:id');
61
+ const delUser = endpoint.delete('/api/users/:id');
62
+ ```
63
+
64
+ ### Request Validation
65
+
66
+ Attach schemas for body, query string, and headers. Validation errors automatically produce a 400 Problem Details response.
67
+
68
+ ```ts
69
+ import { object, string, number } from '@cleverbrush/schema';
70
+
71
+ const ListUsers = endpoint
72
+ .get('/api/users')
73
+ .query(object({ page: number().coerce().optional(), search: string().optional() }));
74
+
75
+ const CreateUser = endpoint
76
+ .post('/api/users')
77
+ .body(object({ name: string(), email: string() }));
78
+ ```
79
+
80
+ ### Type-Safe Path Parameters
81
+
82
+ Use `route()` to define path parameters with the full schema type system:
83
+
84
+ ```ts
85
+ import { route } from '@cleverbrush/server';
86
+ import { number } from '@cleverbrush/schema';
87
+
88
+ const GetUser = endpoint.get(
89
+ route({ id: number().coerce() })`/api/users/${t => t.id}`
90
+ );
91
+
92
+ server.handle(GetUser, ({ params }) => {
93
+ params.id; // number (already coerced from the URL)
94
+ });
95
+ ```
96
+
97
+ ### Authorization
98
+
99
+ ```ts
100
+ import { object, string } from '@cleverbrush/schema';
101
+
102
+ const UserPrincipal = object({ sub: string(), role: string() });
103
+
104
+ // Any authenticated user
105
+ const ProtectedEp = endpoint.get('/api/profile').authorize(UserPrincipal);
106
+
107
+ // Specific roles
108
+ const AdminEp = endpoint.delete('/api/users/:id').authorize(UserPrincipal, 'admin');
109
+ ```
110
+
111
+ ### OpenAPI Metadata
112
+
113
+ ```ts
114
+ const CreateUser = endpoint
115
+ .post('/api/users')
116
+ .body(CreateUserBody)
117
+ .returns(UserSchema)
118
+ .summary('Create a new user')
119
+ .description('Creates a user and returns the full record.')
120
+ .tags('users')
121
+ .operationId('createUser');
122
+ ```
123
+
124
+ ## Registering and Handling Endpoints
125
+
126
+ ```ts
127
+ const server = new ServerBuilder();
128
+
129
+ server.handle(CreateUser, ({ body, context }) => {
130
+ return ActionResult.created({ id: 42, ...body }, `/api/users/42`);
131
+ });
132
+
133
+ // Per-endpoint middleware
134
+ server.handle(AdminEp, ({ params }) => { /* … */ }, {
135
+ middlewares: [loggingMiddleware]
136
+ });
137
+
138
+ await server.listen(3000);
139
+ ```
140
+
141
+ ## Action Results
142
+
143
+ | Method | Status | Notes |
144
+ |---|---|---|
145
+ | `ActionResult.ok(body)` | 200 | Content-negotiated JSON |
146
+ | `ActionResult.created(body, location?)` | 201 | Sets `Location` header |
147
+ | `ActionResult.noContent()` | 204 | No body |
148
+ | `ActionResult.redirect(url, permanent?)` | 302 / 301 | |
149
+ | `ActionResult.json(body, status?)` | any | Forces `application/json` |
150
+ | `ActionResult.file(buffer, fileName)` | 200 | Attachment download |
151
+ | `ActionResult.content(body, contentType)` | 200 | Arbitrary string body |
152
+ | `ActionResult.stream(readable, contentType)` | 200 | Pipes a `Readable` |
153
+ | `ActionResult.status(status)` | any | Bare status, no body |
154
+
155
+ ## Middleware
156
+
157
+ ```ts
158
+ import type { Middleware } from '@cleverbrush/server';
159
+
160
+ const logger: Middleware = async (ctx, next) => {
161
+ console.log(ctx.method, ctx.url.pathname);
162
+ await next();
163
+ };
164
+
165
+ server.use(logger);
166
+ ```
167
+
168
+ ## Dependency Injection
169
+
170
+ ```ts
171
+ import { ServiceCollection } from '@cleverbrush/di';
172
+ import { object, func, string } from '@cleverbrush/schema';
173
+
174
+ const IUserRepo = object({ findById: func() });
175
+
176
+ const GetUser = endpoint
177
+ .get('/api/users/:id')
178
+ .inject({ repo: IUserRepo });
179
+
180
+ server
181
+ .services(svc => svc.addSingleton(IUserRepo, () => new UserRepository()))
182
+ .handle(GetUser, ({ params }, { repo }) => {
183
+ return repo.findById(params.id);
184
+ });
185
+ ```
186
+
187
+ ## Authentication
188
+
189
+ ```ts
190
+ import { jwtScheme } from '@cleverbrush/auth';
191
+
192
+ server.useAuthentication({
193
+ defaultScheme: 'jwt',
194
+ schemes: [
195
+ jwtScheme({
196
+ secret: process.env.JWT_SECRET!,
197
+ mapClaims: claims => ({ sub: claims.sub as string, role: claims.role as string })
198
+ })
199
+ ]
200
+ });
201
+
202
+ server.useAuthorization();
203
+ ```
204
+
205
+ ## HTTP Errors
206
+
207
+ Throw any `HttpError` subclass from a handler — it becomes a Problem Details response automatically:
208
+
209
+ ```ts
210
+ import { NotFoundError, BadRequestError, ForbiddenError } from '@cleverbrush/server';
211
+
212
+ server.handle(GetUser, ({ params }) => {
213
+ const user = db.find(params.id);
214
+ if (!user) throw new NotFoundError(`User ${params.id} not found`);
215
+ return user;
216
+ });
217
+ ```
218
+
219
+ | Class | Status |
220
+ |---|---|
221
+ | `BadRequestError` | 400 |
222
+ | `UnauthorizedError` | 401 |
223
+ | `ForbiddenError` | 403 |
224
+ | `NotFoundError` | 404 |
225
+ | `ConflictError` | 409 |
226
+ | `HttpError` | any (base class) |
227
+
228
+ ## License
229
+
230
+ BSD-3-Clause — see [LICENSE](../../LICENSE).
@@ -0,0 +1,119 @@
1
+ import type * as http from 'node:http';
2
+ import type { Readable } from 'node:stream';
3
+ import type { ContentNegotiator } from './ContentNegotiator.js';
4
+ /**
5
+ * Abstract base for all HTTP action results.
6
+ *
7
+ * Instead of writing directly to `res`, handlers return an `ActionResult`
8
+ * instance. The server calls `executeAsync()` after the middleware pipeline
9
+ * completes, ensuring consistent error handling and content negotiation.
10
+ *
11
+ * Use the static factory methods (`ActionResult.ok()`, `.created()`, etc.)
12
+ * rather than constructing subclasses directly.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * server.handle(GetUser, ({ params }) => {
17
+ * const user = db.find(params.id);
18
+ * if (!user) throw new NotFoundError();
19
+ * return ActionResult.ok(user);
20
+ * });
21
+ * ```
22
+ */
23
+ export declare abstract class ActionResult {
24
+ abstract executeAsync(req: http.IncomingMessage, res: http.ServerResponse, contentNegotiator: ContentNegotiator): Promise<void>;
25
+ /** 200 OK — serializes value using content negotiation. */
26
+ static ok(body: unknown, headers?: Record<string, string>): JsonResult;
27
+ /** 201 Created — serializes value using content negotiation. */
28
+ static created(body: unknown, location?: string, headers?: Record<string, string>): JsonResult;
29
+ /** 204 No Content. */
30
+ static noContent(): NoContentResult;
31
+ /** Temporary (302) or permanent (301) redirect. */
32
+ static redirect(url: string, permanent?: boolean): RedirectResult;
33
+ /** Explicit JSON response — always uses application/json regardless of Accept. */
34
+ static json(body: unknown, status?: number, headers?: Record<string, string>): JsonResult;
35
+ /** Send a file buffer as a download attachment. */
36
+ static file(content: Buffer | Uint8Array, fileName: string, contentType?: string): FileResult;
37
+ /** Arbitrary string body with an explicit content type. */
38
+ static content(body: string, contentType: string, status?: number): ContentResult;
39
+ /** Pipe a Readable stream to the response. */
40
+ static stream(readable: Readable, contentType: string, fileName?: string): StreamResult;
41
+ /** Bare status code with no body. */
42
+ static status(status: number, headers?: Record<string, string>): StatusCodeResult;
43
+ }
44
+ /**
45
+ * Serializes a value and writes it as JSON with `content-type: application/json`,
46
+ * bypassing content negotiation entirely.
47
+ *
48
+ * Created by `ActionResult.json()`.
49
+ * `ActionResult.ok()` and `ActionResult.created()` produce a {@link JsonResult}
50
+ * that goes through content negotiation instead.
51
+ */
52
+ export declare class JsonResult extends ActionResult {
53
+ readonly body: unknown;
54
+ readonly status: number;
55
+ readonly headers: Record<string, string>;
56
+ constructor(body: unknown, status?: number, headers?: Record<string, string>);
57
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
58
+ }
59
+ /**
60
+ * Sends a binary buffer as a file download attachment.
61
+ * Created by `ActionResult.file()`.
62
+ */
63
+ export declare class FileResult extends ActionResult {
64
+ readonly content: Buffer | Uint8Array;
65
+ readonly fileName: string;
66
+ readonly contentType: string;
67
+ constructor(content: Buffer | Uint8Array, fileName: string, contentType?: string);
68
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
69
+ }
70
+ /**
71
+ * Writes an arbitrary string body with a specific content type and status.
72
+ * Created by `ActionResult.content()`.
73
+ */
74
+ export declare class ContentResult extends ActionResult {
75
+ readonly body: string;
76
+ readonly contentType: string;
77
+ readonly status: number;
78
+ constructor(body: string, contentType: string, status?: number);
79
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
80
+ }
81
+ /**
82
+ * Pipes a `Readable` stream to the HTTP response.
83
+ * Created by `ActionResult.stream()`.
84
+ */
85
+ export declare class StreamResult extends ActionResult {
86
+ readonly readable: Readable;
87
+ readonly contentType: string;
88
+ readonly fileName: string | undefined;
89
+ constructor(readable: Readable, contentType: string, fileName?: string);
90
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
91
+ }
92
+ /**
93
+ * Responds with a bare HTTP status code and no body.
94
+ * Created by `ActionResult.status()`.
95
+ */
96
+ export declare class StatusCodeResult extends ActionResult {
97
+ readonly status: number;
98
+ readonly headers: Record<string, string>;
99
+ constructor(status: number, headers?: Record<string, string>);
100
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
101
+ }
102
+ /**
103
+ * Redirects the client to a new URL.
104
+ * Uses 302 (temporary) by default; pass `permanent = true` for 301.
105
+ * Created by `ActionResult.redirect()`.
106
+ */
107
+ export declare class RedirectResult extends ActionResult {
108
+ readonly url: string;
109
+ readonly permanent: boolean;
110
+ constructor(url: string, permanent?: boolean);
111
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
112
+ }
113
+ /**
114
+ * Responds with 204 No Content and no body.
115
+ * Created by `ActionResult.noContent()`.
116
+ */
117
+ export declare class NoContentResult extends ActionResult {
118
+ executeAsync(_req: http.IncomingMessage, res: http.ServerResponse, _contentNegotiator: ContentNegotiator): Promise<void>;
119
+ }
@@ -0,0 +1,31 @@
1
+ import type { ContentTypeHandler } from './types.js';
2
+ /**
3
+ * Selects the appropriate serializer/deserializer for a request or response
4
+ * based on the `Accept` / `Content-Type` HTTP headers.
5
+ *
6
+ * JSON is registered by default. Additional handlers can be added with
7
+ * `register()` or via `ServerBuilder.contentType()`.
8
+ */
9
+ export declare class ContentNegotiator {
10
+ #private;
11
+ constructor();
12
+ /**
13
+ * Register a new content type handler.
14
+ * If a handler for the same MIME type was already registered it is replaced.
15
+ */
16
+ register(handler: ContentTypeHandler): void;
17
+ /**
18
+ * Select the best response serializer for the given `Accept` header value.
19
+ *
20
+ * Returns `null` if no registered handler can satisfy the request;
21
+ * the server will respond with 406 Not Acceptable in that case.
22
+ */
23
+ selectResponseHandler(acceptHeader?: string): ContentTypeHandler | null;
24
+ /**
25
+ * Select the deserializer for an incoming `Content-Type` header.
26
+ *
27
+ * Returns `null` if the content type is not recognised; the server will
28
+ * respond with 415 Unsupported Media Type in that case.
29
+ */
30
+ selectRequestHandler(contentTypeHeader?: string): ContentTypeHandler | null;
31
+ }
@@ -0,0 +1,203 @@
1
+ import type { InferType, ObjectSchemaBuilder, ParseStringSchemaBuilder, SchemaBuilder } from '@cleverbrush/schema';
2
+ import type { ActionResult } from './ActionResult.js';
3
+ import type { RequestContext } from './RequestContext.js';
4
+ type Simplify<T> = {
5
+ [K in keyof T]: T[K];
6
+ } & {};
7
+ type HasKeys<T> = keyof T extends never ? false : true;
8
+ type ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal> = {
9
+ context: RequestContext;
10
+ } & (HasKeys<TParams> extends true ? {
11
+ params: TParams;
12
+ } : {}) & (TBody extends undefined ? {} : {
13
+ body: TBody;
14
+ }) & (HasKeys<TQuery> extends true ? {
15
+ query: TQuery;
16
+ } : {}) & (HasKeys<THeaders> extends true ? {
17
+ headers: THeaders;
18
+ } : {}) & (TPrincipal extends undefined ? {} : {
19
+ principal: TPrincipal;
20
+ });
21
+ /**
22
+ * The fully-typed argument object passed to endpoint handlers.
23
+ *
24
+ * The shape is inferred from the `EndpointBuilder` chain — only the keys
25
+ * actually configured (body, query, headers, params, principal) are present.
26
+ */
27
+ export type ActionContext<E> = E extends EndpointBuilder<infer TParams, infer TBody, infer TQuery, infer THeaders, any, infer TPrincipal, any, any> ? Simplify<ActionContextParts<TParams, TBody, TQuery, THeaders, TPrincipal>> : never;
28
+ type InferServices<T> = {
29
+ [K in keyof T]: T[K] extends SchemaBuilder<any, any, any, any, any> ? InferType<T[K]> : never;
30
+ };
31
+ /**
32
+ * Extracts the injected service schemas map from an `EndpointBuilder` type.
33
+ * Used internally by the `Handler` type to derive the `services` argument.
34
+ */
35
+ export type ServiceSchemas<E> = E extends EndpointBuilder<any, any, any, any, infer TServices, any, any, any> ? TServices : {};
36
+ type ResponseType<E> = E extends EndpointBuilder<any, any, any, any, any, any, any, infer TResponse> ? TResponse : any;
37
+ /**
38
+ * The handler function type inferred from an `EndpointBuilder`.
39
+ *
40
+ * When the endpoint has injected services, the handler receives a second
41
+ * `services` argument with all resolved service instances.
42
+ */
43
+ export type Handler<E> = HasKeys<ServiceSchemas<E>> extends true ? (arg: ActionContext<E>, services: Simplify<InferServices<ServiceSchemas<E>>>) => ResponseType<E> | ActionResult | Promise<ResponseType<E> | ActionResult> : (arg: ActionContext<E>) => ResponseType<E> | ActionResult | Promise<ResponseType<E> | ActionResult>;
44
+ type RoutePath = string | ParseStringSchemaBuilder<any, any, any, any, any>;
45
+ /**
46
+ * Snapshot of all configuration set on an `EndpointBuilder`.
47
+ * Used by the server for routing and by `@cleverbrush/server-openapi` for
48
+ * spec generation.
49
+ */
50
+ export interface EndpointMetadata {
51
+ readonly method: string;
52
+ readonly basePath: string;
53
+ readonly pathTemplate: RoutePath;
54
+ readonly bodySchema: SchemaBuilder<any, any, any, any, any> | null;
55
+ readonly querySchema: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null;
56
+ readonly headerSchema: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null;
57
+ readonly serviceSchemas: Record<string, SchemaBuilder<any, any, any, any, any>> | null;
58
+ /**
59
+ * Authorization roles required for this endpoint.
60
+ * - `null` → no authorization required (public)
61
+ * - `[]` → any authenticated user
62
+ * - `['admin', ...]` → user must have at least one of these roles
63
+ */
64
+ readonly authRoles: readonly string[] | null;
65
+ readonly summary: string | null;
66
+ readonly description: string | null;
67
+ readonly tags: readonly string[];
68
+ readonly operationId: string | null;
69
+ readonly deprecated: boolean;
70
+ readonly responseSchema: SchemaBuilder<any, any, any, any, any> | null;
71
+ }
72
+ /**
73
+ * Immutable, fluent builder for HTTP endpoint definitions.
74
+ *
75
+ * All methods return a new builder instance — the original is never mutated.
76
+ * Use the {@link endpoint} singleton (or {@link createEndpoints}) to obtain
77
+ * the first builder in the chain.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * const GetUser = endpoint
82
+ * .get('/api/users')
83
+ * .query(object({ id: number().coerce() }))
84
+ * .authorize(UserPrincipal, 'admin')
85
+ * .returns(UserSchema)
86
+ * .summary('Get a user by ID');
87
+ * ```
88
+ */
89
+ export declare class EndpointBuilder<TParams = {}, TBody = undefined, TQuery = {}, THeaders = {}, TServices = {}, TPrincipal = undefined, TRoles extends string = string, TResponse = any> {
90
+ #private;
91
+ constructor(method: string, basePath: string, pathTemplate: RoutePath, bodySchema: SchemaBuilder<any, any, any, any, any> | null, querySchema: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null, headerSchema: ObjectSchemaBuilder<any, any, any, any, any, any, any> | null, serviceSchemas?: Record<string, SchemaBuilder<any, any, any, any, any>> | null, authRoles?: readonly string[] | null, summary?: string | null, description?: string | null, tags?: readonly string[], operationId?: string | null, deprecated?: boolean, responseSchema?: SchemaBuilder<any, any, any, any, any> | null);
92
+ /** Define the request body schema. Validation failures return 422 Problem Details. */
93
+ /** Define the request body schema. Validation failures return 422 Problem Details. */
94
+ body<TSchema extends SchemaBuilder<any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, InferType<TSchema>, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse>;
95
+ /** Define the query string schema (must be an object schema). Validation failures return 422. */
96
+ query<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, InferType<TSchema>, THeaders, TServices, TPrincipal, TRoles, TResponse>;
97
+ /** Define an expected request headers schema (must be an object schema). */
98
+ headers<TSchema extends ObjectSchemaBuilder<any, any, any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, InferType<TSchema>, TServices, TPrincipal, TRoles, TResponse>;
99
+ /** Declare DI services to be resolved per-request and passed as the second handler argument. */
100
+ inject<TSchemas extends Record<string, SchemaBuilder<any, any, any, any, any>>>(schemas: TSchemas): EndpointBuilder<TParams, TBody, TQuery, THeaders, TSchemas, TPrincipal, TRoles, TResponse>;
101
+ /**
102
+ * Mark this endpoint as requiring authorization.
103
+ *
104
+ * Overloads:
105
+ * - `authorize(principalSchema, ...roles)` — typed principal, optional role requirements
106
+ * - `authorize(...roles)` — untyped principal (`unknown`), optional role requirements
107
+ *
108
+ * If no roles are specified, any authenticated user is allowed.
109
+ */
110
+ authorize<TSchema extends SchemaBuilder<any, any, any, any, any>>(principalSchema: TSchema, ...roles: TRoles[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, InferType<TSchema>, TRoles, TResponse>;
111
+ authorize(...roles: TRoles[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, unknown, TRoles, TResponse>;
112
+ /**
113
+ * Declare the response type for OpenAPI spec generation.
114
+ *
115
+ * Overloads:
116
+ * - `returns<T>()` — generic type only, no runtime schema
117
+ * - `returns(schema)` — provides a schema for spec generation and type inference
118
+ */
119
+ returns<T>(): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, T>;
120
+ returns<TSchema extends SchemaBuilder<any, any, any, any, any>>(schema: TSchema): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, InferType<TSchema>>;
121
+ /** Short, human-readable summary for OpenAPI operation objects. */
122
+ summary(text: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse>;
123
+ /** Longer description for OpenAPI operation objects. Supports Markdown. */
124
+ description(text: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse>;
125
+ /** OpenAPI tags grouping this operation in generated documentation. */
126
+ tags(...tags: string[]): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse>;
127
+ /** A unique, stable identifier for this operation in OpenAPI spec. */
128
+ operationId(id: string): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse>;
129
+ /** Mark this endpoint as deprecated in OpenAPI spec output. */
130
+ deprecated(): EndpointBuilder<TParams, TBody, TQuery, THeaders, TServices, TPrincipal, TRoles, TResponse>;
131
+ /** Return an immutable snapshot of this builder's configuration as {@link EndpointMetadata}. */
132
+ introspect(): EndpointMetadata;
133
+ }
134
+ /**
135
+ * Optional OpenAPI metadata fields accepted by `createEndpoint` / `createEndpoints`.
136
+ */
137
+ export type EndpointMetadataDescriptors = {
138
+ readonly summary?: string;
139
+ readonly description?: string;
140
+ readonly tags?: string[];
141
+ readonly operationId?: string;
142
+ readonly deprecated?: boolean;
143
+ };
144
+ type ScopedEndpointFactoryMethods<TPrincipal, TRoles extends string = string> = {
145
+ get<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
146
+ post<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
147
+ put<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
148
+ patch<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
149
+ delete<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
150
+ head<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
151
+ options<TParams = {}>(pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, TPrincipal, TRoles>;
152
+ };
153
+ export type ScopedEndpointFactory<TRoles extends string = string> = ScopedEndpointFactoryMethods<undefined, TRoles> & {
154
+ /**
155
+ * Returns a new resource factory where all endpoints inherit
156
+ * the given authorization requirements.
157
+ *
158
+ * - `authorize(principalSchema, ...roles)` — typed principal
159
+ * - `authorize(...roles)` — untyped principal
160
+ */
161
+ authorize<TSchema extends SchemaBuilder<any, any, any, any, any>>(principalSchema: TSchema, ...roles: TRoles[]): ScopedEndpointFactoryMethods<InferType<TSchema>, TRoles>;
162
+ authorize(...roles: TRoles[]): ScopedEndpointFactoryMethods<unknown, TRoles>;
163
+ };
164
+ type EndpointFactory<TRoles extends string = string> = {
165
+ get<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
166
+ post<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
167
+ put<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
168
+ patch<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
169
+ delete<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
170
+ head<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
171
+ options<TParams = {}>(basePath: string, pathTemplate?: ParseStringSchemaBuilder<TParams, any, any, any, any>): EndpointBuilder<TParams, undefined, {}, {}, {}, undefined, TRoles>;
172
+ resource(basePath: string): ScopedEndpointFactory<TRoles>;
173
+ };
174
+ /**
175
+ * Create a role-constrained endpoint factory. Roles are defined as a plain
176
+ * `as const` object whose *values* become the string-literal union accepted
177
+ * by `authorize()`.
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * const Roles = { admin: 'admin', editor: 'editor' } as const;
182
+ * const ep = createEndpoints(Roles);
183
+ * ep.get('/api/admin').authorize(IPrincipal, 'admin'); // ✓
184
+ * ep.get('/api/admin').authorize(IPrincipal, 'typo'); // ✗ type error
185
+ * ```
186
+ */
187
+ export declare function createEndpoints<const T extends Record<string, string>>(_roles: T): EndpointFactory<T[keyof T]>;
188
+ /**
189
+ * The global endpoint factory singleton.
190
+ *
191
+ * Creates `EndpointBuilder` instances for each HTTP method. Use
192
+ * {@link createEndpoints} to get a role-constrained version.
193
+ *
194
+ * @example
195
+ * ```ts
196
+ * import { endpoint } from '@cleverbrush/server';
197
+ *
198
+ * const GetUsers = endpoint.get('/api/users');
199
+ * const CreateUser = endpoint.post('/api/users').body(CreateUserSchema);
200
+ * ```
201
+ */
202
+ export declare const endpoint: EndpointFactory;
203
+ export {};
@@ -0,0 +1,41 @@
1
+ import { type ProblemDetails } from './ProblemDetails.js';
2
+ /**
3
+ * Base class for HTTP errors thrown from endpoint handlers.
4
+ *
5
+ * Instances are automatically caught by the server and serialized as
6
+ * RFC 9457 Problem Details (`application/problem+json`) responses.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * throw new HttpError(429, 'Too Many Requests', 'Rate limit exceeded.');
11
+ * ```
12
+ */
13
+ export declare class HttpError extends Error {
14
+ readonly status: number;
15
+ readonly title: string;
16
+ readonly detail?: string;
17
+ readonly extensions?: Record<string, unknown>;
18
+ constructor(status: number, title?: string, detail?: string, extensions?: Record<string, unknown>);
19
+ /** Converts this error into an RFC 9457 {@link ProblemDetails} object. */
20
+ toProblemDetails(): ProblemDetails;
21
+ }
22
+ /** Thrown when a requested resource cannot be found. Produces a 404 response. */
23
+ export declare class NotFoundError extends HttpError {
24
+ constructor(detail?: string);
25
+ }
26
+ /** Thrown when the request is malformed or fails validation. Produces a 400 response. */
27
+ export declare class BadRequestError extends HttpError {
28
+ constructor(detail?: string);
29
+ }
30
+ /** Thrown when the request lacks valid authentication credentials. Produces a 401 response. */
31
+ export declare class UnauthorizedError extends HttpError {
32
+ constructor(detail?: string);
33
+ }
34
+ /** Thrown when the authenticated principal lacks permission. Produces a 403 response. */
35
+ export declare class ForbiddenError extends HttpError {
36
+ constructor(detail?: string);
37
+ }
38
+ /** Thrown when the request conflicts with the current state of the resource. Produces a 409 response. */
39
+ export declare class ConflictError extends HttpError {
40
+ constructor(detail?: string);
41
+ }
@@ -0,0 +1,18 @@
1
+ import type { RequestContext } from './RequestContext.js';
2
+ import type { Middleware } from './types.js';
3
+ /**
4
+ * Executes a chain of {@link Middleware} functions in order, then invokes
5
+ * a final handler when `next()` is called by every middleware in the chain.
6
+ *
7
+ * Middleware can short-circuit the chain by not calling `next()`.
8
+ */
9
+ export declare class MiddlewarePipeline {
10
+ #private;
11
+ /** Append a middleware to the end of the pipeline. */
12
+ add(middleware: Middleware): void;
13
+ /**
14
+ * Execute the pipeline with the given `context`, calling each middleware
15
+ * in order and finally invoking `finalHandler`.
16
+ */
17
+ execute(context: RequestContext, finalHandler: () => Promise<void>): Promise<void>;
18
+ }
@@ -0,0 +1,25 @@
1
+ import type { EndpointMetadata } from './Endpoint.js';
2
+ import type { ProblemDetails } from './ProblemDetails.js';
3
+ import type { RequestContext } from './RequestContext.js';
4
+ /**
5
+ * Result returned by `resolveArgs()`. When `valid` is `false` the
6
+ * `problemDetails` payload should be sent as a 400 response.
7
+ */
8
+ export type ResolveResult = {
9
+ valid: true;
10
+ args: unknown[];
11
+ } | {
12
+ valid: false;
13
+ problemDetails: ProblemDetails;
14
+ };
15
+ /**
16
+ * Returns true if the endpoint declares a body schema.
17
+ */
18
+ export declare function needsBody(meta: EndpointMetadata): boolean;
19
+ /**
20
+ * Resolve the action context object for an endpoint-based handler.
21
+ *
22
+ * Builds `{ context, params?, body?, query?, headers? }` based on
23
+ * what the endpoint declares.
24
+ */
25
+ export declare function resolveArgs(meta: EndpointMetadata, parsedPath: Record<string, any> | null, context: RequestContext, parsedBody: unknown): Promise<ResolveResult>;