@ultimat3/http 23.0.0 → 25.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.
Files changed (54) hide show
  1. package/CLAUDE.md +75 -31
  2. package/README.md +89 -26
  3. package/package.json +9 -6
  4. package/src/app-config.ts +3 -1
  5. package/src/auth-redirect.ts +56 -17
  6. package/src/bearer-mount.ts +81 -26
  7. package/src/cache-policy.ts +10 -2
  8. package/src/config.ts +62 -32
  9. package/src/context.ts +11 -11
  10. package/src/correlation.ts +2 -2
  11. package/src/cors.ts +3 -2
  12. package/src/csrf.ts +17 -8
  13. package/src/deadline.ts +10 -8
  14. package/src/error-facts.ts +18 -2
  15. package/src/error-map-http.ts +94 -0
  16. package/src/error-map-tier-0.ts +62 -0
  17. package/src/error-map-tier-1.ts +73 -0
  18. package/src/error-map-tier-2.ts +120 -0
  19. package/src/error-map-tier-3.ts +100 -0
  20. package/src/error-map-tier-4.ts +116 -0
  21. package/src/error-map.ts +27 -474
  22. package/src/error-page.ts +3 -4
  23. package/src/error-titles.ts +93 -0
  24. package/src/errors.ts +46 -96
  25. package/src/forwarded.ts +36 -10
  26. package/src/health-disclosure.ts +66 -0
  27. package/src/hooks.ts +6 -5
  28. package/src/html-render.ts +3 -17
  29. package/src/index.ts +36 -27
  30. package/src/locale-prefix.ts +5 -1
  31. package/src/locale.ts +0 -27
  32. package/src/navigation.ts +26 -1
  33. package/src/overlay.ts +3 -1
  34. package/src/peer-identity.ts +10 -6
  35. package/src/pipeline.ts +32 -13
  36. package/src/preferences.ts +1 -1
  37. package/src/problem-meta.ts +7 -21
  38. package/src/rate-limit-buckets.ts +26 -2
  39. package/src/rate-limit-errors.ts +36 -10
  40. package/src/rate-limit-headers.ts +40 -0
  41. package/src/rate-limit-installed.ts +79 -0
  42. package/src/rate-limit-peek.ts +32 -0
  43. package/src/rate-limit-postgres.ts +30 -21
  44. package/src/rate-limit-stage.ts +132 -0
  45. package/src/rate-limit.ts +40 -15
  46. package/src/request-facts.ts +44 -0
  47. package/src/request.ts +12 -5
  48. package/src/response.ts +10 -2
  49. package/src/route-cache.ts +1 -1
  50. package/src/router.ts +36 -8
  51. package/src/server.ts +101 -30
  52. package/src/set-cookie.ts +60 -0
  53. package/src/stages.ts +50 -54
  54. package/src/validate.ts +2 -2
package/src/server.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  beginWork,
8
8
  configureLifecycle,
9
9
  drain,
10
+ healthBody,
10
11
  healthzPayload,
11
12
  lifecycleState,
12
13
  logger,
@@ -14,17 +15,20 @@ import {
14
15
  markReady,
15
16
  onShutdown,
16
17
  readyzPayload,
18
+ systemClock,
17
19
  } from '@ultimat3/core';
18
20
  import type { Server } from 'bun';
19
21
  import { defineHttpConfig, type HttpConfig } from './config';
20
22
  import { HttpError, serverNotStarted } from './errors';
23
+ import { disclosesHealthDetail } from './health-disclosure';
21
24
  import type { ServerHooks } from './hooks';
22
25
  import type { Middleware } from './middleware';
23
- import { createPipeline, type Pipeline } from './pipeline';
24
- import { createRateLimiter, type RateLimitStore } from './rate-limit';
26
+ import { httpPipeline, type Pipeline } from './pipeline';
27
+ import { type RateLimitStore, rateLimiter } from './rate-limit';
25
28
  import { withRouteBuckets } from './rate-limit-buckets';
