@ultimat3/http 24.0.0 → 25.1.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/errors.ts CHANGED
@@ -1,46 +1,18 @@
1
1
  // The HTTP layer's stable error codes. Every throw in this package goes through a
2
2
  // factory here so a code, a cause and an exact fix always travel together — the
3
3
  // terminal, the dev overlay and `--json` all render the same three strings.
4
- import {
5
- deniedCallerFix,
6
- registerErrorCodes,
7
- registerErrorRetry,
8
- renderFixShellArg,
9
- renderThrowable,
10
- UltimateError,
11
- } from '@ultimat3/core';
12
-
13
- /** Codes this package declares and owns. */
14
- export const HTTP_OWNED_ERROR_CODES = [
15
- 'X_ROUTE_NOT_FOUND',
16
- 'X_METHOD_NOT_ALLOWED',
17
- 'X_PATH_INVALID',
18
- 'X_BODY_INVALID',
19
- 'X_RATE_LIMITED',
20
- 'X_BUILD_SKEW',
21
- 'X_ROUTE_CONFLICT',
22
- 'X_SERVER_NOT_STARTED',
23
- 'X_PIPELINE_NO_RESPONSE',
24
- 'X_PIPELINE_FINALIZE_FAILED',
25
- 'X_NO_REQUEST',
26
- 'X_ERROR_STATUS_INVALID',
27
- 'X_PROBLEM_META_INVALID',
28
- 'X_CORS_CONFIG_INVALID',
29
- 'X_CSP_DIRECTIVE_INVALID',
30
- 'X_RATE_LIMIT_NOT_SHARED',
31
- 'X_RATE_LIMIT_BUCKET_CONFLICT',
32
- 'X_RATE_LIMIT_BUCKET_UNBOUND',
33
- 'X_RATE_LIMIT_SCOPE_UNSET',
34
- 'X_RATE_LIMIT_INVALID',
35
- 'X_RATE_LIMIT_STORE_UNAVAILABLE',
36
- 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
37
- 'X_TRUST_PROXY_UNSET',
38
- 'X_OVERLOADED',
39
- 'X_CSRF_BLOCKED',
40
- 'X_WEBHOOK_SIGNATURE_INVALID',
41
- 'X_WEBHOOK_SIGNATURE_STALE',
42
- 'X_BEARER_MOUNT_INVALID',
43
- ] as const;
4
+ import { deniedCallerFix, renderFixShellArg, renderThrowable, UltimateError } from '@ultimat3/core';
5
+ // The owned code list, its titles and its retry classes are `error-titles.ts` — the one module
6
+ // `sideEffects` lists, imported bare here and from the barrel — so this module is side-effect-free
7
+ // and a chunk keeps only the classes it constructs. Re-exported: every name still reads from here.
8
+ import './error-titles';
9
+ import { HTTP_OWNED_ERROR_CODES } from './error-titles';
10
+
11
+ export {
12
+ HTTP_ERROR_TITLES,
13
+ HTTP_OWNED_ERROR_CODES,
14
+ type HttpOwnedErrorCode,
15
+ } from './error-titles';
44
16
 
45
17
  /**
46
18
  * Codes this package throws but does NOT own. `X_FORBIDDEN` belongs to `@ultimat3/policy` and
@@ -71,60 +43,8 @@ export const HTTP_BORROWED_ERROR_CODES = [
71
43
  /** Every code http can throw: the ones it owns plus the two it borrows. */
72
44
  export const HTTP_ERROR_CODES = [...HTTP_OWNED_ERROR_CODES, ...HTTP_BORROWED_ERROR_CODES] as const;
73
45
 
74
- export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
75
46
  export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
76
47
 
