@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/src/pipeline.ts CHANGED
@@ -1,78 +1,47 @@
1
- // THE request lifecycle. An explicit, ordered arraynot a middleware stack because
2
- // this order IS the framework's guarantee: context before user code, identity before
3
- // rate limiting, validation before authz, authz before the handler. Nothing can skip a
4
- // stage, and the array is exported so `/_x` renders it and pipeline.test.ts asserts it.
5
- import {
6
- anonymousActor,
7
- isAnonymous,
8
- logger,
9
- recordRequest,
10
- runWithContext,
11
- withSpan,
12
- } from '@ultimat3/core';
13
- import { defineHttpConfig, type HttpConfig, stripBasePath } from './config';
14
- import { actorView, asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
15
- import { corsHeaders, preflight } from './cors';
16
- import { factsOf } from './error-map';
17
- import {
18
- bodyInvalid,
19
- forbidden,
20
- methodNotAllowed,
21
- pipelineNoResponse,
22
- rateLimited,
23
- routeNotFound,
24
- unauthenticated,
25
- } from './errors';
1
+ // THE request lifecycle: which stages exist, in what ORDER, why and the one loop that drives a
2
+ // request through them. The order IS the framework's guarantee, an explicit array and not a
3
+ // middleware stack, so nothing can skip a stage and `/_x` and `pipeline.test.ts` can both read it.
4
+ // The other two thirds of the lifecycle are siblings: `stages.ts` owns what each stage does, and
5
+ // `finalize.ts` owns the promise that the tail always answers rather than rejecting.
6
+ import { recordRequest, runWithContext, withSpan } from '@ultimat3/core';
7
+ import { defineHttpConfig, type HttpConfig } from './config';
8
+ import { asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
9
+ import { readCorrelation } from './correlation';
10
+ import { type Deadline, startDeadline } from './deadline';
11
+ import { pipelineNoResponse } from './errors';
12
+ import { recoverWith, runFinalize } from './finalize';
13
+ import { clientAddress, clientUsedHttps } from './forwarded';
26
14
  import type { ServerHooks } from './hooks';
27
- import { negotiateLocale, readCookie, resolveTimeZone } from './locale';
28
- import { compose, type Middleware } from './middleware';
29
- import { overlayResponse, wantsOverlay } from './overlay';
30
- import { createRateLimiter, type RateLimiter, rateLimitKey } from './rate-limit';
15
+ import type { Middleware } from './middleware';
16
+ import { peerIdentity } from './peer-identity';
17
+ import { assertRateLimitScope, createRateLimiter, type RateLimiter } from './rate-limit';
18
+ import { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
31
19
  import { UltimateRequest } from './request';
32
- import { applyCacheHeaders, type CacheHint, problem } from './response';
33
- import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
34
- import { securityHeaders } from './security-headers';
35
- import { validate } from './validate';
36
-
37
- export type StageName =
38
- | 'request-id'
39
- | 'trace'
40
- | 'context'
41
- | 'locale'
42
- | 'auth'
43
- | 'rate-limit'
44
- | 'body'
45
- | 'authz'
46
- | 'handler'
47
- | 'cache-headers'
48
- | 'error-map'
49
- | 'response';
50
-
51
- /**
52
- * `request` may short-circuit by returning a Response.
53
- * `terminal` runs the route handler.
54
- * `recover` runs only when something above threw.
55
- * `finalize` always runs, on success and on failure.
56
- */
57
- export type StagePhase = 'request' | 'terminal' | 'recover' | 'finalize';
58
-
59
- export interface StageDoc {
60
- readonly name: StageName;
61
- readonly phase: StagePhase;
62
- /** Why the stage sits at this index. Rendered verbatim by the dev dashboard. */
63
- readonly why: string;
64
- }
20
+ import { problem } from './response';
21
+ import type { RouteTable } from './router';
22
+ import {
23
+ type Stage,
24
+ type StageDoc,
25
+ type StagePhase,
26
+ stageRunners,
27
+ UNMATCHED_ROUTE,
28
+ } from './stages';
65
29
 
66
30
  export const PIPELINE_STAGES: readonly StageDoc[] = [
67
31
  {
68
32
  name: 'request-id',
69
33
  phase: 'request',
70
- why: 'first: every log line, span, error body and problem document quotes it, so it must exist before anything can fail',
34
+ why: 'first: every log line, span, error body and problem document quotes it, so it must be on the response before anything can fail',
35
+ },
36
+ {
37
+ name: 'admit',
38
+ phase: 'request',
39
+ why: 'second, and before every other stage does any work: a draining process answers 503 here rather than accepting work it will abandon, and past maxInflight a request is shed with Retry-After before it can queue behind the pool that is already the bottleneck',
71
40
  },
72
41
  {
73
42
  name: 'trace',
74
43
  phase: 'request',
75
- why: 'before any I/O: a span started later would silently exclude auth and DB latency from the trace',
44
+ why: 'publishes the trace id the root span already carries — the span is started before stage 1, from the inbound traceparent, because one started here would exclude every stage above it and could not adopt the caller as its parent',
76
45
  },
77
46
  {
78
47
  name: 'context',
@@ -94,6 +63,11 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
94
63
  phase: 'request',
95
64
  why: 'before the body is read: a limited request must never make the server allocate its payload',
96
65
  },
66
+ {
67
+ name: 'csrf',
68
+ phase: 'request',
69
+ why: 'after auth so it only judges a caller holding an AMBIENT credential — a bearer token and an anonymous call are both exempt — and before body so a forged write never makes the server allocate its payload. CORS cannot cover this: application/x-www-form-urlencoded is a simple content type, so a cross-site form post is sent and executed and only the RESPONSE is withheld',
70
+ },
97
71
  {
98
72
  name: 'body',
99
73
  phase: 'request',
@@ -122,223 +96,22 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
122
96
  },
123
97
  ];
124
98
 
125
- export type StageRun = (
126
- request: UltimateRequest,
127
- ctx: RequestContext,
128
- ) => Response | undefined | Promise<Response | undefined>;
129
-
130
- export interface Stage extends StageDoc {
131
- readonly run: StageRun;
132
- }
133
-
134
99
  export interface PipelineDeps {
135
100
  readonly table: RouteTable;
136
101
  readonly config?: HttpConfig;
137
102
  readonly hooks?: ServerHooks;
138
103
  readonly middleware?: readonly Middleware[];
104
+ /**
105
+ * A limiter built elsewhere — `createServer({ rateLimitStore })` is the one supported way, and
106
+ * it hands over a limiter built from the SAME merged config this constructor would have built.
107
+ * One passed from anywhere else resolves bucket names against the table IT closed over, which
108
+ * is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
109
+ * a limiter that cannot enforce one (`X_RATE_LIMIT_BUCKET_UNBOUND`) rather than letting the
110
+ * name fall through to `default`.
111
+ */
139
112
  readonly limiter?: RateLimiter;
140
113
  }
141
114
 
142
- const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/;
143
- const REQUEST_ID = /^[\w.:-]{8,128}$/;
144
-
145
- /**
146
- * The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
147
- * would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
148
- * the server's cardinality is how a Prometheus dies.
149
- */
150
- const UNMATCHED_ROUTE = 'unmatched';
151
-
152
- /** Authenticated routes are never shared-cacheable; that default is not overridable. */
153
- const defaultCache = (route: Route | undefined): CacheHint =>
154
- route === undefined || route.meta.auth === 'required'
155
- ? { mode: 'no-store' }
156
- : { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
157
-
158
- const runners = (deps: PipelineDeps, config: HttpConfig, limiter: RateLimiter) => {
159
- const hooks = deps.hooks ?? {};
160
- const wrapped = new Map<Route, RouteHandler>();
161
- const wrap = compose(deps.middleware ?? []);
162
- for (const route of deps.table.routes) wrapped.set(route, wrap(route.handler));
163
-
164
- const table: Record<StageName, StageRun> = {
165
- 'request-id': (request, ctx) => {
166
- const inbound = config.trustProxy ? request.header('x-request-id') : null;
167
- if (inbound !== null && REQUEST_ID.test(inbound)) ctx.requestId = inbound;
168
- ctx.headers.set('x-request-id', ctx.requestId);
169
- return undefined;
170
- },
171
-
172
- trace: (request, ctx) => {
173
- const inbound = request.header('traceparent');
174
- const parsed = inbound === null ? null : TRACEPARENT.exec(inbound);
175
- if (parsed !== null) {
176
- ctx.traceId = parsed[1] ?? ctx.traceId;
177
- ctx.parentSpanId = parsed[2] ?? null;
178
- }
179
- ctx.headers.set('x-trace-id', ctx.traceId);
180
- return undefined;
181
- },
182
-
183
- context: (request, ctx) => {
184
- // A preflight carries no credentials, so answering it after `auth` would 401
185
- // every legitimate cross-origin call.
186
- const answered = preflight(request.raw, config.cors);
187
- if (answered !== undefined) return answered;
188
-
189
- ctx.buildId = request.header(config.buildIdHeader);
190
- request.assertBuild();
191
-
192
- const pathname = stripBasePath(ctx.url.pathname, config.basePath);
193
- const match = matchRoute(deps.table, ctx.method, pathname);
194
- if (!match.ok) {
195
- if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
196
- ctx.headers.set('allow', match.allow.join(', '));
197
- throw methodNotAllowed(ctx.method, pathname, match.allow);
198
- }
199
- ctx.route = match.route;
200
- ctx.params = match.params;
201
- return undefined;
202
- },
203
-
204
- locale: (request, ctx) => {
205
- const cookies = request.header('cookie');
206
- ctx.locale = negotiateLocale(
207
- request.header('accept-language'),
208
- config.locale,
209
- readCookie(cookies, config.locale.cookie),
210
- );
211
- ctx.tz = resolveTimeZone(
212
- request.header(config.tz.header) ?? readCookie(cookies, config.tz.cookie),
213
- config.tz,
214
- );
215
- ctx.headers.set('content-language', ctx.locale);
216
- return undefined;
217
- },
218
-
219
- auth: async (request, ctx) => {
220
- if (hooks.authenticate !== undefined) {
221
- // The hook says "anonymous" with null; the context says it with core's anonymous actor,
222
- // because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
223
- ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
224
- }
225
- if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
226
- throw unauthenticated(ctx.url.pathname);
227
- }
228
- return undefined;
229
- },
230
-
231
- 'rate-limit': async (_request, ctx) => {
232
- if (!config.rateLimit.enabled) return undefined;
233
- const actor = actorView(ctx.actor);
234
- const key = rateLimitKey({
235
- actorId: actor?.id ?? null,
236
- orgId: actor?.orgId ?? null,
237
- ip: ctx.ip,
238
- routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
239
- });
240
- const decision = await limiter.check(
241
- key,
242
- ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
243
- );
244
- // Recorded before the throw so the 429 can carry Retry-After and the
245
- // RateLimit-* headers rather than making the client guess.
246
- ctx.rateLimit = decision;
247
- for (const [name, value] of Object.entries(limiter.headers(decision))) {
248
- ctx.headers.set(name, value);
249
- }
250
- if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
251
- return undefined;
252
- },
253
-
254
- body: async (request, ctx) => {
255
- const schema = ctx.route?.meta.input;
256
- if (schema === undefined) return undefined;
257
- const outcome = await validate(schema, await request.bodyRaw());
258
- if (!outcome.ok) throw bodyInvalid(ctx.url.pathname, outcome.issues);
259
- ctx.input = outcome.value;
260
- return undefined;
261
- },
262
-
263
- authz: async (request, ctx) => {
264
- const route = ctx.route;
265
- if (route === undefined || route.meta.policy === undefined) return undefined;
266
- // The handler owns this route's single evaluation (`RouteMeta.enforcedBy`). Deciding
267
- // here as well would be a second authz system holding strictly less than the first —
268
- // no row — and it is the one that answers first, so it is the one that would win.
269
- if (route.meta.enforcedBy === 'handler') return undefined;
270
- if (hooks.authorize === undefined) {
271
- // A declared policy with no evaluator is a wiring bug, and failing open
272
- // here is exactly how a framework ends up with two authz systems.
273
- throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`);
274
- }
275
- const decision = await hooks.authorize(route, request, ctx);
276
- ctx.authz = decision;
277
- if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason);
278
- return undefined;
279
- },
280
-
281
- handler: async (request, ctx) => {
282
- const route = ctx.route;
283
- if (route === undefined) throw routeNotFound(ctx.method, ctx.url.pathname);
284
- const handler = wrapped.get(route) ?? route.handler;
285
- return await handler(request, ctx);
286
- },
287
-
288
- 'cache-headers': (_request, ctx) => {
289
- const response = ctx.response;
290
- if (response === undefined) return undefined;
291
- if (!response.headers.has('cache-control')) {
292
- applyCacheHeaders(response, ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route));
293
- }
294
- return undefined;
295
- },
296
-
297
- 'error-map': (request, ctx) => {
298
- const error = ctx.error;
299
- const facts = factsOf(error);
300
- hooks.onError?.(error, ctx);
301
- logger.error(`${facts.code}: ${facts.cause} [${ctx.requestId}]`);
302
- if (config.dev && wantsOverlay(request.raw)) {
303
- return overlayResponse(error, {
304
- requestId: ctx.requestId,
305
- method: ctx.method,
306
- path: ctx.url.pathname,
307
- buildId: config.buildId,
308
- });
309
- }
310
- const retryAfter =
311
- facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
312
- ? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
313
- : {};
314
- return problem(error, {
315
- instance: ctx.url.pathname,
316
- requestId: ctx.requestId,
317
- headers: retryAfter,
318
- });
319
- },
320
-
321
- response: (request, ctx) => {
322
- const response = ctx.response;
323
- if (response === undefined) return undefined;
324
- for (const [name, value] of ctx.headers) response.headers.set(name, value);
325
- for (const [name, value] of Object.entries(
326
- corsHeaders(config.cors, request.header('origin')),
327
- )) {
328
- response.headers.set(name, value);
329
- }
330
- for (const [name, value] of Object.entries(
331
- securityHeaders(config.security, { https: ctx.https }),
332
- )) {
333
- response.headers.set(name, value);
334
- }
335
- response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
336
- return undefined;
337
- },
338
- };
339
- return table;
340
- };
341
-
342
115
  export interface HandleInit {
343
116
  readonly role: RequestContext['role'];
344
117
  readonly ip?: string | null;
@@ -347,14 +120,32 @@ export interface HandleInit {
347
120
  export interface Pipeline {
348
121
  readonly stages: readonly Stage[];
349
122
  readonly config: HttpConfig;
350
- /** Runs the full lifecycle for one request and always resolves to a Response. */
123
+ /**
124
+ * Runs the full lifecycle for one request and always resolves to a Response — a stage that
125
+ * throws after the handler, or while rendering another stage's throw, degrades to the coded
126
+ * 500 (`X_PIPELINE_FINALIZE_FAILED`) rather than rejecting. A caller has a socket open.
127
+ */
351
128
  handle(request: Request, init: HandleInit): Promise<Response>;
352
129
  }
353
130
 
354
131
  export const createPipeline = (deps: PipelineDeps): Pipeline => {
355
- const config = deps.config ?? defineHttpConfig();
132
+ // Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
133
+ // bucket a route declares only becomes real at the construction that holds both. Idempotent, so
134
+ // a config `createServer` already merged passes through unchanged.
135
+ const config = withRouteBuckets(deps.config ?? defineHttpConfig(), deps.table.routes);
356
136
  const limiter = deps.limiter ?? createRateLimiter({ config: config.rateLimit });
357
- const run = runners(deps, config, limiter);
137
+ // Here rather than in `createServer`: this is the one construction path every server, test and
138
+ // embedder shares, so a limiter that cannot keep the app's declaration is refused exactly once.
139
+ // Two halves of one question — where the counters live, and which buckets the limiter holds.
140
+ assertRateLimitScope(config.rateLimit, limiter);
141
+ assertRouteBuckets(limiter, deps.table.routes);
142
+ const run = stageRunners({
143
+ table: deps.table,
144
+ config,
145
+ limiter,
146
+ hooks: deps.hooks ?? {},
147
+ middleware: deps.middleware ?? [],
148
+ });
358
149
  const stages: readonly Stage[] = PIPELINE_STAGES.map((doc) => ({ ...doc, run: run[doc.name] }));
359
150
 
360
151
  const byPhase = (phase: StagePhase): readonly Stage[] =>
@@ -362,30 +153,45 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
362
153
  const requestStages = byPhase('request');
363
154
  const finalizeStages = byPhase('finalize');
364
155
  const terminal = stages.find((stage) => stage.phase === 'terminal');
365
- const recover = stages.find((stage) => stage.phase === 'recover');
156
+ const recovered = recoverWith(stages.find((stage) => stage.phase === 'recover'));
366
157
 
367
- const execute = async (request: UltimateRequest, ctx: RequestContext): Promise<Response> => {
368
- try {
369
- for (const stage of requestStages) {
370
- const short = await stage.run(request, ctx);
371
- if (short !== undefined) {
372
- ctx.response = short;
373
- break;
374
- }
158
+ const runStages = async (request: UltimateRequest, ctx: RequestContext): Promise<void> => {
159
+ for (const stage of requestStages) {
160
+ const short = await stage.run(request, ctx);
161
+ if (short !== undefined) {
162
+ ctx.response = short;
163
+ return;
375
164
  }
376
- if (ctx.response === undefined && terminal !== undefined) {
377
- ctx.response = (await terminal.run(request, ctx)) ?? new Response(null, { status: 204 });
165
+ }
166
+ if (ctx.response === undefined && terminal !== undefined) {
167
+ ctx.response = (await terminal.run(request, ctx)) ?? new Response(null, { status: 204 });
168
+ }
169
+ };
170
+
171
+ const execute = async (
172
+ request: UltimateRequest,
173
+ ctx: RequestContext,
174
+ deadline: Deadline,
175
+ ): Promise<Response> => {
176
+ try {
177
+ const work = runStages(request, ctx);
178
+ if (deadline.expired === undefined) await work;
179
+ else {
180
+ // The abort is the cooperative half and app code that reads `ctx.signal` unwinds on its
181
+ // own; this race is the half that answers the SOCKET when it does not. `work` keeps its
182
+ // own handler either way — a rejection arriving after the deadline already won is still
183
+ // a rejection, and an unhandled one takes the process down.
184
+ void work.catch(() => undefined);
185
+ await Promise.race([work, deadline.expired]);
378
186
  }
379
187
  } catch (error) {
380
188
  ctx.error = error;
381
- ctx.response =
382
- (recover === undefined ? undefined : await recover.run(request, ctx)) ??
383
- problem(error, { instance: ctx.url.pathname, requestId: ctx.requestId });
384
- }
385
- for (const stage of finalizeStages) {
386
- const replaced = await stage.run(request, ctx);
387
- if (replaced !== undefined) ctx.response = replaced;
189
+ ctx.response = await recovered(request, ctx);
388
190
  }
191
+ // `finalize.ts`, not a loop here: these two stages run after the request is already answered
192
+ // or already failed, so a throw of their own has nothing left to catch it — and `handle`
193
+ // promises a Response.
194
+ await runFinalize(finalizeStages, request, ctx, recovered);
389
195
  return ctx.response ?? problem(pipelineNoResponse('response'));
390
196
  };
391
197
 
@@ -394,12 +200,43 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
394
200
  config,
395
201
  async handle(raw, init) {
396
202
  const url = new URL(raw.url);
203
+ // Before the context and before the span, in this order on purpose. `startSpan` resolves
204
+ // its parent from `currentSpanContext()`, which reads `ctx.traceId` — so a `traceparent`
205
+ // parsed by a stage arrived one frame too late, every time: the caller's trace was
206
+ // discarded and the root span kept an id the logs beside it never mentioned.
207
+ const correlation = readCorrelation(raw.headers, config);
208
+ const deadline = startDeadline({
209
+ headers: raw.headers,
210
+ config,
211
+ method: raw.method.toUpperCase(),
212
+ pathname: url.pathname,
213
+ });
214
+ const forwarded = {
215
+ headers: raw.headers,
216
+ config,
217
+ socketAddress: init.ip ?? null,
218
+ urlProtocol: url.protocol,
219
+ };
397
220
  const ctx = createRequestContext({
398
221
  url,
399
222
  method: raw.method,
400
223
  role: init.role,
401
224
  config,
402
- ip: init.ip ?? null,
225
+ requestId: correlation.requestId,
226
+ traceId: correlation.traceId,
227
+ parentSpanId: correlation.parentSpanId,
228
+ // Resolved here, not in `server.ts`: `pipeline.fetch()` is the supported way to test a
229
+ // route and it must take the identical path a socket does.
230
+ ip: clientAddress(forwarded),
231
+ https: clientUsedHttps(forwarded),
232
+ // The mesh's assertion about the caller's certificate, read at the SAME hop index as the
233
+ // address — an identity from an untrusted hop authenticates, which is worse than none.
234
+ peer: peerIdentity(forwarded),
235
+ signal: deadline.signal,
236
+ // The context is what app code reaches through core's ALS; without the inbound headers
237
+ // on it, a cookie the server itself set could never be read back on the next request,
238
+ // and `ctx.session` had no way to exist.
239
+ requestHeaders: raw.headers,
403
240
  });
404
241
  const request = new UltimateRequest(raw, ctx);
405
242
  // The ALS scope is entered here, before stage 1, so every stage — and everything
@@ -409,13 +246,13 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
409
246
  `${ctx.method} ${url.pathname}`,
410
247
  async (span) => {
411
248
  // This package's ONE metrics call site. `finally`, not the happy line: `execute`
412
- // absorbs app throws into a problem response, but a finalize stage can still throw on
413
- // its own (immutable headers on a `Response.redirect`), and a counter that skips the
414
- // requests the server handled worst is the one an autoscaler must not have. 500 is the
415
- // status such a request gets from the caller either way.
249
+ // answers every stage's throw with a problem response, but a counter that skipped the
250
+ // requests the server handled worst is the one an autoscaler must not have — so a
251
+ // rejection nothing here predicted still counts, at the 500 such a request gets from
252
+ // the caller either way.
416
253
  let status = 500;
417
254
  try {
418
- const response = await execute(request, ctx);
255
+ const response = await execute(request, ctx, deadline);
419
256
  status = response.status;
420
257
  // The root span of every request carried no attributes at all, so an exporter got a
421
258
  // name and a duration and nothing to correlate: which request, which outcome. These
@@ -439,9 +276,19 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
439
276
  status,
440
277
  durationMs: elapsedMs(ctx),
441
278
  });
279
+ // A live timer keeps the event loop from going idle, so a process that answered
280
+ // every request would still refuse to exit for the length of one timeout.
281
+ deadline.clear();
442
282
  }
443
283
  },
444
- { kind: 'server' },
284
+ {
285
+ kind: 'server',
286
+ // Explicit, and the whole point of parsing `traceparent` up here: without it
287
+ // `startSpan` falls back to `currentSpanContext()` — the context's own traceId, with
288
+ // an empty spanId — which produces a root span with no parent and a trace the caller
289
+ // never heard of.
290
+ ...(correlation.parent === undefined ? {} : { parent: correlation.parent }),
291
+ },
445
292
  ),
446
293
  );
447
294
  },
@@ -0,0 +1,86 @@
1
+ // Registration: a route that declares its own bucket puts it in the limiter's table. The bucket
2
+ // maths and the store stay in `rate-limit.ts`; this file is the one point where routes and config
3
+ // meet — `defineHttpConfig` runs before any route exists, so the table it builds cannot hold them,
4
+ // and a bucket name with nothing behind it falls through `bucketFor` to `default`.
5
+
6
+ import type { HttpConfig } from './config';
7
+ import { rateLimitBucketConflict, rateLimitBucketUnbound } from './errors';
8
+ import type { Bucket, RateLimiter } from './rate-limit';
9
+ import type { Route } from './router';
10
+
11
+ const same = (a: Bucket, b: Bucket): boolean =>
12
+ a.capacity === b.capacity && a.refillPerSecond === b.refillPerSecond;
13
+
14
+ /**
15
+ * The config with every route-declared bucket registered under the name that route selects.
16
+ *
17
+ * Precedence is refusal, never a winner: an equal restatement passes, and any disagreement — with
18
+ * a configured bucket, or with another route claiming the same name — is
19
+ * `X_RATE_LIMIT_BUCKET_CONFLICT` before the socket opens. Picking one would leave the other a
20
+ * number an author read, an OpenAPI document published and nothing enforced, which is the exact
21
+ * failure this seam closes. Same shape as `@ultimat3/auth`'s `AuthLimiter` policy check.
22
+ *
23
+ * Idempotent, so both construction paths (`createServer`, and `createPipeline` under it) can apply
24
+ * it: a second pass compares each bucket against the copy the first pass registered.
25
+ */
26
+ export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]): HttpConfig => {
27
+ const declared = new Map<string, { readonly bucket: Bucket; readonly route: string }>();
28
+ for (const route of routes) {
29
+ const bucket = route.meta.rateLimitBucket;
30
+ const name = route.meta.rateLimit;
31
+ // Numbers with no name to file them under enforce nothing; `toRoute` always sets both.
32
+ if (bucket === undefined || name === undefined) continue;
33
+ const prior = declared.get(name);
34
+ const configured = config.rateLimit.buckets[name];
35
+ if (prior !== undefined && !same(prior.bucket, bucket)) {
36
+ throw rateLimitBucketConflict({
37
+ bucket: name,
38
+ otherRoute: prior.route,
39
+ route: route.meta.name,
40
+ other: prior.bucket,
41
+ declared: bucket,
42
+ });
43
+ }
44
+ if (prior === undefined && configured !== undefined && !same(configured, bucket)) {
45
+ throw rateLimitBucketConflict({
46
+ bucket: name,
47
+ otherRoute: null,
48
+ route: route.meta.name,
49
+ other: configured,
50
+ declared: bucket,
51
+ });
52
+ }
53
+ declared.set(name, { bucket, route: route.meta.name });
54
+ }
55
+ if (declared.size === 0) return config;
56
+ const buckets: Record<string, Bucket> = { ...config.rateLimit.buckets };
57
+ for (const [name, entry] of declared) buckets[name] = entry.bucket;
58
+ return { ...config, rateLimit: { ...config.rateLimit, buckets } };
59
+ };
60
+
61
+ /**
62
+ * The other half of registration: the limiter actually installed must hold what the routes
63
+ * declared. `withRouteBuckets` puts the numbers in the CONFIG, which is enough only when the
64
+ * pipeline builds the limiter from that config. A limiter handed in through `PipelineDeps.limiter`
65
+ * closed over a table of its own, and a name it does not hold falls through `bucketFor` to
66
+ * `default` — silently, and looser than the declaration. So the two tables are compared once, at
67
+ * construction, exactly as `assertRateLimitScope` compares the two scopes.
68
+ *
69
+ * A limiter that declares no table at all is refused for the same reason a per-process store under
70
+ * a `'shared'` declaration is: what cannot be shown to hold is not assumed to hold.
71
+ */
72
+ export const assertRouteBuckets = (limiter: RateLimiter, routes: readonly Route[]): void => {
73
+ for (const route of routes) {
74
+ const bucket = route.meta.rateLimitBucket;
75
+ const name = route.meta.rateLimit;
76
+ if (bucket === undefined || name === undefined) continue;
77
+ const found = limiter.buckets?.[name];
78
+ if (found !== undefined && same(found, bucket)) continue;
79
+ throw rateLimitBucketUnbound({
80
+ bucket: name,
81
+ route: route.meta.name,
82
+ declared: bucket,
83
+ found: found ?? null,
84
+ });
85
+ }
86
+ };