26
- import { json } from './response';
27
- import { createRouter, describeRoutes, type Route, type RouteDescription } from './router';
29
+ import { adoptRateLimitStore } from './rate-limit-installed';
30
+ import { jsonResponse } from './response';
31
+ import { describeRoutes, httpRouter, type Route, type RouteDescription } from './router';
28
32
 
29
33
  /**
30
34
  * Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
@@ -33,13 +37,13 @@ import { createRouter, describeRoutes, type Route, type RouteDescription } from
33
37
  * A websocket mount and something already answering its path. Same code as two routes claiming
34
38
  * one, because it is the same fact: one path, two declarations, and the framework picks — Bun's
35
39
  * native route table is matched BEFORE `fetch`, so the route wins and the upgrade never reaches
36
- * the mount. Refused at `createServer`, not discovered as a socket that will not open.
40
+ * the mount. Refused at `httpServer`, not discovered as a socket that will not open.
37
41
  */
38
42
  const websocketPathTaken = (path: string, answered: string): HttpError =>
39
43
  new HttpError({
40
44
  code: 'X_ROUTE_CONFLICT',
41
45
  cause: `the websocket mount claims ${path}, and ${answered} already answers it — Bun matches its native route table before \`fetch\`, so the upgrade would never reach the mount`,
42
- fix: `x routes list --json # then move the mount's path, or the declaration at ${path}`,
46
+ fix: `x routes --json # then move the mount's path, or the declaration at ${path}`,
43
47
  });
44
48
 
45
49
  /** Core owns the state machine; this alias exists so callers need one import. */