77
- /** Human title per owned code. Kept next to the codes so one edit updates every surface. */
78
- export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
79
- X_ROUTE_NOT_FOUND: 'no route matches this request',
80
- X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
81
- X_PATH_INVALID: 'a path segment is not valid percent-encoding',
82
- X_BODY_INVALID: 'request body failed its schema',
83
- X_RATE_LIMITED: 'rate limit exhausted for this key',
84
- X_BUILD_SKEW: 'client build id does not match the server build id',
85
- X_ROUTE_CONFLICT: 'two routes claim the same path',
86
- X_SERVER_NOT_STARTED: 'server handle used before start()',
87
- X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
88
- X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
89
- X_NO_REQUEST: 'the inbound request is not in scope here',
90
- X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
91
- X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
92
- X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
93
- X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
94
- X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
95
- X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
96
- X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
97
- X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
98
- X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
99
- X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
100
- X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
101
- X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
102
- X_OVERLOADED: 'in-flight requests are at the configured ceiling',
103
- X_CSRF_BLOCKED: 'an unsafe request that did not prove same-origin',
104
- X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
105
- X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
106
- X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
107
- };
108
-
109
- // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
110
- // everywhere. Without this the registry humanises the code (`X_BUILD_SKEW` → "build skew"); with a
111
- // presence guard, a package that claimed one of these first would silently keep its own title.
112
- registerErrorCodes(
113
- Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
114
- );
115
-
116
- /**
117
- * The two codes this package throws that a client is SUPPOSED to come back from, and both say
118
- * when: each carries `retry-after` on the response. Unclassified defaults to `terminal`, which is
119
- * right for the rest — a 404, a 422 and a wiring bug all fail the same way forever — but wrong for
120
- * a shed request, whose whole contract is "not now". Only codes this package OWNS are listed:
121
- * `X_TIMEOUT` and `X_DRAINING` are borrowed, and core classifies its own.
122
- */
123
- registerErrorRetry({
124
- X_RATE_LIMITED: 'retry-after',
125
- X_OVERLOADED: 'retry-after',
126
- });
127
-
128
48
  // No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
129
49
  // `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
130
50
  // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
@@ -274,7 +194,7 @@ export const serverNotStarted = (member: string): HttpError =>
274
194
  new HttpError({
275
195
  code: 'X_SERVER_NOT_STARTED',
276
196
  cause: `${member} was read before start() bound a socket`,
277
- fix: 'call createServer({ ... }).start() before reading url()',
197
+ fix: 'call httpServer({ ... }).start() before reading url()',
278
198
  });
279
199
 
280
200
  export const pipelineNoResponse = (stage: string): HttpError =>
@@ -297,7 +217,7 @@ export const finalizeFailed = (stage: string, cause: unknown): HttpError =>
297
217
  // runs a `Proxy`'s `getPrototypeOf` trap and `.message` runs a getter, so both reads go
298
218
  // through core's total `renderThrowable` — the fast path was the last unguarded one here.
299
219
  cause: `the "${stage}" stage threw while finishing the response: ${renderThrowable(cause)}`,
300
- fix: 'return a Response built here — json(), text(), html() or redirect() from @ultimat3/http; one whose headers cannot be set, like Response.redirect(), cannot take the final headers',
220
+ fix: 'return a Response built here — jsonResponse(), textResponse(), html() or redirect() from @ultimat3/http; one whose headers cannot be set, like Response.redirect(), cannot take the final headers',
301
221
  });
302
222
 
