@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.
@@ -1,6 +1,9 @@
1
- // Locked security headers. The CSP is written to work with the three things the
2
- // framework actually ships a service worker, streamed HTML with a hydration
3
- // nonce, and wasm and nothing else, so widening it is a visible config change.
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
- /** Directive -> sources. `'nonce-*'` is injected per response, never stored here. */
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
- 'style-src': ["'self'"],
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, nonce?: string): string => {
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: { nonce?: string; https?: boolean } = {},
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, options.nonce),
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 skip it.
83
- if (config.hsts !== null && options.https !== false) {
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
- // Delegate to core so a manual stop() and a real SIGTERM take the identical
180
- // three-phase path. The drain deadline is core's, not ours.
181
- await drain('manual');
182
- unregister?.();
183
- unregisterClose?.();
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
+ };
@@ -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 && result.issues.length > 0) {
34
- return { ok: false, issues: result.issues.map(formatIssue) };
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
  };