@@ -61,7 +65,7 @@ export interface ServerOptions {
61
65
  * Where the rate limiter keeps its counters. Omitted means `memoryRateLimitStore()`, which is
62
66
  * one process' worth of state — correct for dev and tests, and N × every configured number for
63
67
  * N replicas. An app that runs more than one process declares `rateLimit.scope: 'shared'` and
64
- * passes a store that says the same, or `createServer` refuses here.
68
+ * passes a store that says the same, or `httpServer` refuses here.
65
69
  */
66
70
  readonly rateLimitStore?: RateLimitStore;
67
71
  /**
@@ -98,6 +102,8 @@ export interface ServerOptions {
98
102
  */
99
103
  export interface UpgradeTarget {
100
104
  upgrade(request: Request, options: { data: unknown }): boolean;
105
+ /** The peer's socket address — a mount that answers its own health paths decides detail by it. */
106
+ requestIP(request: Request): { readonly address: string } | null;
101
107
  }
102
108
 
103
109
  /**
@@ -138,7 +144,7 @@ export interface ServerHandle {
138
144
  url(): string;
139
145
  describe(): readonly RouteDescription[];
140
146
  start(): ServerHandle;
141
- /** Runs core's three-phase drain. The deadline is `config.drainTimeoutMs`. */
147
+ /** Runs core's three-phase drain, on the deadline `drain.deadlineMs` set (`lifecycleForRole`). */
142
148
  stop(): Promise<void>;
143
149
  /**
144
150
  * Runs one request through the entire lifecycle with no socket. This is the
@@ -147,20 +153,41 @@ export interface ServerHandle {
147
153
  fetch(request: Request): Promise<Response>;
148
154
  }
149
155
 
156
+ /** How often the `inflight` hook re-reads Bun's count of unfinished responses. */
157
+ const BODY_POLL_MS = 5;
158
+
150
159
  const roleFromEnv = (): Role => (Bun.env['ROLE'] ?? 'web') as Role;
151
160
 
152
- export const createServer = (options: ServerOptions): ServerHandle => {
161
+ export const httpServer = (options: ServerOptions): ServerHandle => {
162
+ // The store this server is handed is also the one actions and queries spend their declared
163
+ // `rateLimit:` from — adopted BEFORE the pipeline, whose boot check reads it, and never over a
164
+ // store the boot already chose (`keepChosen`). Given back on `stop()`, or by ANY refusal from
165
+ // here to the handle — the pipeline's checks, `configureLifecycle`, `assertMountPathFree` — so a
166
+ // boot that never produced a server leaves nothing behind it.
167
+ const releaseStore =
168
+ options.rateLimitStore === undefined
169
+ ? () => undefined
170
+ : adoptRateLimitStore(options.rateLimitStore, { keepChosen: true });
171
+ try {
172
+ return buildServer(options, releaseStore);
173
+ } catch (error) {
174
+ releaseStore();
175
+ throw error;
176
+ }
177
+ };
178
+
179
+ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHandle => {
153
180
  const role = options.role ?? roleFromEnv();
154
- const table = createRouter(options.routes);
155
- // Merged here as well as in `createPipeline`, and for the store's sake: the limiter below is
181
+ const table = httpRouter(options.routes);
182
+ // Merged here as well as in `httpPipeline`, and for the store's sake: the limiter below is
156
183
  // built from `config.rateLimit`, so a table without the routes' own buckets would resolve a
157
184
  // declared name to `default` — the very hole this closes. `withRouteBuckets` is idempotent, so
158
185
  // the pipeline's second pass changes nothing. `handle.config` is the merged one for the same
159
186
  // reason: `server.config.rateLimit.buckets` has to be what the limiter runs on.
160
187
  const config = withRouteBuckets(options.config ?? defineHttpConfig(), table.routes);
161
188
  // The store feeds the limiter seam `PipelineDeps` already had, rather than becoming a second
162
- // one: the bucket maths stays in `createRateLimiter`, so every driver agrees on the numbers.
163
- const pipeline = createPipeline({
189
+ // one: the bucket maths stays in `rateLimiter`, so every driver agrees on the numbers.
190
+ const pipeline = httpPipeline({
164
191
  table,
165
192
  config,
166
193
  ...(options.hooks === undefined ? {} : { hooks: options.hooks }),
@@ -168,17 +195,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
168
195
  ...(options.rateLimitStore === undefined
169
196
  ? {}
170
197
  : {
171
- limiter: createRateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
198
+ limiter: rateLimiter({ config: config.rateLimit, store: options.rateLimitStore }),
172
199
  }),
173
200
  });
174
201
 
175
- // The one HTTP-owned knob feeds core's deadline, so there is a single drain budget — and only
176
- // when this app DECLARED it. Unconditional, with `defineHttpConfig` defaulting the number, this
177
- // line reverted `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s
178
- // own `fix:` prints — back to 15s on every boot that serves web, silently. "Nobody said" and
179
- // "the app said 15 seconds" are different claims and `null` is what keeps them apart.
180
- if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
181
- // Same rule: only a declared grace is applied. Core waits it out between the readiness flip and
202
+ // No deadline is set here. `drain.deadlineMs` is the one budget, applied per role by
203
+ // `lifecycleForRole` before any server exists; `http.drainTimeoutMs` re-set it on the web role
204
+ // only, after that, and was deleted in 25.0.0 for being a second answer (axiom 1).
205
+ // Only a declared grace is applied. Core waits it out between the readiness flip and
182
206
  // the `accept` hook below, so endpoints stop routing here before the socket closes.
183
207
  const readinessGraceMs = options.drain?.readinessGraceMs;
184
208
  if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
@@ -186,6 +210,7 @@ export const createServer = (options: ServerOptions): ServerHandle => {
186
210
  const mount = options.websocket;
187
211
  let server: BunServer | undefined;
188
212
  let unregister: (() => void) | undefined;
213
+ let unregisterBodies: (() => void) | undefined;
189
214
  let unregisterClose: (() => void) | undefined;
190
215
  let stopListening: (() => void) | undefined;
191
216
 
@@ -193,10 +218,21 @@ export const createServer = (options: ServerOptions): ServerHandle => {
193
218
  * Core owns the health state, the in-flight count and the drain deadline so every
194
219
  * role reports identically. `HealthPayload` is `{ ok, status, body }` — core stays
195
220
  * HTTP-free and hands us the status code as data, which we render here.
221
+ *
222
+ * The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a
223
+ * peer `healthDetailPeers` lists: these two paths answer outside the pipeline, unauthenticated.
196
224
  */
197
- const healthResponse = (payload: HealthPayload): Response =>
198
- json(
199
- { ...payload.body, role },
225
+ const healthResponse = (payload: HealthPayload, request: Request, socket: BunServer): Response =>
226
+ jsonResponse(
227
+ healthBody(
228
+ payload.body,
229
+ role,
230
+ disclosesHealthDetail({
231
+ config,
232
+ headers: request.headers,
233
+ socketAddress: socket.requestIP(request)?.address ?? null,
234
+ }),
235
+ ),
200
236
  { status: payload.status, headers: { 'cache-control': 'no-store' } },
201
237
  );
202
238
 
@@ -232,7 +268,10 @@ export const createServer = (options: ServerOptions): ServerHandle => {
232
268
  const out: Record<string, NativeHandler> = {};
233
269
  for (const description of describeRoutes(table)) {
234
270
  if (description.params.length > 0) continue;
235
- out[`${prefix}${description.path}`] = dispatch;
271
+ // `encodeURI`: `Bun.serve` refuses a non-ASCII key with a bare `TypeError`, so one route named
272
+ // `/precios-españa` kept the whole server from starting. Encoded as `URL.pathname` spells
273
+ // it; any other spelling falls through to `fetch`, where the router decodes it.
274
+ out[encodeURI(`${prefix}${description.path}`)] = dispatch;
236
275
  }
237
276
  return out;
238
277
  };
@@ -289,11 +328,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
289
328
  ...nativeRoutes(),
290
329
  // Health endpoints answer outside the pipeline on purpose: a draining or
291
330
  // rate-limited process must still be able to say what it is doing.
292
- '/healthz': () => healthResponse(healthzPayload()),
331
+ '/healthz': (request: Request, socket: BunServer) =>
332
+ healthResponse(healthzPayload(), request, socket),
293
333
  // `?deep=1` always answers on the dependencies, whatever `health.readiness` says.
294
- '/readyz': (request: Request) =>
334
+ '/readyz': (request: Request, socket: BunServer) =>
295
335
  healthResponse(
296
336
  readyzPayload({ deep: new URL(request.url).searchParams.get('deep') === '1' }),
337
+ request,
338
+ socket,
297
339
  ),
298
340
  },
299
341
  };
@@ -321,14 +363,40 @@ export const createServer = (options: ServerOptions): ServerHandle => {
321
363
  // 'accept' runs first on SIGTERM, but only after core's readiness grace: readyz flips to
322
364
  // 503 the moment the drain starts, the socket stays open for `readinessGraceMs`, so the
323
365
  // endpoints controller stops routing here before this closes it — not a 502 in between.
366
+ //
367
+ // NOT awaited: Bun's `stop(false)` stops the listener at once but resolves only when every
368
+ // request AND every websocket on it has closed — so one open browser tab held this hook, and
369
+ // every `accept` hook after it (worker, scheduler), until the drain deadline, leaving the
370
+ // `close` hooks 0 ms. Handlers are core's to wait for (`beginWork()`), bodies the `inflight`
371
+ // hook's below, sockets the `close` hook's `stop(true)`. The same shape as
372
+ // `realtime/src/sync-listen.ts`. A rejection is the close hook's to surface, not this one's.
324
373
  unregister = onShutdown(
325
374
  `http:${role}`,
326
- async () => {
327
- await server?.stop(false);
375
+ () => {
376
+ server?.stop(false).catch(() => undefined);
328
377
  },
329
378
  { phase: 'accept' },
330
379
  );
331
- // 'close' runs after core has waited out the in-flight phase.
380
+ // The BODIES. `beginWork()` ends when a handler returns its Response, and a streamed body —
381
+ // a page render, a file, an LLM stream — is still being written after that; awaiting
382
+ // `stop(false)` above was what used to wait for it, websockets and all. Bun's
383
+ // `pendingRequests` counts exactly the responses not yet finished (a client that went away
384
+ // included as finished), never an upgraded socket, so this waits for bodies alone and costs
385
+ // the request path nothing. Polled, and bounded by the drain's own deadline: a body that
386
+ // never ends (SSE) is cut by the `close` hook's `stop(true)`, not waited on forever.
387
+ unregisterBodies = onShutdown(
388
+ `http:${role}:bodies`,
389
+ async (reason) => {
390
+ while (
391
+ (server?.pendingRequests ?? 0) > 0 &&
392
+ systemClock.monotonic() < reason.deadlineAt
393
+ ) {
394
+ await Bun.sleep(BODY_POLL_MS);
395
+ }
396
+ },
397
+ { phase: 'inflight' },
398
+ );
399
+ // 'close' runs after core has waited out the in-flight phase, bodies included.
332
400
  unregisterClose = onShutdown(
333
401
  `http:${role}:close`,
334
402
  async () => {
@@ -350,9 +418,12 @@ export const createServer = (options: ServerOptions): ServerHandle => {
350
418
  // core's `shutdownHookCount()` exists to make visible. The shape `worker.ts`'s teardown
351
419
  // holds: the unregisters are the closure's, not the drain's.
352
420
  const releaseHooks = (): void => {
421
+ releaseStore();
353
422
  unregister?.();
423
+ unregisterBodies?.();
354
424
  unregisterClose?.();
355
425
  unregister = undefined;
426
+ unregisterBodies = undefined;
356
427
  unregisterClose = undefined;
357
428
  };
358
429
  if (server === undefined) {
@@ -0,0 +1,60 @@
1
+ // How a route handler, a page loader or an action sets a cookie on the response it cannot build:
2
+ // one serialized line, from core's one serializer, appended to the context's response-header bag —
3
+ // the bag the `response` stage APPENDS `set-cookie` from, so two cookies are two lines, never one.
4
+
5
+ import { CookieInvalidError, type SetCookieOptions, serializeSetCookie } from '@ultimat3/core';
6
+ import { assertInRequest } from './context';
7
+
8
+ /**
9
+ * What identifies the cookie being cleared, plus the attributes its prefix or partition requires.
10
+ * A browser deletes only the cookie whose name, `Path` and `Domain` all match — `Partitioned` too
11
+ * for a CHIPS cookie — so these must be what `setCookie` was given. No `maxAge`, `expires` or
12
+ * `priority`: the clearing line decides those itself.
13
+ */
14
+ export type DeleteCookieOptions = Pick<
15
+ SetCookieOptions,
16
+ 'path' | 'domain' | 'secure' | 'httpOnly' | 'sameSite' | 'partitioned'
17
+ >;
18
+
19
+ const DELETE_FIX = 'deleteCookie(name, { path, domain }) # the one way to clear a cookie';
20
+
21
+ /**
22
+ * Clearing has ONE spelling, `deleteCookie`, so a reader searching for where a cookie dies finds
23
+ * every site. `maxAge: 0` and an `expires` already past both clear in a browser, so both are
24
+ * refused here rather than left as a second, unsearchable way to do the same thing.
25
+ */
26
+ function refuseClearing(name: string, options: SetCookieOptions, now: Date): void {
27
+ if (options.maxAge === 0) {
28
+ throw new CookieInvalidError('maxAge', name, 'is 0, which deletes the cookie', DELETE_FIX);
29
+ }
30
+ const { expires } = options;
31
+ if (expires instanceof Date && expires.getTime() <= now.getTime()) {
32
+ throw new CookieInvalidError(
33
+ 'expires',
34
+ name,
35
+ 'is not after now, so it deletes the cookie',
36
+ DELETE_FIX,
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Set cookie `name` on the response to the request in scope. Defaults are core's:
43
+ * `Path=/; HttpOnly; Secure; SameSite=Lax`. Every call APPENDS a line — the same name on two
44
+ * paths is two cookies — and an invalid cookie is `X_COOKIE_INVALID` before anything is appended.
45
+ * Outside a request it throws `X_NO_REQUEST`: a cookie nobody will receive is a silent failure.
46
+ */
47
+ export const setCookie = (name: string, value: string, options: SetCookieOptions = {}): void => {
48
+ const ctx = assertInRequest('setCookie()');
49
+ refuseClearing(name, options, ctx.now());
50
+ ctx.headers.append('set-cookie', serializeSetCookie(name, value, options));
51
+ };
52
+
53
+ /**
54
+ * Clear cookie `name`: an empty value and `Max-Age=0`, with the `Path` and `Domain` it was set
55
+ * with (defaults the same as `setCookie`'s, so a cookie set with none is cleared with none).
56
+ */
57
+ export const deleteCookie = (name: string, options: DeleteCookieOptions = {}): void => {
58
+ const ctx = assertInRequest('deleteCookie()');
59
+ ctx.headers.append('set-cookie', serializeSetCookie(name, '', { ...options, maxAge: 0 }));
60
+ };
package/src/stages.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {
8
8
  anonymousActor,
9
+ BUILD_ID_HEADER,
9
10
  inflightCount,
10
11
  isAnonymous,
11
12
  isDraining,
@@ -15,7 +16,7 @@ import {
15
16
  import { signInRedirect } from './auth-redirect';
16
17
  import { finalizeCacheHeaders } from './cache-stage';
17
18
  import { type HttpConfig, stripBasePath } from './config';
18
- import { actorView, elapsedMs, type RequestContext } from './context';
19
+ import { elapsedMs, type RequestContext } from './context';
19
20
  import { corsHeaders, preflight } from './cors';
20
21
  import { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
21
22
  import { factsOf, retryAfterOf } from './error-facts';
@@ -38,13 +39,17 @@ import { compose, type Middleware } from './middleware';
38
39
  import { navigationGate, redirectForRouter } from './navigation';
39
40
  import { overlayResponse } from './overlay';
40
41
  import { resolvePreferences } from './preferences';
41
- import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
42
- import { rateLimited } from './rate-limit-errors';
42
+ import type { RateLimiter } from './rate-limit';
43
+ import {
44
+ refuseExhaustedAddress,
45
+ spendRequestBuckets,
46
+ spendUnauthenticated,
47
+ } from './rate-limit-stage';
43
48
  import type { UltimateRequest } from './request';
44
49
  import { addVary, problem, redirect } from './response';
45
50
  import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
46
51
  import { responseSecurityHeaders } from './security-headers';
47
- import { validate } from './validate';
52
+ import { validateBody } from './validate';
48
53
 
49
54
  export type StageName =
50
55
  | 'request-id'
@@ -93,6 +98,10 @@ export interface Stage extends StageDoc {
93
98
  */
94
99
  const SHED_RETRY_AFTER_SECONDS = '1';
95
100
 
101
+ /** The two names a policy travels under — the enforced one and `reportOnly`'s. */
102
+ const isCspHeader = (name: string): boolean =>
103
+ name === 'content-security-policy' || name === 'content-security-policy-report-only';
104
+
96
105
  /**
97
106
  * The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
98
107
  * would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
@@ -165,12 +174,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
165
174
  const answered = preflight(request.raw, config.cors);
166
175
  if (answered !== undefined) return answered;
167
176
 
168
- ctx.clientBuildId = request.header(config.buildIdHeader);
177
+ ctx.clientBuildId = request.header(BUILD_ID_HEADER);
169
178
  // Stamped BEFORE the assertion, so the refusal carries it too. A skew answer that
170
179
  // withholds the server's own id tells a client it is stale without telling it what to
171
180
  // become — and the one caller that can act on that, the service worker holding the
172
181
  // stale id, then has no way to tell this 409 from any other.
173
- if (config.buildId !== null) ctx.headers.set(config.buildIdHeader, config.buildId);
182
+ if (config.buildId !== null) ctx.headers.set(BUILD_ID_HEADER, config.buildId);
174
183
  request.assertBuild();
175
184
 
176
185
  // A `/<locale>/` prefix is stripped BEFORE the match, never by middleware: middleware wraps
@@ -210,6 +219,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
210
219
  // A route's own authenticator REPLACES the app's, never runs beside it: a bearer mount must
211
220
  // not also accept the session cookie the app's hook would resolve (`RouteMeta.authenticate`).
212
221
  const authenticate = ctx.route?.meta.authenticate ?? hooks.authenticate;
222
+ // A required route is where credentials are guessed: an address that has spent its failures
223
+ // is refused before the credential store is asked, a right guess included.
224
+ if (ctx.route?.meta.auth === 'required') {
225
+ await refuseExhaustedAddress(ctx, limiter, config.rateLimit);
226
+ }
213
227
  if (authenticate !== undefined) {
214
228
  // The hook says "anonymous" with null; the context says it with core's anonymous actor,
215
229
  // because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
@@ -221,53 +235,21 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
221
235
  }
222
236
  }
223
237
  if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
238
+ // This refusal leaves before `rate-limit` runs, so it is metered here or nowhere: the
239
+ // address's own allowance, spent only by a failure (`spendUnauthenticated`).
240
+ await spendUnauthenticated(ctx, limiter, config.rateLimit);
224
241
  throw unauthenticated(ctx.url.pathname);
225
242
  }
226
243
  return undefined;
227
244
  },
228
245
 
229
246
  'rate-limit': async (_request, ctx) => {
230
- if (!config.rateLimit.enabled) return undefined;
231
- const actor = actorView(ctx.actor);
232
- // A LIST, and the second entry is why: the key builder used to pick ONE subject —
233
- // actor > org > ip, exclusive — so an authenticated request never touched a tenant bucket
234
- // and one org's 8,000 seats each ran their own allowance against one shared pool.
235
- const spends = rateLimitSpends(
236
- {
237
- actorId: actor?.id ?? null,
238
- orgId: actor?.orgId ?? null,
239
- ip: ctx.ip,
240
- routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
241
- },
242
- {
243
- route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
244
- tenant: config.rateLimit.tenantBucket,
245
- },
247
+ await spendRequestBuckets(
248
+ ctx,
249
+ limiter,
250
+ config.rateLimit,
251
+ ctx.route?.meta.name ?? UNMATCHED_ROUTE,
246
252
  );
247
- let answer: RateLimitDecision | undefined;
248
- let refusedKey: string | undefined;
249
- for (const spend of spends) {
250
- const decision = await limiter.check(spend.key, spend.bucket);
251
- // The bucket closest to refusing is the one the caller has to plan against: reporting
252
- // `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
253
- // tells a client it may proceed and then refuses its next call.
254
- if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
255
- if (!decision.allowed) {
256
- // The first refusal ends the spend, so a caller its own bucket already refused costs
257
- // its tenant nothing — one noisy actor may not drain the allowance it shares.
258
- answer = decision;
259
- refusedKey = spend.key;
260
- break;
261
- }
262
- }
263
- if (answer === undefined) return undefined;
264
- // Recorded before the throw so the 429 can carry Retry-After and the
265
- // RateLimit-* headers rather than making the client guess.
266
- ctx.rateLimit = answer;
267
- for (const [name, value] of Object.entries(limiter.headers(answer))) {
268
- ctx.headers.set(name, value);
269
- }
270
- if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
271
253
  return undefined;
272
254
  },
273
255
 
@@ -292,7 +274,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
292
274
  body: async (request, ctx) => {
293
275
  const schema = ctx.route?.meta.input;
294
276
  if (schema === undefined) return undefined;
295
- const outcome = await validate(schema, await request.bodyRaw());
277
+ const outcome = await validateBody(schema, await request.bodyRaw());
296
278
  if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
297
279
  ctx.input = outcome.value;
298
280
  return undefined;
@@ -394,7 +376,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
394
376
  // never handed a page. Which of the two a browser gets is the ENVIRONMENT — the overlay
395
377
  // prints the cause, the fix and the stack, which is what a visitor may never see.
396
378
  if (acceptsHtml(request.raw)) {
397
- if (config.dev) {
379
+ // A 4xx is not a defect to debug, it is a page a visitor will see, so dev asks for the
380
+ // app's own file first (#492, O-492). A 5xx keeps the overlay, and so does a 4xx the app
381
+ // has no file for: in dev the diagnostic beats the framework's generic page.
382
+ // A throw from the hook is caught by `recoverWith` and degrades to the problem document.
383
+ const override =
384
+ config.dev && facts.status >= 500
385
+ ? undefined
386
+ : await hooks.errorPage?.(facts.status, ctx);
387
+ if (config.dev && override === undefined) {
398
388
  // Asked for inside the branch, never above it: the overlay is the only surface a notice
399
389
  // has, so a production process — or an agent that asked for json — must not pay a
400
390
  // diagnostic's per-request cost to produce findings nothing will render.
@@ -420,10 +410,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
420
410
  ...(seconds === undefined ? {} : { retryAfterSeconds: seconds }),
421
411
  signInPath: config.signInPath,
422
412
  },
423
- // The app's own file, read per request by whoever mounted the hook. A throw here is
424
- // caught by `recoverWith` and degrades to the problem document, which is the answer a
425
- // page whose renderer failed can still give.
426
- { override: await hooks.errorPage?.(facts.status, ctx), headers: retryAfter },
413
+ { override, headers: retryAfter },
427
414
  );
428
415
  }
429
416
  return problem(error, {
@@ -446,7 +433,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
446
433
  if (handedOver !== undefined) ctx.response = handedOver;
447
434
  const response = ctx.response;
448
435
  if (response === undefined) return undefined;
449
- for (const [name, value] of ctx.headers) response.headers.set(name, value);
436
+ // `Set-Cookie` is the one header that is a LIST of field lines: `set` in this loop kept the
437
+ // last cookie only, and dropped any the handler had put on its own Response.
438
+ for (const [name, value] of ctx.headers) {
439
+ if (name === 'set-cookie') response.headers.append(name, value);
440
+ else response.headers.set(name, value);
441
+ }
450
442
  for (const [name, value] of Object.entries(
451
443
  corsHeaders(config.cors, request.header('origin')),
452
444
  )) {
@@ -462,7 +454,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
462
454
  for (const [name, value] of Object.entries(
463
455
  responseSecurityHeaders(config.security, ctx.https),
464
456
  )) {
465
- response.headers.set(name, value);
457
+ // A policy the handler set is KEPT and the app's is added beside it — comma-joined, two
458
+ // policies, both enforced — so a handler can only narrow (a `sandbox` on an uploaded file),
459
+ // never widen. Overwritten, that `sandbox` never reached a browser.
460
+ if (isCspHeader(name) && response.headers.has(name)) response.headers.append(name, value);
461
+ else response.headers.set(name, value);
466
462
  }
467
463
  response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
468
464
  return undefined;
package/src/validate.ts CHANGED
@@ -55,12 +55,12 @@ const outcome = <Out>(result: {
55
55
  export const validateSync = <Out>(schema: Schema<Out>, value: unknown): ValidationOutcome<Out> => {
56
56
  const result = schema['~standard'].validate(value);
57
57
  if (result instanceof Promise) {
58
- return { ok: false, issues: ['schema is async; use validate() for request bodies'] };
58
+ return { ok: false, issues: ['schema is async; use validateBody() for request bodies'] };
59
59
  }
60
60
  return outcome<Out>(result);
61
61
  };
62
62
 
63
- export const validate = async <Out>(
63
+ export const validateBody = async <Out>(
64
64
  schema: Schema<Out>,
65
65
  value: unknown,
66
66
  ): Promise<ValidationOutcome<Out>> => outcome<Out>(await schema['~standard'].validate(value));