@ultimat3/http 1.2.0 → 3.0.0
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/CLAUDE.md +301 -0
- package/README.md +99 -2
- package/package.json +6 -3
- package/src/auth-redirect.ts +81 -0
- package/src/cache-policy.ts +24 -0
- package/src/config.ts +67 -7
- package/src/context.ts +187 -36
- package/src/correlation.ts +44 -0
- package/src/cors.ts +33 -5
- package/src/csrf.ts +85 -0
- package/src/deadline.ts +79 -0
- package/src/error-map.ts +218 -16
- package/src/errors.ts +334 -5
- package/src/finalize.ts +75 -0
- package/src/forwarded.ts +94 -0
- package/src/hooks.ts +39 -4
- package/src/index.ts +59 -23
- package/src/locale.ts +34 -82
- package/src/overlay-style.ts +43 -0
- package/src/overlay.ts +59 -41
- package/src/peer-identity.ts +107 -0
- package/src/pipeline.ts +148 -301
- package/src/rate-limit-buckets.ts +86 -0
- package/src/rate-limit.ts +197 -9
- package/src/redirect.ts +29 -0
- package/src/request.ts +47 -13
- package/src/response.ts +34 -8
- package/src/router.ts +63 -13
- package/src/security-headers.ts +34 -13
- package/src/server.ts +44 -7
- package/src/stages.ts +381 -0
- package/src/type-pins.ts +48 -0
- package/src/validate.ts +13 -2
package/src/security-headers.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// Locked security headers. The CSP
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Locked security headers. The CSP admits exactly what the framework itself emits — a service
|
|
2
|
+
// worker, wasm, and the inline `<style>` every document carries — so widening it is a visible
|
|
3
|
+
// config change. Inline style is admitted by sha256 hash, never `'unsafe-inline'`: a prerendered
|
|
4
|
+
// document is a file on disk, so no per-response nonce can reach it, but its body is fixed.
|
|
5
|
+
|
|
6
|
+
import { OVERLAY_STYLE } from './overlay-style';
|
|
4
7
|
|
|
5
8
|
export interface SecurityConfig {
|
|
6
9
|
readonly csp: {
|
|
@@ -32,12 +35,30 @@ export const DEFAULT_SECURITY: SecurityConfig = {
|
|
|
32
35
|
hsts: { maxAgeSeconds: 63_072_000, includeSubdomains: true, preload: false },
|
|
33
36
|
};
|
|
34
37
|
|
|
35
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* `'sha256-<base64>'` for the exact text an inline `<style>` or `<script>` holds, ready to
|
|
40
|
+
* concatenate into a directive. A hash and not a nonce because the two documents that most need
|
|
41
|
+
* covering cannot receive one: a prerendered page is a file on disk, and a response's `<style>` is
|
|
42
|
+
* built by the handler, after the stage that would have had to choose the nonce.
|
|
43
|
+
*/
|
|
44
|
+
export const cspHashSource = (body: string): string =>
|
|
45
|
+
`'sha256-${new Bun.CryptoHasher('sha256').update(body).digest('base64')}'`;
|
|
46
|
+
|
|
47
|
+
/** Directive -> sources. Inline bodies are admitted by hash; `config.csp.extend` adds the rest. */
|
|
36
48
|
const baseline = (config: SecurityConfig): Record<string, readonly string[]> => ({
|
|
37
49
|
'default-src': ["'self'"],
|
|
38
50
|
// 'wasm-unsafe-eval' only: no 'unsafe-inline', no 'unsafe-eval'.
|
|
39
51
|
'script-src': ["'self'", "'wasm-unsafe-eval'"],
|
|
40
|
-
|
|
52
|
+
// The dev overlay is the one document this package renders itself, so its hash is the one
|
|
53
|
+
// `style-src` source that is not the caller's to supply. Unconditional rather than gated on
|
|
54
|
+
// `dev`: a header that changes with a runtime branch is a header a CDN caches for the wrong
|
|
55
|
+
// document, and admitting a stylesheet the framework wrote grants an attacker nothing.
|
|
56
|
+
'style-src': ["'self'", cspHashSource(OVERLAY_STYLE)],
|
|
57
|
+
// ATTRIBUTES ONLY, and the one relaxation in this file. Every layout composite sizes itself
|
|
58
|
+
// with `style="--shell-sidebar: 16rem"`, computed per render, so there is no fixed text to
|
|
59
|
+
// hash and `'unsafe-hashes'` cannot express it. `style-src` still governs `<style>` ELEMENTS,
|
|
60
|
+
// so a `defineTheme()` value carrying `</style>` is still a refusal and not an injection.
|
|
61
|
+
'style-src-attr': ["'unsafe-inline'"],
|
|
41
62
|
'img-src': ["'self'", 'data:', 'blob:'],
|
|
42
63
|
'font-src': ["'self'"],
|
|
43
64
|
// ws:/wss: are required by the realtime tiers; blob: by streamed responses.
|
|
@@ -51,14 +72,11 @@ const baseline = (config: SecurityConfig): Record<string, readonly string[]> =>
|
|
|
51
72
|
'object-src': ["'none'"],
|
|
52
73
|
});
|
|
53
74
|
|
|
54
|
-
export const buildCsp = (config: SecurityConfig
|
|
75
|
+
export const buildCsp = (config: SecurityConfig): string => {
|
|
55
76
|
const directives = baseline(config);
|
|
56
77
|
for (const [name, sources] of Object.entries(config.csp.extend)) {
|
|
57
78
|
directives[name] = [...(directives[name] ?? []), ...sources];
|
|
58
79
|
}
|
|
59
|
-
if (nonce !== undefined) {
|
|
60
|
-
directives['script-src'] = [...(directives['script-src'] ?? []), `'nonce-${nonce}'`];
|
|
61
|
-
}
|
|
62
80
|
const parts = Object.entries(directives).map(([name, sources]) => `${name} ${sources.join(' ')}`);
|
|
63
81
|
if (config.csp.reportUri !== null) parts.push(`report-uri ${config.csp.reportUri}`);
|
|
64
82
|
return parts.join('; ');
|
|
@@ -66,21 +84,24 @@ export const buildCsp = (config: SecurityConfig, nonce?: string): string => {
|
|
|
66
84
|
|
|
67
85
|
export const securityHeaders = (
|
|
68
86
|
config: SecurityConfig,
|
|
69
|
-
options: {
|
|
87
|
+
options: { https?: boolean } = {},
|
|
70
88
|
): Record<string, string> => {
|
|
71
89
|
const cspHeader = config.csp.reportOnly
|
|
72
90
|
? 'content-security-policy-report-only'
|
|
73
91
|
: 'content-security-policy';
|
|
74
92
|
const headers: Record<string, string> = {
|
|
75
|
-
[cspHeader]: buildCsp(config
|
|
93
|
+
[cspHeader]: buildCsp(config),
|
|
76
94
|
'x-content-type-options': 'nosniff',
|
|
77
95
|
'referrer-policy': config.referrerPolicy,
|
|
78
96
|
'permissions-policy': config.permissionsPolicy,
|
|
79
97
|
'cross-origin-opener-policy': config.coop,
|
|
80
98
|
'cross-origin-resource-policy': config.corp,
|
|
81
99
|
};
|
|
82
|
-
// HSTS over plaintext is ignored by browsers and confuses local dev, so
|
|
83
|
-
|
|
100
|
+
// HSTS over plaintext is ignored by browsers and confuses local dev, so it is emitted only when
|
|
101
|
+
// the caller AFFIRMS https. `!== false` said the opposite of this comment: the zero-argument
|
|
102
|
+
// default — every caller that is not the pipeline, which passes `ctx.https` — sent a two-year
|
|
103
|
+
// `includeSubDomains` for a connection nothing had established was secure.
|
|
104
|
+
if (config.hsts !== null && options.https === true) {
|
|
84
105
|
const parts = [`max-age=${config.hsts.maxAgeSeconds}`];
|
|
85
106
|
if (config.hsts.includeSubdomains) parts.push('includeSubDomains');
|
|
86
107
|
if (config.hsts.preload) parts.push('preload');
|
package/src/server.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { serverNotStarted } from './errors';
|
|
|
21
21
|
import type { ServerHooks } from './hooks';
|
|
22
22
|
import type { Middleware } from './middleware';
|
|
23
23
|
import { createPipeline, type Pipeline } from './pipeline';
|
|
24
|
+
import { createRateLimiter, type RateLimitStore } from './rate-limit';
|
|
25
|
+
import { withRouteBuckets } from './rate-limit-buckets';
|
|
24
26
|
import { json } from './response';
|
|
25
27
|
import { createRouter, describeRoutes, type Route, type RouteDescription } from './router';
|
|
26
28
|
|
|
@@ -39,6 +41,13 @@ export interface ServerOptions {
|
|
|
39
41
|
readonly role?: Role;
|
|
40
42
|
readonly hooks?: ServerHooks;
|
|
41
43
|
readonly middleware?: readonly Middleware[];
|
|
44
|
+
/**
|
|
45
|
+
* Where the rate limiter keeps its counters. Omitted means `memoryRateLimitStore()`, which is
|
|
46
|
+
* one process' worth of state — correct for dev and tests, and N × every configured number for
|
|
47
|
+
* N replicas. An app that runs more than one process declares `rateLimit.scope: 'shared'` and
|
|
48
|
+
* passes a store that says the same, or `createServer` refuses here.
|
|
49
|
+
*/
|
|
50
|
+
readonly rateLimitStore?: RateLimitStore;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export interface ServerHandle {
|
|
@@ -62,14 +71,26 @@ export interface ServerHandle {
|
|
|
62
71
|
const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
|
|
63
72
|
|
|
64
73
|
export const createServer = (options: ServerOptions): ServerHandle => {
|
|
65
|
-
const config = options.config ?? defineHttpConfig();
|
|
66
74
|
const role = options.role ?? roleFromEnv();
|
|
67
75
|
const table = createRouter(options.routes);
|
|
76
|
+
// Merged here as well as in `createPipeline`, and for the store's sake: the limiter below is
|
|
77
|
+
// built from `config.rateLimit`, so a table without the routes' own buckets would resolve a
|
|
78
|
+
// declared name to `default` — the very hole this closes. `withRouteBuckets` is idempotent, so
|
|
79
|
+
// the pipeline's second pass changes nothing. `handle.config` is the merged one for the same
|
|
80
|
+
// reason: `server.config.rateLimit.buckets` has to be what the limiter runs on.
|
|
81
|
+
const config = withRouteBuckets(options.config ?? defineHttpConfig(), table.routes);
|
|
82
|
+
// The store feeds the limiter seam `PipelineDeps` already had, rather than becoming a second
|
|
83
|
+
// one: the bucket maths stays in `createRateLimiter`, so every driver agrees on the numbers.
|
|
68
84
|
const pipeline = createPipeline({
|
|
69
85
|
table,
|
|
70
86
|
config,
|
|
71
87
|
...(options.hooks === undefined ? {} : { hooks: options.hooks }),
|
|
72
88
|
...(options.middleware === undefined ? {} : { middleware: options.middleware }),
|
|
89
|
+
...(options.rateLimitStore === undefined
|
|
90
|
+
? {}
|
|
91
|
+
: {
|
|
92
|
+
limiter: createRateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
|
|
93
|
+
}),
|
|
73
94
|
});
|
|
74
95
|
|
|
75
96
|
// The one HTTP-owned knob feeds core's deadline, so there is a single drain budget.
|
|
@@ -132,6 +153,17 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
132
153
|
describe: () => describeRoutes(table),
|
|
133
154
|
fetch: (request) => dispatch(request),
|
|
134
155
|
start() {
|
|
156
|
+
// FIRST, before the socket. `markReady()` refuses a process whose lifecycle already drained
|
|
157
|
+
// (`X_LIFECYCLE_DRAINED`), and a refusal that arrived after the bind would leave a listener
|
|
158
|
+
// this handle can never close: `drain()` memoized on the first drain, so `stop()` below never
|
|
159
|
+
// reaches the close hook — measured, a second server was still accepting connections after
|
|
160
|
+
// its own `stop()` returned, answering 503 to everything in between.
|
|
161
|
+
//
|
|
162
|
+
// The promotion moving above the bind costs nothing observable: `Bun.serve` is synchronous,
|
|
163
|
+
// and `/readyz` is served by the socket this line precedes. `x dev`'s only earlier listener
|
|
164
|
+
// is the metrics endpoint, which answers `METRICS_PATH` and nothing else.
|
|
165
|
+
markReady();
|
|
166
|
+
|
|
135
167
|
server = Bun.serve({
|
|
136
168
|
port: config.port,
|
|
137
169
|
hostname: config.hostname,
|
|
@@ -170,17 +202,22 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
170
202
|
{ phase: 'close' },
|
|
171
203
|
);
|
|
172
204
|
|
|
173
|
-
markReady();
|
|
174
205
|
logger.info(`ultimate ${role} listening on ${server.url.origin}`);
|
|
175
206
|
return handle;
|
|
176
207
|
},
|
|
177
208
|
async stop() {
|
|
178
209
|
if (server === undefined) return;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
210
|
+
try {
|
|
211
|
+
// Delegate to core so a manual stop() and a real SIGTERM take the identical
|
|
212
|
+
// three-phase path. The drain deadline is core's, not ours.
|
|
213
|
+
await drain('manual');
|
|
214
|
+
} finally {
|
|
215
|
+
// A throwing drain() must not leave this handle's hooks registered against a server
|
|
216
|
+
// that is going away — core would still call them, against `server` fields already
|
|
217
|
+
// torn down below, on the next drain this process runs.
|
|
218
|
+
unregister?.();
|
|
219
|
+
unregisterClose?.();
|
|
220
|
+
}
|
|
184
221
|
// Idempotent: the close hook already released, unless the drain deadline cut it short.
|
|
185
222
|
stopListening?.();
|
|
186
223
|
stopListening = undefined;
|
package/src/stages.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// One function per stage name: what each stage of the lifecycle DOES. The package's three-way
|
|
2
|
+
// split, each file naming the other two — `pipeline.ts` owns the ORDER the stages run in and the
|
|
3
|
+
// run itself, `finalize.ts` owns the promise that the tail cannot reject, and this file owns the
|
|
4
|
+
// work. The vocabulary (`StageName`, `Stage`) lives here too, beside the fourteen implementations
|
|
5
|
+
// it names, so `Record<StageName, StageRun>` below is the build error that catches a missing one.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
anonymousActor,
|
|
9
|
+
inflightCount,
|
|
10
|
+
isAnonymous,
|
|
11
|
+
isDraining,
|
|
12
|
+
reportError,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import { resolveLocale } from '@ultimat3/i18n';
|
|
15
|
+
import { resolveTimeZone } from '@ultimat3/time';
|
|
16
|
+
import { signInRedirect } from './auth-redirect';
|
|
17
|
+
import { defaultCache } from './cache-policy';
|
|
18
|
+
import { type HttpConfig, stripBasePath } from './config';
|
|
19
|
+
import { actorView, elapsedMs, type RequestContext } from './context';
|
|
20
|
+
import { corsHeaders, preflight } from './cors';
|
|
21
|
+
import { checkCsrf, selfOrigin } from './csrf';
|
|
22
|
+
import { factsOf } from './error-map';
|
|
23
|
+
import {
|
|
24
|
+
bodyInvalid,
|
|
25
|
+
csrfBlocked,
|
|
26
|
+
draining,
|
|
27
|
+
forbidden,
|
|
28
|
+
methodNotAllowed,
|
|
29
|
+
overloaded,
|
|
30
|
+
pathInvalid,
|
|
31
|
+
rateLimited,
|
|
32
|
+
routeNotFound,
|
|
33
|
+
unauthenticated,
|
|
34
|
+
} from './errors';
|
|
35
|
+
import type { ServerHooks } from './hooks';
|
|
36
|
+
import { readCookie } from './locale';
|
|
37
|
+
import { compose, type Middleware } from './middleware';
|
|
38
|
+
import { overlayResponse, wantsOverlay } from './overlay';
|
|
39
|
+
import { type RateLimiter, rateLimitKey } from './rate-limit';
|
|
40
|
+
import type { UltimateRequest } from './request';
|
|
41
|
+
import { addVary, applyCacheHeaders, problem, redirect } from './response';
|
|
42
|
+
import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
|
|
43
|
+
import { securityHeaders } from './security-headers';
|
|
44
|
+
import { validate } from './validate';
|
|
45
|
+
|
|
46
|
+
export type StageName =
|
|
47
|
+
| 'request-id'
|
|
48
|
+
| 'admit'
|
|
49
|
+
| 'trace'
|
|
50
|
+
| 'context'
|
|
51
|
+
| 'locale'
|
|
52
|
+
| 'auth'
|
|
53
|
+
| 'rate-limit'
|
|
54
|
+
| 'csrf'
|
|
55
|
+
| 'body'
|
|
56
|
+
| 'authz'
|
|
57
|
+
| 'handler'
|
|
58
|
+
| 'cache-headers'
|
|
59
|
+
| 'error-map'
|
|
60
|
+
| 'response';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `request` may short-circuit by returning a Response.
|
|
64
|
+
* `terminal` runs the route handler.
|
|
65
|
+
* `recover` runs only when something above threw.
|
|
66
|
+
* `finalize` always runs, on success and on failure.
|
|
67
|
+
*/
|
|
68
|
+
export type StagePhase = 'request' | 'terminal' | 'recover' | 'finalize';
|
|
69
|
+
|
|
70
|
+
export interface StageDoc {
|
|
71
|
+
readonly name: StageName;
|
|
72
|
+
readonly phase: StagePhase;
|
|
73
|
+
/** Why the stage sits at this index. Rendered verbatim by the dev dashboard. */
|
|
74
|
+
readonly why: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type StageRun = (
|
|
78
|
+
request: UltimateRequest,
|
|
79
|
+
ctx: RequestContext,
|
|
80
|
+
) => Response | undefined | Promise<Response | undefined>;
|
|
81
|
+
|
|
82
|
+
export interface Stage extends StageDoc {
|
|
83
|
+
readonly run: StageRun;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A shed request must say when to come back, or it comes back immediately and the retry storm is
|
|
88
|
+
* the load it was shed to avoid. One second: long enough to matter across a fleet, short enough
|
|
89
|
+
* that a client is not parked past a rolling restart.
|
|
90
|
+
*/
|
|
91
|
+
const SHED_RETRY_AFTER_SECONDS = '1';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
|
|
95
|
+
* would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
|
|
96
|
+
* the server's cardinality is how a Prometheus dies. Exported because `pipeline.ts` labels the
|
|
97
|
+
* request metric with it too, and two spellings of "unmatched" is two series.
|
|
98
|
+
*/
|
|
99
|
+
export const UNMATCHED_ROUTE = 'unmatched';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Everything a stage may read, named one by one rather than as `PipelineDeps`: a stage body has no
|
|
103
|
+
* business seeing the constructor's input shape, and this list IS the answer to "what can a stage
|
|
104
|
+
* depend on".
|
|
105
|
+
*/
|
|
106
|
+
export interface StageRunnersInput {
|
|
107
|
+
readonly table: RouteTable;
|
|
108
|
+
readonly config: HttpConfig;
|
|
109
|
+
readonly limiter: RateLimiter;
|
|
110
|
+
readonly hooks: ServerHooks;
|
|
111
|
+
readonly middleware: readonly Middleware[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The stage table, closed over one pipeline's config, routes, hooks and limiter. */
|
|
115
|
+
export const stageRunners = (input: StageRunnersInput): Record<StageName, StageRun> => {
|
|
116
|
+
const { config, hooks, limiter } = input;
|
|
117
|
+
const wrapped = new Map<Route, RouteHandler>();
|
|
118
|
+
const wrap = compose(input.middleware);
|
|
119
|
+
for (const route of input.table.routes) wrapped.set(route, wrap(route.handler));
|
|
120
|
+
|
|
121
|
+
const table: Record<StageName, StageRun> = {
|
|
122
|
+
// Both ids are resolved in `correlation.ts`, before the context and the root span exist —
|
|
123
|
+
// this stage publishes what was decided there. It used to DECIDE, one frame after
|
|
124
|
+
// `withSpan` had already frozen the span's parent, so an inbound `traceparent` was read into
|
|
125
|
+
// `ctx.traceId` and the span kept a trace id nothing else in the request had ever seen.
|
|
126
|
+
'request-id': (_request, ctx) => {
|
|
127
|
+
ctx.headers.set('x-request-id', ctx.requestId);
|
|
128
|
+
return undefined;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
admit: (_request, ctx) => {
|
|
132
|
+
// Before the trace, the route match, auth, the body — everything. A refusal that costs as
|
|
133
|
+
// much as a served request is not load shedding, and this is the stage that makes
|
|
134
|
+
// "reject 40% fast, serve 60% at p99" expressible at all.
|
|
135
|
+
if (isDraining()) {
|
|
136
|
+
ctx.headers.set('retry-after', SHED_RETRY_AFTER_SECONDS);
|
|
137
|
+
throw draining();
|
|
138
|
+
}
|
|
139
|
+
const ceiling = config.maxInflight;
|
|
140
|
+
// `beginWork()` in `server.ts` counted THIS request before the pipeline was entered, so the
|
|
141
|
+
// ceiling is compared against a number that already includes it.
|
|
142
|
+
if (ceiling > 0 && inflightCount() > ceiling) {
|
|
143
|
+
ctx.headers.set('retry-after', SHED_RETRY_AFTER_SECONDS);
|
|
144
|
+
throw overloaded(inflightCount(), ceiling);
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
trace: (_request, ctx) => {
|
|
150
|
+
ctx.headers.set('x-trace-id', ctx.traceId);
|
|
151
|
+
return undefined;
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
context: (request, ctx) => {
|
|
155
|
+
// A preflight carries no credentials, so answering it after `auth` would 401
|
|
156
|
+
// every legitimate cross-origin call.
|
|
157
|
+
const answered = preflight(request.raw, config.cors);
|
|
158
|
+
if (answered !== undefined) return answered;
|
|
159
|
+
|
|
160
|
+
ctx.clientBuildId = request.header(config.buildIdHeader);
|
|
161
|
+
request.assertBuild();
|
|
162
|
+
|
|
163
|
+
const pathname = stripBasePath(ctx.url.pathname, config.basePath);
|
|
164
|
+
const match = matchRoute(input.table, ctx.method, pathname);
|
|
165
|
+
if (!match.ok) {
|
|
166
|
+
if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
|
|
167
|
+
if (match.reason === 'path-invalid') throw pathInvalid(pathname, match.segment);
|
|
168
|
+
ctx.headers.set('allow', match.allow.join(', '));
|
|
169
|
+
throw methodNotAllowed(ctx.method, pathname, match.allow);
|
|
170
|
+
}
|
|
171
|
+
ctx.route = match.route;
|
|
172
|
+
ctx.params = match.params;
|
|
173
|
+
return undefined;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The two values land on `ctx.locale` / `ctx.tz` — core's own declared fields, which is what
|
|
178
|
+
* makes `currentLocale()` and `currentTimeZone()` answer for this request once `pipeline.ts`
|
|
179
|
+
* publishes the context into the ALS. This stage decides only WHERE to read them from; the
|
|
180
|
+
* owners decide what they mean, so `Accept-Language: zh-Hant-TW` and `x-timezone: eUrOpE/bErLiN`
|
|
181
|
+
* get one answer in the framework rather than one per package.
|
|
182
|
+
*/
|
|
183
|
+
locale: (request, ctx) => {
|
|
184
|
+
const cookies = request.header('cookie');
|
|
185
|
+
ctx.locale = resolveLocale({
|
|
186
|
+
header: request.header('accept-language'),
|
|
187
|
+
cookie: readCookie(cookies, config.locale.cookie),
|
|
188
|
+
}).locale;
|
|
189
|
+
ctx.tz = resolveTimeZone({
|
|
190
|
+
cookie: readCookie(cookies, config.tz.cookie),
|
|
191
|
+
header: request.header(config.tz.header),
|
|
192
|
+
}).zone;
|
|
193
|
+
ctx.headers.set('content-language', ctx.locale);
|
|
194
|
+
return undefined;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
auth: async (request, ctx) => {
|
|
198
|
+
if (hooks.authenticate !== undefined) {
|
|
199
|
+
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
200
|
+
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
201
|
+
ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
|
|
202
|
+
}
|
|
203
|
+
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
204
|
+
throw unauthenticated(ctx.url.pathname);
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
'rate-limit': async (_request, ctx) => {
|
|
210
|
+
if (!config.rateLimit.enabled) return undefined;
|
|
211
|
+
const actor = actorView(ctx.actor);
|
|
212
|
+
const key = rateLimitKey({
|
|
213
|
+
actorId: actor?.id ?? null,
|
|
214
|
+
orgId: actor?.orgId ?? null,
|
|
215
|
+
ip: ctx.ip,
|
|
216
|
+
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
|
|
217
|
+
});
|
|
218
|
+
const decision = await limiter.check(
|
|
219
|
+
key,
|
|
220
|
+
ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
|
|
221
|
+
);
|
|
222
|
+
// Recorded before the throw so the 429 can carry Retry-After and the
|
|
223
|
+
// RateLimit-* headers rather than making the client guess.
|
|
224
|
+
ctx.rateLimit = decision;
|
|
225
|
+
for (const [name, value] of Object.entries(limiter.headers(decision))) {
|
|
226
|
+
ctx.headers.set(name, value);
|
|
227
|
+
}
|
|
228
|
+
if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
|
|
229
|
+
return undefined;
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
csrf: (request, ctx) => {
|
|
233
|
+
const verdict = checkCsrf({
|
|
234
|
+
method: ctx.method,
|
|
235
|
+
// `ctx.https`, not `ctx.url.protocol`: behind a TLS-terminating ingress the internal hop
|
|
236
|
+
// is plain http while the browser's `Origin` says https, so comparing the raw URL would
|
|
237
|
+
// refuse every legitimate form post in the shape the framework's own chart ships.
|
|
238
|
+
selfOrigin: selfOrigin(ctx.url, ctx.https),
|
|
239
|
+
origin: request.header('origin'),
|
|
240
|
+
secFetchSite: request.header('sec-fetch-site'),
|
|
241
|
+
hasAuthorizationHeader: request.header('authorization') !== null,
|
|
242
|
+
anonymous: isAnonymous(ctx.actor),
|
|
243
|
+
cors: config.cors,
|
|
244
|
+
config: config.csrf,
|
|
245
|
+
});
|
|
246
|
+
if (!verdict.ok) throw csrfBlocked(ctx.url.pathname, verdict.reason);
|
|
247
|
+
return undefined;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
body: async (request, ctx) => {
|
|
251
|
+
const schema = ctx.route?.meta.input;
|
|
252
|
+
if (schema === undefined) return undefined;
|
|
253
|
+
const outcome = await validate(schema, await request.bodyRaw());
|
|
254
|
+
if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
|
|
255
|
+
ctx.input = outcome.value;
|
|
256
|
+
return undefined;
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
authz: async (request, ctx) => {
|
|
260
|
+
const route = ctx.route;
|
|
261
|
+
if (route === undefined || route.meta.policy === undefined) return undefined;
|
|
262
|
+
// The handler owns this route's single evaluation (`RouteMeta.enforcedBy`). Deciding
|
|
263
|
+
// here as well would be a second authz system holding strictly less than the first —
|
|
264
|
+
// no row — and it is the one that answers first, so it is the one that would win.
|
|
265
|
+
if (route.meta.enforcedBy === 'handler') return undefined;
|
|
266
|
+
if (hooks.authorize === undefined) {
|
|
267
|
+
// A declared policy with no evaluator is a wiring bug, and failing open
|
|
268
|
+
// here is exactly how a framework ends up with two authz systems.
|
|
269
|
+
throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`);
|
|
270
|
+
}
|
|
271
|
+
const decision = await hooks.authorize(route, request, ctx);
|
|
272
|
+
ctx.authz = decision;
|
|
273
|
+
if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason);
|
|
274
|
+
return undefined;
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
handler: async (request, ctx) => {
|
|
278
|
+
const route = ctx.route;
|
|
279
|
+
if (route === undefined) throw routeNotFound(ctx.method, ctx.url.pathname);
|
|
280
|
+
const handler = wrapped.get(route) ?? route.handler;
|
|
281
|
+
return await handler(request, ctx);
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
'cache-headers': (_request, ctx) => {
|
|
285
|
+
const response = ctx.response;
|
|
286
|
+
if (response === undefined) return undefined;
|
|
287
|
+
if (!response.headers.has('cache-control')) {
|
|
288
|
+
applyCacheHeaders(
|
|
289
|
+
response,
|
|
290
|
+
ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
'error-map': (request, ctx) => {
|
|
297
|
+
const error = ctx.error;
|
|
298
|
+
const facts = factsOf(error);
|
|
299
|
+
// This package's ONE error-reporting call site, and it is the framework's own — `onError`
|
|
300
|
+
// below stays the APP's sink. 5xx only: a 404 or a 422 is the caller's mistake, the problem
|
|
301
|
+
// document already told them, and a monitor that also holds those is a log nobody reads.
|
|
302
|
+
// The `operation` is the route PATTERN for the same reason `recordRequest` uses it.
|
|
303
|
+
if (facts.status >= 500) {
|
|
304
|
+
reportError(error, {
|
|
305
|
+
source: 'http',
|
|
306
|
+
scope: {
|
|
307
|
+
requestId: ctx.requestId,
|
|
308
|
+
traceId: ctx.traceId,
|
|
309
|
+
role: ctx.role,
|
|
310
|
+
operation: `${ctx.method} ${ctx.route?.path ?? UNMATCHED_ROUTE}`,
|
|
311
|
+
actorId: isAnonymous(ctx.actor) ? undefined : ctx.actor.id,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
hooks.onError?.(error, ctx);
|
|
316
|
+
// FIELDS, never interpolation. `logger.emit()` redacts `bound`, `contextFields` and
|
|
317
|
+
// `fields` — never `msg` — so a cause baked into the message reached the log store past
|
|
318
|
+
// every rule that exists to stop it: a rejected `{"password":"hunter2"}` was logged
|
|
319
|
+
// verbatim, at 4xx, which is logged and not reported and therefore kept for the full
|
|
320
|
+
// retention. The message is the CODE alone; everything variable is a field.
|
|
321
|
+
// The other half of this is `@ultimat3/schema`'s, and it is the load-bearing one: an issue
|
|
322
|
+
// message must stop echoing the rejected value at all. This change makes the value
|
|
323
|
+
// redactable; it does not make it absent.
|
|
324
|
+
ctx.logger.error(facts.code, { cause: facts.cause, status: facts.status });
|
|
325
|
+
// Before the overlay and before the problem document: a browser with no session has not
|
|
326
|
+
// hit a defect to debug, it has hit a login wall, and the answer to that is the sign-in
|
|
327
|
+
// page. `signInPath` is null until an app declares one, so this is off by default.
|
|
328
|
+
const toSignIn = signInRedirect({
|
|
329
|
+
code: facts.code,
|
|
330
|
+
signInPath: config.signInPath,
|
|
331
|
+
request: request.raw,
|
|
332
|
+
ctx,
|
|
333
|
+
});
|
|
334
|
+
if (toSignIn !== undefined) return redirect(toSignIn.location, toSignIn.status);
|
|
335
|
+
if (config.dev && wantsOverlay(request.raw)) {
|
|
336
|
+
// Asked for inside the branch, never above it: the overlay is the only surface a notice
|
|
337
|
+
// has, so a production process — or an agent that asked for json — must not pay a
|
|
338
|
+
// diagnostic's per-request cost to produce findings nothing will render.
|
|
339
|
+
const notices = hooks.devNotices?.(ctx) ?? [];
|
|
340
|
+
return overlayResponse(error, {
|
|
341
|
+
requestId: ctx.requestId,
|
|
342
|
+
method: ctx.method,
|
|
343
|
+
path: ctx.url.pathname,
|
|
344
|
+
buildId: config.buildId,
|
|
345
|
+
...(notices.length === 0 ? {} : { notices }),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const retryAfter =
|
|
349
|
+
facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
|
|
350
|
+
? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
|
|
351
|
+
: {};
|
|
352
|
+
return problem(error, {
|
|
353
|
+
instance: ctx.url.pathname,
|
|
354
|
+
requestId: ctx.requestId,
|
|
355
|
+
headers: retryAfter,
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
response: (request, ctx) => {
|
|
360
|
+
const response = ctx.response;
|
|
361
|
+
if (response === undefined) return undefined;
|
|
362
|
+
for (const [name, value] of ctx.headers) response.headers.set(name, value);
|
|
363
|
+
for (const [name, value] of Object.entries(
|
|
364
|
+
corsHeaders(config.cors, request.header('origin')),
|
|
365
|
+
)) {
|
|
366
|
+
// `vary` is the one header two stages both contribute to, so it is added and never set:
|
|
367
|
+
// `set` here would drop the cache stage's key (`accept-language`, `cookie`) on the floor.
|
|
368
|
+
if (name === 'vary') addVary(response, [value]);
|
|
369
|
+
else response.headers.set(name, value);
|
|
370
|
+
}
|
|
371
|
+
for (const [name, value] of Object.entries(
|
|
372
|
+
securityHeaders(config.security, { https: ctx.https }),
|
|
373
|
+
)) {
|
|
374
|
+
response.headers.set(name, value);
|
|
375
|
+
}
|
|
376
|
+
response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
|
|
377
|
+
return undefined;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
return table;
|
|
381
|
+
};
|
package/src/type-pins.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Compile-time pins for the shapes this package declares but never constructs. Source, not a
|
|
2
|
+
// `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a
|
|
3
|
+
// test file and a claim written there can never fail. Nothing here emits or is imported — a
|
|
4
|
+
// regression is a build error, the only enforcement that counts (axiom 3).
|
|
5
|
+
|
|
6
|
+
import type { AuthzDecision } from './hooks';
|
|
7
|
+
|
|
8
|
+
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
9
|
+
type Assert<T extends true> = T;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* An allow carries nothing else. `hooks.authorize` is implemented at tier 3 by
|
|
13
|
+
* `@ultimat3/action`, which never sees this package's tests — so "an allowed decision needs no
|
|
14
|
+
* reason" has to be a type error at the declaration, not an assertion over a literal a test wrote.
|
|
15
|
+
*/
|
|
16
|
+
export type _AuthzAllowNeedsNothingElse = Assert<
|
|
17
|
+
{ allowed: true } extends AuthzDecision ? true : false
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* And carries nothing else, which the assignability pin above cannot say: an optional field added
|
|
22
|
+
* to the allow branch leaves `{ allowed: true }` assignable, so the claim in that comment would
|
|
23
|
+
* have gone on compiling while an allow grew somewhere to put a reason. `never` is wrapped in a
|
|
24
|
+
* tuple because a bare `never` on the left of `extends` short-circuits the conditional.
|
|
25
|
+
*/
|
|
26
|
+
type AuthzAllow = Extract<AuthzDecision, { allowed: true }>;
|
|
27
|
+
|
|
28
|
+
export type _AuthzAllowCarriesNothingElse = Assert<
|
|
29
|
+
[Exclude<keyof AuthzAllow, 'allowed'>] extends [never] ? true : false
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A denial must carry its reason: it is what the pipeline renders and what an agent reads.
|
|
34
|
+
*/
|
|
35
|
+
export type _AuthzDenyNeedsAReason = Assert<
|
|
36
|
+
{ allowed: false } extends AuthzDecision ? false : true
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `code` stays optional on a denial — a limiter denying with no framework code must not have to
|
|
41
|
+
* invent one, and `error-map.ts` defaults it. This is the pin that made
|
|
42
|
+
* `hooks.test.ts`'s `if (!decision.allowed) expect(decision.code).toBeUndefined()` deletable: that
|
|
43
|
+
* guard was statically true over a literal the test itself wrote, so it ran no production code and
|
|
44
|
+
* could not fail.
|
|
45
|
+
*/
|
|
46
|
+
export type _AuthzDenyCodeIsOptional = Assert<
|
|
47
|
+
{ allowed: false; reason: string } extends AuthzDecision ? true : false
|
|
48
|
+
>;
|
package/src/validate.ts
CHANGED
|
@@ -26,12 +26,23 @@ export const formatIssue = (issue: Issue): string => {
|
|
|
26
26
|
return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
/** A refusal still owes its reader a sentence, and a degenerate result gave none. */
|
|
30
|
+
const NO_ISSUES_REPORTED = 'the schema reported a failure with no issues';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A Standard Schema result is discriminated by the PRESENCE of `issues`, never by its length: a
|
|
34
|
+
* success result declares `issues?: undefined` and a failure result carries no `value` at all. A
|
|
35
|
+
* length test read `issues: []` as success and returned `value: undefined as Out` — an `undefined`
|
|
36
|
+
* the caller's types say cannot happen, which surfaced one frame later as a `TypeError` and a 500
|
|
37
|
+
* for a request that was simply invalid.
|
|
38
|
+
*/
|
|
29
39
|
const outcome = <Out>(result: {
|
|
30
40
|
readonly value?: Out;
|
|
31
41
|
readonly issues?: readonly Issue[] | undefined;
|
|
32
42
|
}): ValidationOutcome<Out> => {
|
|
33
|
-
if (result.issues !== undefined
|
|
34
|
-
|
|
43
|
+
if (result.issues !== undefined) {
|
|
44
|
+
const issues = result.issues.map(formatIssue);
|
|
45
|
+
return { ok: false, issues: issues.length > 0 ? issues : [NO_ISSUES_REPORTED] };
|
|
35
46
|
}
|
|
36
47
|
return { ok: true, value: result.value as Out };
|
|
37
48
|
};
|