@ultimat3/http 0.0.1
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 +70 -0
- package/package.json +36 -0
- package/src/config.ts +89 -0
- package/src/context.ts +105 -0
- package/src/cors.ts +61 -0
- package/src/error-map.ts +124 -0
- package/src/errors.ts +122 -0
- package/src/hooks.ts +32 -0
- package/src/index.ts +119 -0
- package/src/locale.ts +109 -0
- package/src/middleware.ts +24 -0
- package/src/overlay.ts +106 -0
- package/src/pipeline.ts +392 -0
- package/src/rate-limit.ts +146 -0
- package/src/request.ts +170 -0
- package/src/response.ts +129 -0
- package/src/router.ts +251 -0
- package/src/security-headers.ts +90 -0
- package/src/server.ts +157 -0
- package/src/validate.ts +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @ultimat3/http ๐
|
|
2
|
+
|
|
3
|
+
A thin, **owned** layer over `Bun.serve`. Not a framework-agnostic HTTP kit โ the
|
|
4
|
+
lifecycle belongs to us so ALS context, tracing, locale/tz and authz are impossible
|
|
5
|
+
to skip.
|
|
6
|
+
|
|
7
|
+
## What it owns
|
|
8
|
+
|
|
9
|
+
| Concern | Module |
|
|
10
|
+
|---|---|
|
|
11
|
+
| server lifecycle, drain, `/healthz` + `/readyz` | `server.ts` |
|
|
12
|
+
| route table, matcher, `describeRoutes()` | `router.ts` |
|
|
13
|
+
| the ordered request lifecycle | `pipeline.ts` |
|
|
14
|
+
| typed request (params, query, body) | `request.ts` |
|
|
15
|
+
| response constructors + `problem()` | `response.ts` |
|
|
16
|
+
| code โ status, `factsOf()` | `error-map.ts` |
|
|
17
|
+
| token-bucket limiting | `rate-limit.ts` |
|
|
18
|
+
| CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |
|
|
19
|
+
| dev error overlay | `overlay.ts` |
|
|
20
|
+
|
|
21
|
+
## The pipeline is the guarantee
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
request-id โ trace โ context โ locale โ auth โ rate-limit โ body โ authz
|
|
25
|
+
โ handler โ cache-headers โ (error-map) โ response
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exported as `PIPELINE_STAGES`, each entry carrying a `why`. `pipeline.test.ts`
|
|
29
|
+
asserts the order; `/_x` renders it. Ordering rules worth restating:
|
|
30
|
+
|
|
31
|
+
| Rule | Reason |
|
|
32
|
+
|---|---|
|
|
33
|
+
| auth before rate-limit | limiter keys per actor/tenant, not per NAT address |
|
|
34
|
+
| rate-limit before body | a limited request never allocates its payload |
|
|
35
|
+
| body before authz | policies take parsed input as their subject |
|
|
36
|
+
| cache-headers before response | a directive can never drop a security header |
|
|
37
|
+
|
|
38
|
+
## Routing
|
|
39
|
+
|
|
40
|
+
Precedence is structural, not declaration-ordered: **static > param > wildcard**,
|
|
41
|
+
depth-first with backtracking. A tie is `X_ROUTE_CONFLICT` at startup, never a coin
|
|
42
|
+
flip. `HEAD` falls back to the `GET` route. `meta.auth` is **required** โ a route
|
|
43
|
+
cannot forget to declare its auth posture.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const handle = createServer({
|
|
47
|
+
routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },
|
|
48
|
+
handler: (req) => json({ id: req.param('id') }) }],
|
|
49
|
+
config: defineHttpConfig({ port: 3000 }),
|
|
50
|
+
role: 'web',
|
|
51
|
+
}).start();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Static paths are registered in Bun's native `routes` table; param/wildcard paths fall
|
|
55
|
+
through to `fetch`. Method resolution stays ours so a 405 still carries problem+json.
|
|
56
|
+
|
|
57
|
+
## Errors
|
|
58
|
+
|
|
59
|
+
`X_ROUTE_NOT_FOUND` ยท `X_METHOD_NOT_ALLOWED` ยท `X_BODY_INVALID` ยท `X_UNAUTHENTICATED`
|
|
60
|
+
ยท `X_FORBIDDEN` ยท `X_RATE_LIMITED` ยท `X_BUILD_SKEW` ยท `X_ROUTE_CONFLICT`
|
|
61
|
+
|
|
62
|
+
One `factsOf()` feeds three renderings โ terminal, `application/problem+json`, dev
|
|
63
|
+
overlay โ so the `code`/`cause`/`fix` strings can never diverge.
|
|
64
|
+
|
|
65
|
+
## Boundaries
|
|
66
|
+
|
|
67
|
+
Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. Authentication and
|
|
68
|
+
policy evaluation arrive through `ServerHooks`, declared structurally, because
|
|
69
|
+
`@ultimat3/policy` is a sibling tier. There is no plugin API: `Middleware` wraps a
|
|
70
|
+
handler, the pipeline is everything else.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/http",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/http"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@ultimat3/core": "^0.0.1",
|
|
34
|
+
"@ultimat3/schema": "^0.0.1"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The HTTP slice of `app.config.ts`. One resolver, so a value is either a locked
|
|
2
|
+
// default or an explicit override โ never "whatever the first caller passed".
|
|
3
|
+
import { type CorsConfig, DEFAULT_CORS } from './cors';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_LOCALE_CONFIG,
|
|
6
|
+
DEFAULT_TZ_CONFIG,
|
|
7
|
+
type LocaleConfig,
|
|
8
|
+
type TimeZoneConfig,
|
|
9
|
+
} from './locale';
|
|
10
|
+
import { DEFAULT_RATE_LIMIT, type RateLimitConfig } from './rate-limit';
|
|
11
|
+
import { DEFAULT_SECURITY, type SecurityConfig } from './security-headers';
|
|
12
|
+
|
|
13
|
+
export interface HttpConfig {
|
|
14
|
+
readonly port: number;
|
|
15
|
+
readonly hostname: string;
|
|
16
|
+
/** Mounted prefix, stripped before matching. `'/'` means no prefix. */
|
|
17
|
+
readonly basePath: string;
|
|
18
|
+
/** Build id this process serves; `null` disables skew detection (dev). */
|
|
19
|
+
readonly buildId: string | null;
|
|
20
|
+
readonly buildIdHeader: string;
|
|
21
|
+
readonly dev: boolean;
|
|
22
|
+
/** Read `x-forwarded-for` / `x-forwarded-proto`. Only safe behind our own proxy. */
|
|
23
|
+
readonly trustProxy: boolean;
|
|
24
|
+
readonly bodyLimitBytes: number;
|
|
25
|
+
/** How long SIGTERM waits for in-flight requests before hard-stopping. */
|
|
26
|
+
readonly drainTimeoutMs: number;
|
|
27
|
+
readonly locale: LocaleConfig;
|
|
28
|
+
readonly tz: TimeZoneConfig;
|
|
29
|
+
readonly cors: CorsConfig;
|
|
30
|
+
readonly security: SecurityConfig;
|
|
31
|
+
readonly rateLimit: RateLimitConfig;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HttpConfigInput {
|
|
35
|
+
readonly port?: number;
|
|
36
|
+
readonly hostname?: string;
|
|
37
|
+
readonly basePath?: string;
|
|
38
|
+
readonly buildId?: string | null;
|
|
39
|
+
readonly buildIdHeader?: string;
|
|
40
|
+
readonly dev?: boolean;
|
|
41
|
+
readonly trustProxy?: boolean;
|
|
42
|
+
readonly bodyLimitBytes?: number;
|
|
43
|
+
readonly drainTimeoutMs?: number;
|
|
44
|
+
readonly locale?: Partial<LocaleConfig>;
|
|
45
|
+
readonly tz?: Partial<TimeZoneConfig>;
|
|
46
|
+
readonly cors?: Partial<CorsConfig>;
|
|
47
|
+
readonly security?: Partial<Omit<SecurityConfig, 'csp'>> & {
|
|
48
|
+
readonly csp?: Partial<SecurityConfig['csp']>;
|
|
49
|
+
};
|
|
50
|
+
readonly rateLimit?: Partial<RateLimitConfig>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `basePath` is stripped before matching so route paths never encode the mount point. */
|
|
54
|
+
export const stripBasePath = (pathname: string, basePath: string): string => {
|
|
55
|
+
if (basePath === '/' || basePath === '') return pathname;
|
|
56
|
+
const prefix = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath;
|
|
57
|
+
if (!pathname.startsWith(prefix)) return pathname;
|
|
58
|
+
const rest = pathname.slice(prefix.length);
|
|
59
|
+
return rest.length === 0 ? '/' : rest;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const env = (name: string): string | undefined => {
|
|
63
|
+
const value = Bun.env[name];
|
|
64
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
68
|
+
const dev = input.dev ?? env('NODE_ENV') !== 'production';
|
|
69
|
+
return {
|
|
70
|
+
port: input.port ?? Number.parseInt(env('PORT') ?? '3000', 10),
|
|
71
|
+
hostname: input.hostname ?? env('HOSTNAME') ?? '0.0.0.0',
|
|
72
|
+
basePath: input.basePath ?? '/',
|
|
73
|
+
buildId: input.buildId ?? env('BUILD_ID') ?? null,
|
|
74
|
+
buildIdHeader: input.buildIdHeader ?? 'x-ultimate-build',
|
|
75
|
+
dev,
|
|
76
|
+
trustProxy: input.trustProxy ?? true,
|
|
77
|
+
bodyLimitBytes: input.bodyLimitBytes ?? 1_048_576,
|
|
78
|
+
drainTimeoutMs: input.drainTimeoutMs ?? 15_000,
|
|
79
|
+
locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale },
|
|
80
|
+
tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
|
|
81
|
+
cors: { ...DEFAULT_CORS, ...input.cors },
|
|
82
|
+
security: {
|
|
83
|
+
...DEFAULT_SECURITY,
|
|
84
|
+
...input.security,
|
|
85
|
+
csp: { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp },
|
|
86
|
+
},
|
|
87
|
+
rateLimit: { ...DEFAULT_RATE_LIMIT, ...input.rateLimit },
|
|
88
|
+
};
|
|
89
|
+
};
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The per-request context. It is created by the pipeline before any user code runs
|
|
2
|
+
// and published through core's ALS, which is why nothing in the framework has to
|
|
3
|
+
// thread a request object by hand โ and why nothing can accidentally skip it.
|
|
4
|
+
import { type Actor, type Ctx, type Role, useContext, uuid } from '@ultimat3/core';
|
|
5
|
+
import type { HttpConfig } from './config';
|
|
6
|
+
import type { AuthzDecision } from './hooks';
|
|
7
|
+
import type { RateLimitDecision } from './rate-limit';
|
|
8
|
+
import type { CacheHint } from './response';
|
|
9
|
+
import type { Route, RouteParams } from './router';
|
|
10
|
+
|
|
11
|
+
export interface RequestContext {
|
|
12
|
+
/** `performance.now()` at accept time; used for the server-timing header. */
|
|
13
|
+
readonly startedAt: number;
|
|
14
|
+
readonly url: URL;
|
|
15
|
+
readonly method: string;
|
|
16
|
+
readonly role: Role;
|
|
17
|
+
readonly config: HttpConfig;
|
|
18
|
+
readonly ip: string | null;
|
|
19
|
+
readonly https: boolean;
|
|
20
|
+
/** Response headers accumulated by stages before a Response exists. */
|
|
21
|
+
readonly headers: Headers;
|
|
22
|
+
|
|
23
|
+
// Mutable slots, each filled by exactly one pipeline stage. Kept mutable (and
|
|
24
|
+
// documented) rather than rebuilt per stage so a stage list stays a flat array.
|
|
25
|
+
/** Set by the `request-id` stage; seeded so a crash before it still correlates. */
|
|
26
|
+
requestId: string;
|
|
27
|
+
/** Set by the `trace` stage from an inbound `traceparent`, if any. */
|
|
28
|
+
traceId: string;
|
|
29
|
+
parentSpanId: string | null;
|
|
30
|
+
params: RouteParams;
|
|
31
|
+
route: Route | undefined;
|
|
32
|
+
actor: Actor | null;
|
|
33
|
+
locale: string;
|
|
34
|
+
tz: string;
|
|
35
|
+
buildId: string | null;
|
|
36
|
+
input: unknown;
|
|
37
|
+
authz: AuthzDecision | undefined;
|
|
38
|
+
rateLimit: RateLimitDecision | undefined;
|
|
39
|
+
cache: CacheHint | undefined;
|
|
40
|
+
response: Response | undefined;
|
|
41
|
+
error: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RequestContextInit {
|
|
45
|
+
readonly url: URL;
|
|
46
|
+
readonly method: string;
|
|
47
|
+
readonly role: Role;
|
|
48
|
+
readonly config: HttpConfig;
|
|
49
|
+
readonly requestId?: string;
|
|
50
|
+
readonly traceId?: string;
|
|
51
|
+
readonly ip?: string | null;
|
|
52
|
+
readonly https?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const createRequestContext = (init: RequestContextInit): RequestContext => ({
|
|
56
|
+
requestId: init.requestId ?? uuid(),
|
|
57
|
+
traceId: init.traceId ?? uuid(),
|
|
58
|
+
startedAt: performance.now(),
|
|
59
|
+
url: init.url,
|
|
60
|
+
method: init.method.toUpperCase(),
|
|
61
|
+
role: init.role,
|
|
62
|
+
config: init.config,
|
|
63
|
+
ip: init.ip ?? null,
|
|
64
|
+
https: init.https ?? init.url.protocol === 'https:',
|
|
65
|
+
headers: new Headers(),
|
|
66
|
+
parentSpanId: null,
|
|
67
|
+
params: {},
|
|
68
|
+
route: undefined,
|
|
69
|
+
actor: null,
|
|
70
|
+
locale: init.config.locale.default,
|
|
71
|
+
tz: init.config.tz.default,
|
|
72
|
+
buildId: null,
|
|
73
|
+
input: undefined,
|
|
74
|
+
authz: undefined,
|
|
75
|
+
rateLimit: undefined,
|
|
76
|
+
cache: undefined,
|
|
77
|
+
response: undefined,
|
|
78
|
+
error: undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `Ctx` is owned by `@ultimat3/core` and grows service handles by module
|
|
83
|
+
* augmentation. This is the single adapter between the HTTP request context and
|
|
84
|
+
* core's ALS payload, so a change to `Ctx` touches one line of this package.
|
|
85
|
+
*/
|
|
86
|
+
export const asCtx = (ctx: RequestContext): Ctx => ctx as unknown as Ctx;
|
|
87
|
+
|
|
88
|
+
/** Read the ambient request context. Throws outside a request via core's ALS. */
|
|
89
|
+
export const useRequestContext = (): RequestContext => useContext() as unknown as RequestContext;
|
|
90
|
+
|
|
91
|
+
export const elapsedMs = (ctx: RequestContext): number =>
|
|
92
|
+
Math.round((performance.now() - ctx.startedAt) * 100) / 100;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The fields the HTTP layer reads off an actor. `Actor` is owned by
|
|
96
|
+
* `@ultimat3/core` and extended by the auth adapter, so this narrow view is the
|
|
97
|
+
* only place that assumes anything about its shape.
|
|
98
|
+
*/
|
|
99
|
+
export interface ActorView {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly orgId?: string | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const actorView = (actor: Actor | null): ActorView | null =>
|
|
105
|
+
actor === null ? null : (actor as unknown as ActorView);
|
package/src/cors.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// CORS with a locked default: same-origin only. Cross-origin access is a decision
|
|
2
|
+
// the app makes in app.config.ts, never something a route can quietly opt into.
|
|
3
|
+
|
|
4
|
+
export interface CorsConfig {
|
|
5
|
+
/** Exact origins. `'*'` is allowed only when `credentials` is false. */
|
|
6
|
+
readonly origins: readonly string[];
|
|
7
|
+
readonly methods: readonly string[];
|
|
8
|
+
readonly allowHeaders: readonly string[];
|
|
9
|
+
readonly exposeHeaders: readonly string[];
|
|
10
|
+
readonly credentials: boolean;
|
|
11
|
+
readonly maxAgeSeconds: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CORS: CorsConfig = {
|
|
15
|
+
origins: [],
|
|
16
|
+
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
17
|
+
allowHeaders: ['content-type', 'authorization', 'x-ultimate-build', 'x-request-id'],
|
|
18
|
+
exposeHeaders: ['x-request-id', 'x-ultimate-build', 'retry-after'],
|
|
19
|
+
credentials: true,
|
|
20
|
+
maxAgeSeconds: 600,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const allowedOrigin = (config: CorsConfig, origin: string | null): string | null => {
|
|
24
|
+
if (origin === null) return null;
|
|
25
|
+
if (config.origins.includes('*')) return config.credentials ? null : '*';
|
|
26
|
+
return config.origins.includes(origin) ? origin : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Headers to merge into every response, preflight or not. */
|
|
30
|
+
export const corsHeaders = (config: CorsConfig, origin: string | null): Record<string, string> => {
|
|
31
|
+
const allow = allowedOrigin(config, origin);
|
|
32
|
+
if (allow === null) return {};
|
|
33
|
+
const headers: Record<string, string> = {
|
|
34
|
+
'access-control-allow-origin': allow,
|
|
35
|
+
// Caches must not serve one origin's response to another.
|
|
36
|
+
vary: 'origin',
|
|
37
|
+
};
|
|
38
|
+
if (config.credentials) headers['access-control-allow-credentials'] = 'true';
|
|
39
|
+
if (config.exposeHeaders.length > 0) {
|
|
40
|
+
headers['access-control-expose-headers'] = config.exposeHeaders.join(', ');
|
|
41
|
+
}
|
|
42
|
+
return headers;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Answers an OPTIONS preflight. Returns `undefined` when the request is not a
|
|
47
|
+
* preflight so the caller can fall through to the normal pipeline.
|
|
48
|
+
*/
|
|
49
|
+
export const preflight = (request: Request, config: CorsConfig): Response | undefined => {
|
|
50
|
+
if (request.method !== 'OPTIONS') return undefined;
|
|
51
|
+
const requested = request.headers.get('access-control-request-method');
|
|
52
|
+
if (requested === null) return undefined;
|
|
53
|
+
const origin = request.headers.get('origin');
|
|
54
|
+
const allow = allowedOrigin(config, origin);
|
|
55
|
+
if (allow === null) return new Response(null, { status: 403 });
|
|
56
|
+
const headers = new Headers(corsHeaders(config, origin));
|
|
57
|
+
headers.set('access-control-allow-methods', config.methods.join(', '));
|
|
58
|
+
headers.set('access-control-allow-headers', config.allowHeaders.join(', '));
|
|
59
|
+
headers.set('access-control-max-age', String(config.maxAgeSeconds));
|
|
60
|
+
return new Response(null, { status: 204, headers });
|
|
61
|
+
};
|
package/src/error-map.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The one place a framework error code becomes an HTTP status. A table, not a
|
|
2
|
+
// switch chain: adding a code elsewhere in the framework means adding a row here,
|
|
3
|
+
// and a missing row is a loud 500 rather than a silently wrong 200.
|
|
4
|
+
import { HTTP_ERROR_TITLES } from './errors';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* code -> status. Codes owned by other packages are listed here on purpose: HTTP
|
|
8
|
+
* is the only layer that knows what a status means, so no other package should
|
|
9
|
+
* ever hardcode one.
|
|
10
|
+
*/
|
|
11
|
+
export const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
12
|
+
// @ultimat3/http
|
|
13
|
+
X_ROUTE_NOT_FOUND: 404,
|
|
14
|
+
X_METHOD_NOT_ALLOWED: 405,
|
|
15
|
+
X_BODY_INVALID: 422,
|
|
16
|
+
X_UNAUTHENTICATED: 401,
|
|
17
|
+
X_FORBIDDEN: 403,
|
|
18
|
+
X_RATE_LIMITED: 429,
|
|
19
|
+
X_BUILD_SKEW: 409,
|
|
20
|
+
X_ROUTE_CONFLICT: 500,
|
|
21
|
+
X_SERVER_NOT_STARTED: 500,
|
|
22
|
+
X_PIPELINE_NO_RESPONSE: 500,
|
|
23
|
+
// @ultimat3/entity
|
|
24
|
+
X_NOT_FOUND: 404,
|
|
25
|
+
X_ENTITY_DUPLICATE: 409,
|
|
26
|
+
X_INVARIANT_VIOLATED: 422,
|
|
27
|
+
X_TENANCY_UNSCOPED: 500,
|
|
28
|
+
X_DB_DRIFT: 500,
|
|
29
|
+
// @ultimat3/policy
|
|
30
|
+
X_POLICY_MISSING: 500,
|
|
31
|
+
X_PERMISSION_UNKNOWN: 500,
|
|
32
|
+
// @ultimat3/core
|
|
33
|
+
X_NOT_IMPLEMENTED: 501,
|
|
34
|
+
X_TIMEOUT: 504,
|
|
35
|
+
X_ABORTED: 499,
|
|
36
|
+
X_INTERNAL: 500,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_STATUS = 500;
|
|
40
|
+
|
|
41
|
+
export const statusFor = (code: string): number => ERROR_STATUS[code] ?? DEFAULT_STATUS;
|
|
42
|
+
|
|
43
|
+
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
44
|
+
export interface ErrorFacts {
|
|
45
|
+
readonly code: string;
|
|
46
|
+
readonly title: string;
|
|
47
|
+
readonly cause: string;
|
|
48
|
+
readonly fix: string;
|
|
49
|
+
readonly docs: string;
|
|
50
|
+
readonly status: number;
|
|
51
|
+
/** Present only when the process is in dev mode; never sent to a client in prod. */
|
|
52
|
+
readonly stack: string | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const str = (source: Record<string, unknown>, key: string): string | undefined => {
|
|
56
|
+
const value = source[key];
|
|
57
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
61
|
+
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalises any throwable into the framework's error contract. Non-Ultimate
|
|
65
|
+
* throwables still get a code and a fix, because "errors are instructions" has to
|
|
66
|
+
* hold for the accidental `TypeError` too.
|
|
67
|
+
*/
|
|
68
|
+
export const factsOf = (error: unknown): ErrorFacts => {
|
|
69
|
+
const record = asRecord(error);
|
|
70
|
+
const code = str(record, 'code') ?? 'X_INTERNAL';
|
|
71
|
+
const title =
|
|
72
|
+
HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
|
|
73
|
+
str(record, 'message') ??
|
|
74
|
+
'unhandled server error';
|
|
75
|
+
const cause = str(record, 'cause') ?? str(record, 'message') ?? String(error);
|
|
76
|
+
return {
|
|
77
|
+
code,
|
|
78
|
+
title,
|
|
79
|
+
cause,
|
|
80
|
+
fix: str(record, 'fix') ?? 'x logs tail --json # then fix the throwing call site',
|
|
81
|
+
docs: str(record, 'docs') ?? `https://ultimate.dev/errors/${code}`,
|
|
82
|
+
status: statusFor(code),
|
|
83
|
+
stack: str(record, 'stack'),
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
|
|
88
|
+
export interface ProblemDocument {
|
|
89
|
+
readonly type: string;
|
|
90
|
+
readonly title: string;
|
|
91
|
+
readonly status: number;
|
|
92
|
+
readonly detail: string;
|
|
93
|
+
readonly instance: string | undefined;
|
|
94
|
+
readonly code: string;
|
|
95
|
+
readonly cause: string;
|
|
96
|
+
readonly fix: string;
|
|
97
|
+
readonly docs: string;
|
|
98
|
+
readonly requestId: string | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const toProblem = (
|
|
102
|
+
error: unknown,
|
|
103
|
+
meta: { instance?: string; requestId?: string } = {},
|
|
104
|
+
): ProblemDocument => {
|
|
105
|
+
const facts = factsOf(error);
|
|
106
|
+
return {
|
|
107
|
+
type: facts.docs,
|
|
108
|
+
title: facts.title,
|
|
109
|
+
status: facts.status,
|
|
110
|
+
detail: facts.cause,
|
|
111
|
+
instance: meta.instance,
|
|
112
|
+
code: facts.code,
|
|
113
|
+
cause: facts.cause,
|
|
114
|
+
fix: facts.fix,
|
|
115
|
+
docs: facts.docs,
|
|
116
|
+
requestId: meta.requestId,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The exact three lines the terminal prints, reused by the overlay and `--json`. */
|
|
121
|
+
export const renderErrorLines = (error: unknown): string => {
|
|
122
|
+
const facts = factsOf(error);
|
|
123
|
+
return `${facts.code}: ${facts.title}\n cause: ${facts.cause}\n fix: ${facts.fix}`;
|
|
124
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The HTTP layer's stable error codes. Every throw in this package goes through a
|
|
2
|
+
// factory here so a code, a cause and an exact fix always travel together โ the
|
|
3
|
+
// terminal, the dev overlay and `--json` all render the same three strings.
|
|
4
|
+
import { UltimateError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
export const HTTP_ERROR_CODES = [
|
|
7
|
+
'X_ROUTE_NOT_FOUND',
|
|
8
|
+
'X_METHOD_NOT_ALLOWED',
|
|
9
|
+
'X_BODY_INVALID',
|
|
10
|
+
'X_UNAUTHENTICATED',
|
|
11
|
+
'X_FORBIDDEN',
|
|
12
|
+
'X_RATE_LIMITED',
|
|
13
|
+
'X_BUILD_SKEW',
|
|
14
|
+
'X_ROUTE_CONFLICT',
|
|
15
|
+
'X_SERVER_NOT_STARTED',
|
|
16
|
+
'X_PIPELINE_NO_RESPONSE',
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
|
|
20
|
+
|
|
21
|
+
/** Human title per code. Kept next to the codes so one edit updates every surface. */
|
|
22
|
+
export const HTTP_ERROR_TITLES: Readonly<Record<HttpErrorCode, string>> = {
|
|
23
|
+
X_ROUTE_NOT_FOUND: 'no route matches this request',
|
|
24
|
+
X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
|
|
25
|
+
X_BODY_INVALID: 'request body failed its schema',
|
|
26
|
+
X_UNAUTHENTICATED: 'route requires an authenticated actor',
|
|
27
|
+
X_FORBIDDEN: 'policy denied this actor',
|
|
28
|
+
X_RATE_LIMITED: 'rate limit exhausted for this key',
|
|
29
|
+
X_BUILD_SKEW: 'client build id does not match the server build id',
|
|
30
|
+
X_ROUTE_CONFLICT: 'two routes claim the same path',
|
|
31
|
+
X_SERVER_NOT_STARTED: 'server handle used before start()',
|
|
32
|
+
X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const docsFor = (code: HttpErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
36
|
+
|
|
37
|
+
/** Base class for every error this package throws. Never throw a bare `Error`. */
|
|
38
|
+
export class HttpError extends UltimateError {
|
|
39
|
+
constructor(init: { code: HttpErrorCode; cause: string; fix: string }) {
|
|
40
|
+
super({
|
|
41
|
+
code: init.code,
|
|
42
|
+
cause: init.cause,
|
|
43
|
+
fix: init.fix,
|
|
44
|
+
docs: docsFor(init.code),
|
|
45
|
+
});
|
|
46
|
+
this.name = 'HttpError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const routeNotFound = (method: string, pathname: string): HttpError =>
|
|
51
|
+
new HttpError({
|
|
52
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
53
|
+
cause: `no route registered for ${method} ${pathname}`,
|
|
54
|
+
fix: `x routes list --json # then: x g route ${pathname}`,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const methodNotAllowed = (
|
|
58
|
+
method: string,
|
|
59
|
+
pathname: string,
|
|
60
|
+
allow: readonly string[],
|
|
61
|
+
): HttpError =>
|
|
62
|
+
new HttpError({
|
|
63
|
+
code: 'X_METHOD_NOT_ALLOWED',
|
|
64
|
+
cause: `${pathname} accepts ${allow.join(', ')} but the request used ${method}`,
|
|
65
|
+
fix: `add a ${method} route for ${pathname} or call it with ${allow[0] ?? 'GET'}`,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const bodyInvalid = (pathname: string, issues: readonly string[]): HttpError =>
|
|
69
|
+
new HttpError({
|
|
70
|
+
code: 'X_BODY_INVALID',
|
|
71
|
+
cause: `${pathname} body rejected: ${issues.join('; ')}`,
|
|
72
|
+
fix: `x schema show ${pathname} --json # then send a body matching the input schema`,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const unauthenticated = (pathname: string): HttpError =>
|
|
76
|
+
new HttpError({
|
|
77
|
+
code: 'X_UNAUTHENTICATED',
|
|
78
|
+
cause: `${pathname} declares auth: 'required' and no actor was resolved`,
|
|
79
|
+
fix: "send a session cookie or Authorization header, or set meta.auth to 'public'",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const forbidden = (pathname: string, reason: string): HttpError =>
|
|
83
|
+
new HttpError({
|
|
84
|
+
code: 'X_FORBIDDEN',
|
|
85
|
+
cause: `${pathname} denied: ${reason}`,
|
|
86
|
+
fix: `x policy explain ${pathname} --json # shows which clause denied`,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =>
|
|
90
|
+
new HttpError({
|
|
91
|
+
code: 'X_RATE_LIMITED',
|
|
92
|
+
cause: `bucket for ${key} is empty; refills in ${retryAfterSeconds}s`,
|
|
93
|
+
fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export const buildSkew = (clientBuildId: string, serverBuildId: string): HttpError =>
|
|
97
|
+
new HttpError({
|
|
98
|
+
code: 'X_BUILD_SKEW',
|
|
99
|
+
cause: `client sent build ${clientBuildId}, server is running ${serverBuildId}`,
|
|
100
|
+
fix: 'reload the page โ the service worker will fetch the new build manifest',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const serverNotStarted = (member: string): HttpError =>
|
|
104
|
+
new HttpError({
|
|
105
|
+
code: 'X_SERVER_NOT_STARTED',
|
|
106
|
+
cause: `${member} was read before start() bound a socket`,
|
|
107
|
+
fix: 'call createServer({ ... }).start() before reading url()',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const pipelineNoResponse = (stage: string): HttpError =>
|
|
111
|
+
new HttpError({
|
|
112
|
+
code: 'X_PIPELINE_NO_RESPONSE',
|
|
113
|
+
cause: `the pipeline finished at stage "${stage}" without a response`,
|
|
114
|
+
fix: 'return a Response from the route handler, or a Response from the stage that short-circuits',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const routeConflict = (path: string, detail: string): HttpError =>
|
|
118
|
+
new HttpError({
|
|
119
|
+
code: 'X_ROUTE_CONFLICT',
|
|
120
|
+
cause: `${path} conflicts with an already registered route: ${detail}`,
|
|
121
|
+
fix: `x routes list --json # remove or rename one of the two routes at ${path}`,
|
|
122
|
+
});
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The two seams the HTTP layer cannot own itself: who the actor is (auth lives in
|
|
2
|
+
// `@ultimat3/auth`, tier 3) and whether a policy allows the call (`@ultimat3/policy`
|
|
3
|
+
// is a sibling tier, so it cannot be imported here). Both are declared structurally,
|
|
4
|
+
// which keeps the import boundary intact and keeps the pipeline testable.
|
|
5
|
+
import type { Actor } from '@ultimat3/core';
|
|
6
|
+
import type { RequestContext } from './context';
|
|
7
|
+
import type { UltimateRequest } from './request';
|
|
8
|
+
import type { Route } from './router';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Structurally identical to `PolicyDecision` in `@ultimat3/policy`. Tier 3 passes
|
|
12
|
+
* that package's `evaluate()` straight through โ no adapter, no second authz model.
|
|
13
|
+
*/
|
|
14
|
+
export type AuthzDecision =
|
|
15
|
+
| { readonly allowed: true }
|
|
16
|
+
| { readonly allowed: false; readonly reason: string; readonly code?: string };
|
|
17
|
+
|
|
18
|
+
export interface ServerHooks {
|
|
19
|
+
/** Resolve the actor from cookies/headers. Returning `null` means anonymous. */
|
|
20
|
+
readonly authenticate?: (
|
|
21
|
+
request: UltimateRequest,
|
|
22
|
+
ctx: RequestContext,
|
|
23
|
+
) => Promise<Actor | null> | Actor | null;
|
|
24
|
+
/** Evaluate `route.meta.policy`. Required for any route that declares one. */
|
|
25
|
+
readonly authorize?: (
|
|
26
|
+
route: Route,
|
|
27
|
+
request: UltimateRequest,
|
|
28
|
+
ctx: RequestContext,
|
|
29
|
+
) => Promise<AuthzDecision> | AuthzDecision;
|
|
30
|
+
/** Observability sink; the pipeline still maps the error to a response itself. */
|
|
31
|
+
readonly onError?: (error: unknown, ctx: RequestContext) => void;
|
|
32
|
+
}
|