303
223
  /**
@@ -379,6 +299,36 @@ export const trustProxyUnset = (): HttpError =>
379
299
  fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
380
300
  });
381
301
 
302
+ /**
303
+ * `http.drainTimeoutMs`, deleted in 25.0.0. It was a second knob for the budget `drain.deadlineMs`
304
+ * owns: applied after `lifecycleForRole` on the web role only, so the web role and every other
305
+ * role could drain on different budgets from one app config. Refused by key PRESENCE — a JS caller
306
+ * or a spread still handing it over gets the edit, never a silently ignored number.
307
+ */
308
+ export const drainTimeoutDeleted = (): HttpError =>
309
+ new HttpError({
310
+ code: 'X_CONFIG_INVALID',
311
+ cause:
312
+ 'http.drainTimeoutMs was deleted in 25.0.0: drain.deadlineMs is the one drain budget, for every role, and a second key for the web role alone is two answers to how long SIGTERM waits',
313
+ fix: 'drain: { deadlineMs: 25_000 } # in app.config.ts: drain.deadlineMs is the one budget every role, web included, drains on — and delete drainTimeoutMs from configureHttp({ … })',
314
+ meta: { option: 'drainTimeoutMs', replacement: 'drain.deadlineMs' },
315
+ });
316
+
317
+ /**
318
+ * `http.buildIdHeader`, deleted in 25.0.0. The pipeline read the client's build claim from it and
319
+ * stamped its own back under it, while the typed client, the service worker and `DEFAULT_CORS`
320
+ * all spelled core's `BUILD_ID_HEADER` — so a renamed header was a server listening for a header
321
+ * no client of it sent, and skew detection off in silence. Refused by key PRESENCE.
322
+ */
323
+ export const buildIdHeaderDeleted = (): HttpError =>
324
+ new HttpError({
325
+ code: 'X_CONFIG_INVALID',
326
+ cause:
327
+ 'http.buildIdHeader was deleted in 25.0.0: the build-id header is core BUILD_ID_HEADER (x-ultimate-build), which every client sends, and a renamed one is a header no client of this server sends',
328
+ fix: "import { BUILD_ID_HEADER } from '@ultimat3/core' # the one build-id header, not configurable — and delete buildIdHeader from configureHttp({ … })",
329
+ meta: { option: 'buildIdHeader', replacement: 'BUILD_ID_HEADER' },
330
+ });
331
+
382
332
  /**
383
333
  * A numeric knob that is not a count, refused where `app.config.ts` still names it.
384
334
  *
@@ -497,3 +447,17 @@ export const webhookSignatureStale = (
497
447
  fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that',
498
448
  meta: { skewMs, toleranceMs },
499
449
  });
450
+
451
+ /**
452
+ * The receiver holds no secret. Not a weak key — NO key: an HMAC under '' is a mac anyone can
453
+ * compute, so verifying against it accepts every forged delivery. `X_CONFIG_INVALID` because the
454
+ * repair is the route's configuration and nothing a sender does can fix it. `shape` is `typeof`
455
+ * or "an empty string" — never the value, which is a credential when it is one.
456
+ */
457
+ export const webhookSecretMissing = (shape: string): HttpError =>
458
+ new HttpError({
459
+ code: 'X_CONFIG_INVALID',
460
+ cause: `verifyWebhookSignature was given ${shape} as its secret, and a mac under an empty key is one anyone can compute — every forged delivery would verify`,
461
+ fix: "verifyWebhookSignature(request, { secret: process.env.WEBHOOK_SECRET ?? '' }) in the api/ route receiving it, with WEBHOOK_SECRET set in the deployment environment to the signing secret the sender shows for this endpoint — an unset variable is then refused here rather than verified against",
462
+ meta: { option: 'webhook secret' },
463
+ });
package/src/forwarded.ts CHANGED
@@ -43,10 +43,25 @@ export const forwardedElement = (
43
43
  return list[index];
44
44
  };
45
45
 
46
- /** The plain-list form: `x-forwarded-for`, `x-forwarded-proto`. */
46
+ /** The plain-list form: `x-forwarded-for`. */
47
47
  export const forwardedValue = (header: string | null, hops: number): string | undefined =>
48
48
  forwardedElement(header, hops);
49
49
 
50
+ /**
51
+ * `x-forwarded-proto`, which proxies OVERWRITE far more often than they append (nginx's `$scheme`,
52
+ * an ALB, Traefik): behind two hops it holds one value while `x-forwarded-for` holds two. A full
53
+ * chain is read exactly like an address. A SHORTER one is not a forgery here: an overwrite erased
54
+ * everything client-ward of it, so every entry left was written by a trusted hop, and the
55
+ * outermost of them — index 0 — is the one that saw the client's leg. A client value survives only
56
+ * when no trusted hop writes the header at all, the same exposure `hops: 1` has always had.
57
+ */
58
+ export const forwardedProto = (header: string | null, hops: number): string | undefined => {
59
+ if (header === null || hops < 1) return undefined;
60
+ const list = plainSplit(header, ',');
61
+ if (list.length === 0) return undefined;
62
+ return list[Math.max(0, list.length - hops)];
63
+ };
64
+
50
65
  /**
51
66
  * `1.2.3.4:5678` and `[::1]:443` are both legal in an `x-forwarded-for` entry; the port is the
52
67
  * proxy's bookkeeping and would make every connection its own rate-limit bucket.
@@ -91,12 +106,12 @@ export const clientAddress = (input: ForwardedInput): string | null =>
91
106
  forwardedClientAddress(input.headers, input.config) ?? input.socketAddress;
92
107
 
93
108
  /**
94
- * Whether the CLIENT's leg of the connection was TLS. Read at the same hop index as the address,
95
- * because `x-forwarded-proto` is as forgeable as `x-forwarded-for` — and this one decides whether
96
- * a two-year `includeSubDomains` HSTS policy goes out.
109
+ * Whether the CLIENT's leg of the connection was TLS. Hop-indexed like the address, because
110
+ * `x-forwarded-proto` is as forgeable as `x-forwarded-for` — and this one decides whether a
111
+ * two-year `includeSubDomains` HSTS policy goes out. `forwardedProto` says why a short list counts.
97
112
  */
98
113
  export const clientUsedHttps = (input: ForwardedInput): boolean => {
99
- const forwarded = forwardedValue(
114
+ const forwarded = forwardedProto(
100
115
  input.headers.get(FORWARDED_PROTO),
101
116
  input.config.trustedProxyHops,
102
117
  );
@@ -12,9 +12,6 @@ import type { HttpConfig } from './config';
12
12
  import { HttpError } from './errors';
13
13
  import { forwardedClientAddress } from './forwarded';
14
14
 
15
- /** Core's, named here for the config that defaults to it — never a second copy of the list. */
16
- export { DEFAULT_HEALTH_DETAIL_PEERS } from '@ultimat3/core';
17
-
18
15
  /** Every class an entry may name. A `Record` so a class core adds is a build error here. */
19
16
  const ADDRESS_CLASSES = Object.freeze<Record<AddressClass, true>>({
20
17
  loopback: true,
package/src/hooks.ts CHANGED
@@ -40,12 +40,13 @@ export interface ServerHooks {
40
40
  /**
41
41
  * The app's OWN error page for a status, served byte for byte, or `undefined` to render the
42
42
  * framework's. A seam and not a config value because the answer lives on a disk this package
43
- * cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html`, per request, so a file
44
- * dropped into a running server takes effect without a restart — the rule `/favicon.ico`
45
- * already follows for the same class of file.
43
+ * cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html` — per request under
44
+ * `x dev`, so a file dropped into a running server takes effect without a restart (the rule
45
+ * `/favicon.ico` already follows), and once per status in a container.
46
46
  *
47
- * Consulted only on the production HTML path: a dev process answers a browser with the overlay,
48
- * and an agent gets the problem document in both.
47
+ * Asked on the HTML path only — an agent gets the problem document in both environments. In
48
+ * production for every status; in dev for a 4xx alone (the page a visitor will see), with the
49
+ * overlay when it answers `undefined`. A dev 5xx is always the overlay and never asks.
49
50
  */
50
51
  readonly errorPage?: (
51
52
  status: number,
package/src/index.ts CHANGED
@@ -1,19 +1,16 @@
1
1
  // The public surface of @ultimat3/http. Explicit, never `export *`: what is not
2
2
  // listed here is an implementation detail and may change without a major bump.
3
3
 
4
+ // Imported bare, and the one module `sideEffects` lists: the codes' titles and the `retry-after`
5
+ // class of a 429 and a 503 are read by `UltimateError`, which never imports this module — so a
6
+ // server realm that reaches the barrel registers them whatever it constructs. `SIDE_EFFECTS_ANCHORS`
7
+ // carries the argument and `bun run side-effects` enforces it.
8
+ import './error-titles';
9
+
4
10
  export type { PgExecutor, RenderMode } from '@ultimat3/core';
5
- // The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
6
- // the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
7
- // one, and neither may import the other. Re-exported here so a receiver route needs one import.
8
- export {
9
- escapeHtml,
10
- isCanonicalWebhookField,
11
- readCookie,
12
- WEBHOOK_ID_HEADER,
13
- WEBHOOK_SIGNATURE_HEADER,
14
- WEBHOOK_SIGNATURE_VERSION,
15
- WEBHOOK_TOPIC_HEADER,
16
- } from '@ultimat3/core';
11
+ // No core VALUE is re-exported here (`X_HELPER_COPY`, `bun run flight-copies`): the webhook wire
12
+ // format (`WEBHOOK_*`, `isCanonicalWebhookField`), `escapeHtml` and `readCookie` are imported from
13
+ // `@ultimat3/core`, their one home, by every caller.
17
14
  export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
18
15
  export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
19
16
  export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
@@ -30,8 +27,8 @@ export type { ActorView, RequestContext, RequestContextInit } from './context';
30
27
  export {
31
28
  actorView,
32
29
  asCtx,
33
- createRequestContext,
34
30
  elapsedMs,
31
+ requestContext,
35
32
  useRequestBodyBytes,
36
33
  useRequestContext,
37
34
  useRequestCookie,
@@ -44,7 +41,7 @@ export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } fro
44
41
  export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
45
42
  export { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
46
43
  export type { Deadline } from './deadline';
47
- export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
44
+ export { resolveTimeoutMs, startDeadline } from './deadline';
48
45
  export type { ErrorFacts, ProblemDocument } from './error-facts';
49
46
  export {
50
47
  factsOf,
@@ -125,7 +122,7 @@ export { OVERLAY_STYLE } from './overlay-style';
125
122
  export type { PeerIdentity } from './peer-identity';
126
123
  export { peerIdentity } from './peer-identity';
127
124
  export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
128
- export { createPipeline, PIPELINE_STAGES } from './pipeline';
125
+ export { httpPipeline, PIPELINE_STAGES } from './pipeline';
129
126
  export type { ProblemMeta, ProblemMetaDeclaration, ProblemMetaValue } from './problem-meta';
130
127
  export { MAX_PROBLEM_META_BYTES, registerProblemMeta } from './problem-meta';
131
128
  export type {
@@ -142,11 +139,11 @@ export type {
142
139
  } from './rate-limit';
143
140
  export {
144
141
  assertRateLimitScope,
145
- createRateLimiter,
146
142
  DEFAULT_MAX_RATE_LIMIT_KEYS,
147
143
  DEFAULT_RATE_LIMIT,
148
144
  memoryRateLimitStore,
149
145
  rateLimitDecision,
146
+ rateLimiter,
150
147
  rateLimitSpends,
151
148
  resolveRateLimitConfig,
152
149
  toBucket,
@@ -162,6 +159,15 @@ export {
162
159
  rateLimitStoreUnavailable,
163
160
  tenantBucketUnknown,
164
161
  } from './rate-limit-errors';
162
+ export { publishRateLimit, rateLimitHeaders } from './rate-limit-headers';
163
+ export type { AdoptRateLimitStoreOptions } from './rate-limit-installed';
164
+ export {
165
+ adoptRateLimitStore,
166
+ assertInstalledRateLimitScope,
167
+ installedRateLimitStore,
168
+ installRateLimitStore,
169
+ resetRateLimitStore,
170
+ } from './rate-limit-installed';
165
171
  export type { RateLimitPeek } from './rate-limit-peek';
166
172
  export { rateLimitPeek, refilledTokens } from './rate-limit-peek';
167
173
  export type { PostgresRateLimitStore, PostgresRateLimitStoreOptions } from './rate-limit-postgres';
@@ -172,18 +178,20 @@ export {
172
178
  export { setRedirect, takeRedirect } from './redirect';
173
179
  export type { QueryValues } from './request';
174
180
  export { UltimateRequest } from './request';
181
+ export type { RequestFacts, RequestFactsInput } from './request-facts';
182
+ export { requestFacts } from './request-facts';
175
183
  export type { CacheHint, RedirectIntent, RedirectStatus } from './response';
176
184
  export {
177
185
  applyCacheHeaders,
178
186
  cacheControl,
179
187
  html,
180
- json,
188
+ jsonResponse,
181
189
  NO_STORE,
182
190
  noContent,
183
191
  problem,
184
192
  redirect,
185
193
  stream,
186
- text,
194
+ textResponse,
187
195
  withHeaders,
188
196
  } from './response';
189
197
  export type {
@@ -198,9 +206,9 @@ export type {
198
206
  RouteTable,
199
207
  } from './router';
200
208
  export {
201
- createRouter,
202
209
  describeRoutes,
203
210
  HTTP_METHODS,
211
+ httpRouter,
204
212
  matchRoute,
205
213
  } from './router';
206
214
  export type { SecurityConfig } from './security-headers';
@@ -212,12 +220,14 @@ export type {
212
220
  UpgradeTarget,
213
221
  WebSocketMount,
214
222
  } from './server';
215
- export { createServer } from './server';
223
+ export { httpServer } from './server';
224
+ export type { DeleteCookieOptions } from './set-cookie';
225
+ export { deleteCookie, setCookie } from './set-cookie';
216
226
  // The stage vocabulary comes from its declaration site, beside the fourteen implementations it
217
227
  // names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
218
228
  export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
219
229
  export type { InferOutput, Schema, ValidationOutcome } from './validate';
220
- export { formatIssue, validate, validateSync } from './validate';
230
+ export { formatIssue, validateBody, validateSync } from './validate';
221
231
  export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
222
232
  export {
223
233
  DEFAULT_WEBHOOK_BODY_LIMIT,
package/src/navigation.ts CHANGED
@@ -39,7 +39,8 @@ const WEB_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
39
39
  * whenever a redirect target is caller-influenced. Such a target — and one that will not parse —
40
40
  * answers the REQUESTED url instead, as a path: a document load of it re-runs the route, and the
41
41
  * browser's own redirect handling refuses what this refused. Total, never a throw: it runs in the
42
- * `response` stage, after the handler.
42
+ * `response` stage, after the handler. A same-origin pathname is never emitted starting `//`
43
+ * (`hostlessPath`).
43
44
  */
44
45
  export function locationFor(target: string, base: URL): string {
45
46
  const requested = `${base.pathname}${base.search}${base.hash}`;
@@ -47,7 +48,18 @@ export function locationFor(target: string, base: URL): string {
47
48
  const resolved = new URL(target, base);
48
49
  if (!WEB_PROTOCOLS.has(resolved.protocol)) return requested;
49
50
  if (resolved.origin !== base.origin) return target;
50
- return `${resolved.pathname}${resolved.search}${resolved.hash}`;
51
+ return `${hostlessPath(resolved.pathname)}${resolved.search}${resolved.hash}`;
52
+ }
53
+
54
+ /**
55
+ * A pathname that starts `//` — `/.//evil.test`, `/..//evil.test`, a request for `//evil.test` —
56
+ * is a path on THIS origin only while the URL keeps its host. Emitted alone it is a
57
+ * scheme-relative reference, and the router resolves it to another host and assigns that to
58
+ * `window.location`. `/.` in front is how WHATWG URL serialisation keeps a host-less `//` path a
59
+ * path; it resolves back to the same pathname, so nothing legitimate changes.
60
+ */
61
+ function hostlessPath(pathname: string): string {
62
+ return pathname.startsWith('//') ? `/.${pathname}` : pathname;
51
63
  }
52
64
 
53
65
  /** "Load this with a real navigation" — never stored anywhere, it answers one request. */
package/src/pipeline.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // `finalize.ts` owns the promise that the tail always answers rather than rejecting.
6
6
  import { beginWork, recordRequest, runWithContext, withSpan } from '@ultimat3/core';
7
7
  import { defineHttpConfig, type HttpConfig } from './config';
8
- import { asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
8
+ import { asCtx, elapsedMs, type RequestContext, requestContext } from './context';
9
9
  import { readCorrelation } from './correlation';
10
10
  import { type Deadline, startDeadline } from './deadline';
11
11
  import { pipelineNoResponse } from './errors';
@@ -14,8 +14,9 @@ import { clientAddress, clientUsedHttps } from './forwarded';
14
14
  import type { ServerHooks } from './hooks';
15
15
  import type { Middleware } from './middleware';
16
16
  import { peerIdentity } from './peer-identity';
17
- import { assertRateLimitScope, createRateLimiter, type RateLimiter } from './rate-limit';
17
+ import { assertRateLimitScope, type RateLimiter, rateLimiter } from './rate-limit';
18
18
  import { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
19
+ import { assertInstalledRateLimitScope } from './rate-limit-installed';
19
20
  import { UltimateRequest } from './request';
20
21
  import { problem } from './response';
21
22
  import type { RouteTable } from './router';
@@ -109,7 +110,7 @@ export interface PipelineDeps {
109
110
  readonly hooks?: ServerHooks;
110
111
  readonly middleware?: readonly Middleware[];
111
112
  /**
112
- * A limiter built elsewhere — `createServer({ rateLimitStore })` is the one supported way, and
113
+ * A limiter built elsewhere — `httpServer({ rateLimitStore })` is the one supported way, and
113
114
  * it hands over a limiter built from the SAME merged config this constructor would have built.
114
115
  * One passed from anywhere else resolves bucket names against the table IT closed over, which
115
116
  * is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
@@ -135,16 +136,19 @@ export interface Pipeline {
135
136
  handle(request: Request, init: HandleInit): Promise<Response>;
136
137
  }
137
138
 
138
- export const createPipeline = (deps: PipelineDeps): Pipeline => {
139
+ export const httpPipeline = (deps: PipelineDeps): Pipeline => {
139
140
  // Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
140
141
  // bucket a route declares only becomes real at the construction that holds both. Idempotent, so
141
- // a config `createServer` already merged passes through unchanged.
142
+ // a config `httpServer` already merged passes through unchanged.
142
143
  const config = withRouteBuckets(deps.config ?? defineHttpConfig(), deps.table.routes);
143
- const limiter = deps.limiter ?? createRateLimiter({ config: config.rateLimit });
144
- // Here rather than in `createServer`: this is the one construction path every server, test and
144
+ const limiter = deps.limiter ?? rateLimiter({ config: config.rateLimit });
145
+ // Here rather than in `httpServer`: this is the one construction path every server, test and
145
146
  // embedder shares, so a limiter that cannot keep the app's declaration is refused exactly once.
146
147
  // Two halves of one question — where the counters live, and which buckets the limiter holds.
147
148
  assertRateLimitScope(config.rateLimit, limiter);
149
+ // The third half: where an action's or a query's declared `rateLimit:` is counted. Under a
150
+ // `'shared'` declaration that store is held to the same promise as this pipeline's own.
151
+ assertInstalledRateLimitScope(config.rateLimit);
148
152
  assertRouteBuckets(limiter, deps.table.routes);
149
153
  const run = stageRunners({
150
154
  table: deps.table,
@@ -243,7 +247,7 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
243
247
  socketAddress: init.ip ?? null,
244
248
  urlProtocol: url.protocol,
245
249
  };
246
- const ctx = createRequestContext({
250
+ const ctx = requestContext({
247
251
  url,
248
252
  method: raw.method,
249
253
  role: init.role,
@@ -5,7 +5,11 @@
5
5
 
6
6
  import type { HttpConfig } from './config';
7
7
  import type { Bucket, RateLimiter } from './rate-limit';
8
- import { rateLimitBucketConflict, rateLimitBucketUnbound } from './rate-limit-errors';
8
+ import {
9
+ rateLimitBucketConflict,
10
+ rateLimitBucketForPrimitive,
11
+ rateLimitBucketUnbound,
12
+ } from './rate-limit-errors';
9
13
  import type { Route } from './router';
10
14
 
11
15
  const same = (a: Bucket, b: Bucket): boolean =>
@@ -20,10 +24,30 @@ const same = (a: Bucket, b: Bucket): boolean =>
20
24
  * number an author read, an OpenAPI document published and nothing enforced, which is the exact
21
25
  * failure this seam closes. Same shape as `@ultimat3/auth`'s `AuthLimiter` policy check.
22
26
  *
23
- * Idempotent, so both construction paths (`createServer`, and `createPipeline` under it) can apply
27
+ * Idempotent, so both construction paths (`httpServer`, and `httpPipeline` under it) can apply
24
28
  * it: a second pass compares each bucket against the copy the first pass registered.
25
29
  */
30
+ /**
31
+ * A configured bucket keyed by a mounted primitive's NAME, which since 25.0.0 limits nothing.
32
+ * `default`, the tenant bucket and any name a route really selects are live, so they pass.
33
+ */
34
+ const assertNoPrimitiveBuckets = (config: HttpConfig, routes: readonly Route[]): void => {
35
+ const selected = new Set<string>(['default']);
36
+ if (config.rateLimit.tenantBucket !== null) selected.add(config.rateLimit.tenantBucket);
37
+ for (const route of routes) {
38
+ if (route.meta.rateLimit !== undefined) selected.add(route.meta.rateLimit);
39
+ }
40
+ for (const route of routes) {
41
+ const primitive = route.meta.primitive;
42
+ const name = route.meta.name;
43
+ if (primitive === undefined || selected.has(name)) continue;
44
+ if (!Object.hasOwn(config.rateLimit.buckets, name)) continue;
45
+ throw rateLimitBucketForPrimitive({ bucket: name, primitive });
46
+ }
47
+ };
48
+
26
49
  export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]): HttpConfig => {
50
+ assertNoPrimitiveBuckets(config, routes);
27
51
  const declared = new Map<string, { readonly bucket: Bucket; readonly route: string }>();
28
52
  for (const route of routes) {
29
53
  const bucket = route.meta.rateLimitBucket;
@@ -1,7 +1,7 @@
1
1
  // Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration
2
2
  // faults the boot refuses. Split from `errors.ts` at the 500-line ceiling, on the seam it already
3
- // had. The codes and their TITLES stay there, which is the one registry — `registerErrorCodes`
4
- // must see them all in a single call.
3
+ // had. The codes, their TITLES and the `retry-after` class are `error-titles.ts`'s, the one
4
+ // registry — `registerErrorCodes` must see them all in a single call.
5
5
  import { HttpError } from './errors';
6
6
 
7
7
  /**
@@ -19,19 +19,24 @@ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =
19
19
  });
20
20
 
21
21
  /**
22
- * At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means
22
+ * At `httpServer`/`httpPipeline`, never on the request. `replicas: 3` behind one config means
23
23
  * each process holds its own counters, so every configured number is enforced three times over —
24
24
  * a green `x verify` and a limit that is not the limit. The declaration is the app's because the
25
25
  * framework cannot see its replica count, and a framework that guessed would guess wrong.
26
26
  */
27
- export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
27
+ export const rateLimitNotShared = (found: 'process' | 'disabled' | 'installed'): HttpError =>
28
28
  new HttpError({
29
29
  code: 'X_RATE_LIMIT_NOT_SHARED',
30
30
  cause:
31
31
  found === 'disabled'
32
32
  ? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
33
- : "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
34
- fix: "createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
33
+ : found === 'installed'
34
+ ? "http.rateLimit.scope is 'shared' but the store actions and queries spend their declared rateLimit from keeps its counters in this process, so each replica would enforce every declared limit on its own"
35
+ : "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
36
+ fix:
37
+ found === 'installed'
38
+ ? 'installRateLimitStore(rateLimitStore) at boot, with the same postgresRateLimitStore({ executor }) passed to httpServer({ routes, rateLimitStore })'
39
+ : "httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
35
40
  });
36
41
 
37
42
  /**
@@ -46,7 +51,28 @@ interface BucketNumbers {
46
51
  const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
47
52
 
48
53
  /**
49
- * Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that
54
+ * `rateLimit.buckets.<name>` where `<name>` is a mounted action's or query's own name. Since
55
+ * 25.0.0 a primitive's route carries no `meta.rateLimit` — the primitive spends its own declared
56
+ * limit on every surface — so that bucket limits nothing. Refused rather than kept: a number an
57
+ * operator reads in `configureHttp()` and nothing enforces is the failure every 12a deletion turns
58
+ * into a code. `X_CONFIG_INVALID`, borrowed, because it is a config key the framework cannot honour.
59
+ */
60
+ export const rateLimitBucketForPrimitive = (input: {
61
+ bucket: string;
62
+ primitive: 'action' | 'query';
63
+ }): HttpError =>
64
+ new HttpError({
65
+ code: 'X_CONFIG_INVALID',
66
+ cause: `rateLimit.buckets.${input.bucket} is named after the mounted ${input.primitive} "${input.bucket}", and since 25.0.0 an ${input.primitive}'s route spends no named bucket — the ${input.primitive} spends its own declared rateLimit on every surface — so this bucket would limit nothing`,
67
+ fix:
68
+ input.primitive === 'query'
69
+ ? `query({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits a query since 25.0.0 — delete it from configureHttp() and declare the limit on the query`
70
+ : `action({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits an action since 25.0.0 — delete it from configureHttp() and declare the limit on the action`,
71
+ meta: { option: `rateLimit.buckets.${input.bucket}`, primitive: input.primitive },
72
+ });
73
+
74
+ /**
75
+ * Two declarations of one bucket, at `httpServer`/`httpPipeline`. Neither wins: an app that
50
76
  * configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
51
77
  * disagree about what is enforced, and whichever a merge picked would leave the other a number
52
78
  * someone read and nothing applies — the failure this seam exists to end. The message speaks
@@ -84,7 +110,7 @@ export const rateLimitBucketConflict = (input: {
84
110
 
85
111
  /**
86
112
  * A route declares its own bucket and the INSTALLED limiter cannot enforce it — at
87
- * `createPipeline`, never on the request. `createRateLimiter` closes over the config it was built
113
+ * `httpPipeline`, never on the request. `rateLimiter` closes over the config it was built
88
114
  * with, so a limiter constructed before the routes existed resolves the route's bucket name
89
115
  * through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
90
116
  * requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
@@ -109,7 +135,7 @@ export const rateLimitBucketUnbound = (input: {
109
135
  ? 'does not hold that bucket, so the route would run on the default one'
110
136
  : `holds ${numbers(input.found)} for it`
111
137
  }`,
112
- fix: 'pass the STORE and let the pipeline build the limiter — createServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
138
+ fix: 'pass the STORE and let the pipeline build the limiter — httpServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
113
139
  });
114
140
 
115
141
  /**
@@ -125,7 +151,7 @@ export const rateLimitScopeUnset = (): HttpError =>
125
151
  code: 'X_RATE_LIMIT_SCOPE_UNSET',
126
152
  cause:
127
153
  'http.rateLimit is enabled and the deployment has not declared http.rateLimit.scope, so the numbers below it are per replica rather than per fleet',
128
- fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
154
+ fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
129
155
  });
130
156
 
131
157
  /**