@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
@@ -1,26 +1,13 @@
1
- // The shared rate-limit store: one Postgres table, one `insert … on conflict` per take, so N
2
- // replicas count against one bucket. Without it `config.rateLimit.scope: 'shared'` is a
3
- // declaration nothing can satisfy while `docker/helm/values.yaml` runs `roles.web.replicas: 3`.
1
+ // The shared rate-limit store: one Postgres table, one `insert … on conflict` per take and a plain
2
+ // `select` per peek, so N replicas count against one bucket. Without it `rateLimit.scope: 'shared'`
3
+ // is a declaration nothing can satisfy while `docker/helm/values.yaml` runs `replicas: 3`.
4
4
  // Statements are spelled out so an agent can run the exact one it saw in a log.
5
5
 
6
+ import type { PgExecutor } from '@ultimat3/core';
6
7
  import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
7
8
  import { rateLimitDecision } from './rate-limit';
8
9
  import { rateLimitStoreUnavailable } from './rate-limit-errors';
9
-
10
- /**
11
- * The one thing this store needs from the DB layer, declared structurally rather than imported —
12
- * `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/jobs` declare the same shape for
13
- * the same reason: neither package owns the other's connection. Here it is also the only option:
14
- * `@ultimat3/http` has no `@ultimat3/db` dependency at all, and taking one to type a single method
15
- * would put the whole database package in this package's install graph.
16
- *
17
- * **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template whose
18
- * positional form is `unsafe`. What satisfies it is a client that already speaks `(text, values)`,
19
- * wrapped in one line — `@ultimat3/db`'s `DbClient.query({ text, values })` is the framework's own.
20
- */
21
- export interface PgExecutor {
22
- query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
23
- }
10
+ import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
24
11
 
25
12
  /**
26
13
  * Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows,
@@ -93,6 +80,13 @@ returning tokens, spent
93
80
 
94
81
  export const SQL_RATE_LIMIT_RESET = 'delete from x_rate_limit where key = $1';
95
82
 
83
+ /**
84
+ * `peek`: a plain read, never the upsert — it runs on every request to a required route, and a
85
+ * write there was a row lock and a WAL record per signed-in page. The refill is computed in JS
86
+ * from the row by `refilledTokens`, the memory store's own arithmetic; no row means full.
87
+ */
88
+ export const SQL_RATE_LIMIT_PEEK = 'select tokens, last_ms from x_rate_limit where key = $1';
89
+
96
90
  /**
97
91
  * The memory store's forget rule, in SQL: a bucket back at capacity answers exactly as a missing
98
92
  * one, so dropping it changes no decision. A bucket that never refills
@@ -114,6 +108,12 @@ delete from x_rate_limit
114
108
  * refill_per_second >= capacity
115
109
  `;
116
110
 
111
+ interface PeekRow {
112
+ /** `double precision` / `bigint`, which some clients hand back as strings. */
113
+ readonly tokens: number | string;
114
+ readonly last_ms: number | string;
115
+ }
116
+
117
117
  interface TakeRow {
118
118
  /** `double precision`, which some clients hand back as a string. */
119
119
  readonly tokens: number | string;
@@ -135,7 +135,7 @@ export interface PostgresRateLimitStore extends RateLimitStore {
135
135
  *
136
136
  * `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a
137
137
  * task. There is no default, because the only defensible default would be this process' own
138
- * `Date.now()`, and a store that reads a clock nobody handed it is the thing `createRateLimiter`
138
+ * `Date.now()`, and a store that reads a clock nobody handed it is the thing `rateLimiter`
139
139
  * took a `Clock` to stop.
140
140
  */
141
141
  purgeExpired(nowMs: number): Promise<number>;
@@ -150,14 +150,14 @@ export interface PostgresRateLimitStore extends RateLimitStore {
150
150
  *
151
151
  * // apps/web/server.ts — what PROVIDES it
152
152
  * const client = db();
153
- * createServer({
153
+ * httpServer({
154
154
  * rateLimitStore: postgresRateLimitStore({
155
155
  * executor: { query: (text, values) => client.query({ text, values }) },
156
156
  * }),
157
157
  * });
158
158
  * ```
159
159
  *
160
- * `assertRateLimitScope` compares the two once, inside `createPipeline`, and a `'shared'`
160
+ * `assertRateLimitScope` compares the two once, inside `httpPipeline`, and a `'shared'`
161
161
  * declaration over any other store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens.
162
162
  */
163
163
  export function postgresRateLimitStore(
@@ -184,6 +184,15 @@ export function postgresRateLimitStore(
184
184
  return rateLimitDecision(bucket, Number(row.tokens), cost, isTrue(row.spent), nowMs);
185
185
  },
186
186
 
187
+ async peek(key, bucket, nowMs) {
188
+ const row = (await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]))[0];
189
+ const tokens =
190
+ row === undefined
191
+ ? bucket.capacity
192
+ : refilledTokens(bucket, Number(row.tokens), Number(row.last_ms), Math.floor(nowMs));
193
+ return rateLimitPeek(bucket, tokens);
194
+ },
195
+
187
196
  async reset(key): Promise<void> {
188
197
  await exec.query(SQL_RATE_LIMIT_RESET, [key]);
189
198
  },
@@ -0,0 +1,132 @@
1
+ // What the pipeline SPENDS, and when: the `rate-limit` stage's list of keys for a request that
2
+ // has a caller, and the one allowance the `auth` stage spends for a request that failed to have
3
+ // one. Split out of `stages.ts` at the size ceiling; the bucket maths stays in `rate-limit.ts`.
4
+
5
+ import { actorView, type RequestContext } from './context';
6
+ import {
7
+ type RateLimitConfig,
8
+ type RateLimitDecision,
9
+ type RateLimiter,
10
+ type RateLimitSpend,
11
+ rateLimitSpends,
12
+ } from './rate-limit';
13
+ import { rateLimited } from './rate-limit-errors';
14
+
15
+ /**
16
+ * The namespace failed credentials are counted in. Not route-scoped, for `TENANT_SCOPE`'s reason:
17
+ * a per-route key would hand one address its whole allowance again on every required route.
18
+ */
19
+ export const UNAUTHENTICATED_SCOPE = 'unauthenticated';
20
+
21
+ /**
22
+ * Spends each key in order and stops at the first refusal, so a caller its own bucket already
23
+ * refused costs its tenant nothing. Recorded on the context BEFORE the throw, so the 429 carries
24
+ * `Retry-After` and the `RateLimit-*` headers rather than making the client guess.
25
+ */
26
+ const spendAll = async (
27
+ ctx: RequestContext,
28
+ limiter: RateLimiter,
29
+ spends: readonly RateLimitSpend[],
30
+ ): Promise<void> => {
31
+ let answer: RateLimitDecision | undefined;
32
+ let refusedKey: string | undefined;
33
+ for (const spend of spends) {
34
+ const decision = await limiter.check(spend.key, spend.bucket);
35
+ // The bucket closest to refusing is the one the caller has to plan against: reporting
36
+ // `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that tells a
37
+ // client it may proceed and then refuses its next call.
38
+ if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
39
+ if (!decision.allowed) {
40
+ answer = decision;
41
+ refusedKey = spend.key;
42
+ break;
43
+ }
44
+ }
45
+ if (answer === undefined) return;
46
+ ctx.rateLimit = answer;
47
+ for (const [name, value] of Object.entries(limiter.headers(answer))) {
48
+ ctx.headers.set(name, value);
49
+ }
50
+ if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
51
+ };
52
+
53
+ /**
54
+ * The `rate-limit` stage. A LIST, and the second entry is why: the key builder used to pick ONE
55
+ * subject — actor > org > ip, exclusive — so an authenticated request never touched a tenant
56
+ * bucket and one org's 8,000 seats each ran their own allowance against one shared pool.
57
+ */
58
+ export const spendRequestBuckets = async (
59
+ ctx: RequestContext,
60
+ limiter: RateLimiter,
61
+ config: RateLimitConfig,
62
+ routeName: string,
63
+ ): Promise<void> => {
64
+ if (!config.enabled) return;
65
+ const actor = actorView(ctx.actor);
66
+ await spendAll(
67
+ ctx,
68
+ limiter,
69
+ rateLimitSpends(
70
+ { actorId: actor?.id ?? null, orgId: actor?.orgId ?? null, ip: ctx.ip, routeName },
71
+ { route: routeBucketOf(ctx, config), tenant: config.tenantBucket },
72
+ ),
73
+ );
74
+ };
75
+
76
+ /**
77
+ * The ONE place that decides which bucket a route's caller spends here. A route whose handler
78
+ * spends its own declared limit (`rateLimitedBy: 'handler'` — every action and query route, whose
79
+ * primitive counts that limit on every surface) spends none: `default` beside it would cap an
80
+ * action that declared 1,000 at 120, which a declared limit never was.
81
+ */
82
+ const routeBucketOf = (ctx: RequestContext, config: RateLimitConfig): string | null => {
83
+ const meta = ctx.route?.meta;
84
+ if (meta?.rateLimitedBy === 'handler') return null;
85
+ return meta?.rateLimit ?? config.defaultBucket;
86
+ };
87
+
88
+ /** The one key failed credentials are counted under: the address alone, never the route. */
89
+ const unauthenticatedKey = (ip: string | null): string =>
90
+ `${UNAUTHENTICATED_SCOPE}|ip:${ip ?? 'unknown'}`;
91
+
92
+ /**
93
+ * The `auth` stage's GATE, before `authenticate()` on a required route: an address whose failure
94
+ * allowance is spent is refused here, a valid credential included. Spending only on failure
95
+ * answered a wrong guess 429 and still served a right one, so it bounded nothing a guesser cares
96
+ * about — and every refused guess was still a credential-store read.
97
+ *
98
+ * READ ONLY: `RateLimitStore.peek`, never a take — this runs on every request to a required route,
99
+ * signed-in ones included, and a zero-cost take was a Postgres upsert on each. The refusal carries
100
+ * the peek's own `Retry-After` (`rateLimitPeek`). With no address there is no key
101
+ * worth refusing on — `ip:unknown` is everyone at once — so the gate stands aside and the failure
102
+ * path below still counts.
103
+ */
104
+ export const refuseExhaustedAddress = async (
105
+ ctx: RequestContext,
106
+ limiter: RateLimiter,
107
+ config: RateLimitConfig,
108
+ ): Promise<void> => {
109
+ if (!config.enabled || ctx.ip === null) return;
110
+ const key = unauthenticatedKey(ctx.ip);
111
+ const peek = await limiter.peek(key, config.defaultBucket);
112
+ if (peek.remaining >= 1) return;
113
+ throw rateLimited(key, peek.retryAfterSeconds);
114
+ };
115
+
116
+ /**
117
+ * The `auth` stage's failure path. A request refused 401 never reaches `rate-limit`, so nothing
118
+ * metered it: a caller with no credential, or a wrong one, could ask the credential store about
119
+ * tokens without bound. Spent only on FAILURE and keyed on the address ALONE — a signed-in
120
+ * caller behind the same NAT spends nothing here. Once it is spent, `refuseExhaustedAddress`
121
+ * refuses that address on required routes, signed-in callers included, until it refills.
122
+ */
123
+ export const spendUnauthenticated = (
124
+ ctx: RequestContext,
125
+ limiter: RateLimiter,
126
+ config: RateLimitConfig,
127
+ ): Promise<void> => {
128
+ if (!config.enabled) return Promise.resolve();
129
+ return spendAll(ctx, limiter, [
130
+ { key: unauthenticatedKey(ctx.ip), bucket: config.defaultBucket },
131
+ ]);
132
+ };
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in
2
2
  // dev/tests and against a shared tier in a multi-replica deployment — installed through
3
- // `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
3
+ // `httpServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
4
4
  // declaration; the bucket maths lives here so every driver agrees on the numbers.
5
5
  import { type Clock, systemClock } from '@ultimat3/core';
6
6
  import { httpCountInvalid } from './errors';
@@ -11,6 +11,8 @@ import {
11
11
  rateLimitScopeUnset,
12
12
  tenantBucketUnknown,
13
13
  } from './rate-limit-errors';
14
+ import { rateLimitHeaders } from './rate-limit-headers';
15
+ import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
14
16
 
15
17
  /**
16
18
  * Where a limiter's counters live. A store says which it provides; `RateLimitConfig` says which
@@ -36,6 +38,12 @@ export interface RateLimitStore {
36
38
  /** Declared, never inferred: a driver knows where its counters live; nothing else does. */
37
39
  readonly scope: RateLimitScope;
38
40
  take(key: string, bucket: Bucket, cost: number, nowMs: number): Promise<RateLimitDecision>;
41
+ /**
42
+ * What `key` holds NOW, refill applied — READ ONLY: no write, no row, no entry created. A key
43
+ * never taken is full. Required, because the `auth` stage asks it on every request to a required
44
+ * route, and a zero-cost `take` there was a Postgres upsert on every signed-in page.
45
+ */
46
+ peek(key: string, bucket: Bucket, nowMs: number): Promise<RateLimitPeek>;
39
47
  reset(key: string): Promise<void>;
40
48
  }
41
49
 
@@ -298,6 +306,14 @@ export const memoryRateLimitStore = (
298
306
  if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
299
307
  return Promise.resolve(decision);
300
308
  },
309
+ peek(key, bucket, nowMs) {
310
+ const state = buckets.get(key);
311
+ const tokens =
312
+ state === undefined
313
+ ? bucket.capacity
314
+ : refilledTokens(bucket, state.tokens, state.lastMs, nowMs);
315
+ return Promise.resolve(rateLimitPeek(bucket, tokens));
316
+ },
301
317
  reset(key) {
302
318
  buckets.delete(key);
303
319
  return Promise.resolve();
@@ -310,6 +326,13 @@ export interface RateLimitKeyParts {
310
326
  readonly orgId: string | null;
311
327
  readonly ip: string | null;
312
328
  readonly routeName: string;
329
+ /**
330
+ * The subject when there is no actor, no org AND no address. Defaults to `ip:unknown` — every
331
+ * such request shares it, which is what the pipeline has always done. A caller that can name a
332
+ * narrower population (a job run nobody attributed) passes its own, so it does not share one
333
+ * bucket with every anonymous request whose address could not be read.
334
+ */
335
+ readonly unattributed?: string | undefined;
313
336
  }
314
337
 
315
338
  /**
@@ -345,17 +368,20 @@ export interface RateLimitSpend {
345
368
  */
346
369
  export const rateLimitSpends = (
347
370
  parts: RateLimitKeyParts,
348
- buckets: { readonly route: string; readonly tenant: string | null },
371
+ // `route: null` — the route's own limit is spent by its handler (`RouteMeta.rateLimitedBy`), so
372
+ // the caller key is not spent here at all; only the tenant allowance is.
373
+ buckets: { readonly route: string | null; readonly tenant: string | null },
349
374
  ): readonly RateLimitSpend[] => {
350
375
  const subject =
351
376
  parts.actorId !== null
352
377
  ? `actor:${parts.actorId}`
353
378
  : parts.orgId !== null
354
379
  ? `org:${parts.orgId}`
355
- : `ip:${parts.ip ?? 'unknown'}`;
356
- const spends: RateLimitSpend[] = [
357
- { key: `${parts.routeName}|${subject}`, bucket: buckets.route },
358
- ];
380
+ : parts.ip !== null
381
+ ? `ip:${parts.ip}`
382
+ : (parts.unattributed ?? 'ip:unknown');
383
+ const spends: RateLimitSpend[] =
384
+ buckets.route === null ? [] : [{ key: `${parts.routeName}|${subject}`, bucket: buckets.route }];
359
385
  if (buckets.tenant !== null && parts.orgId !== null) {
360
386
  spends.push({ key: `${TENANT_SCOPE}|org:${parts.orgId}`, bucket: buckets.tenant });
361
387
  }
@@ -368,8 +394,8 @@ export interface RateLimiter {
368
394
  /**
369
395
  * The table this limiter resolves a bucket NAME against. Declared, never inferred — the same
370
396
  * rule as `RateLimitStore.scope` and `@ultimat3/auth`'s `AuthLimiter.policy`, and for the same
371
- * reason: `createRateLimiter` closes over its config, so nothing outside can see which buckets
372
- * it actually holds. `createPipeline` compares this against the buckets the ROUTES declare, and
397
+ * reason: `rateLimiter` closes over its config, so nothing outside can see which buckets
398
+ * it actually holds. `httpPipeline` compares this against the buckets the ROUTES declare, and
373
399
  * an unknown name is refused instead of falling through `bucketFor` to `default`.
374
400
  *
375
401
  * Optional only so an existing external implementation still type-checks; an absent table
@@ -377,12 +403,14 @@ export interface RateLimiter {
377
403
  */
378
404
  readonly buckets?: Readonly<Record<string, Bucket>> | undefined;
379
405
  check(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
406
+ /** The store's read-only `peek`, against the named bucket. Spends nothing, writes nothing. */
407
+ peek(key: string, bucketName: string): Promise<RateLimitPeek>;
380
408
  headers(decision: RateLimitDecision): Record<string, string>;
381
409
  /** Throws `X_RATE_LIMITED` when the bucket is empty. */
382
410
  assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
383
411
  }
384
412
 
385
- export const createRateLimiter = (options: {
413
+ export const rateLimiter = (options: {
386
414
  config: RateLimitConfig;
387
415
  store?: RateLimitStore;
388
416
  /**
@@ -390,7 +418,7 @@ export const createRateLimiter = (options: {
390
418
  * BOTH production call sites (`server.ts`, `pipeline.ts`) build their limiter without an
391
419
  * override — so the limiter that actually throttles requests could not be frozen by any test,
392
420
  * while `@ultimat3/auth`'s credential limiter has taken an injected `Clock` since it shipped.
393
- * Defaulted rather than required, the same shape as `createRequestContext`'s `init.clock`;
421
+ * Defaulted rather than required, the same shape as `requestContext`'s `init.clock`;
394
422
  * `PipelineDeps.limiter` stays the one seam for handing the pipeline a limiter of your own,
395
423
  * because a second `clock` beside it would be a second way to set one number.
396
424
  */
@@ -420,11 +448,8 @@ export const createRateLimiter = (options: {
420
448
  // no other way to learn what this limiter can enforce.
421
449
  buckets: options.config.buckets,
422
450
  check,
423
- headers: (decision) => ({
424
- 'ratelimit-limit': String(decision.limit),
425
- 'ratelimit-remaining': String(decision.remaining),
426
- 'ratelimit-reset': String(Math.ceil((decision.resetAtMs - now()) / 1000)),
427
- }),
451
+ peek: (key, bucketName) => store.peek(key, bucketFor(bucketName), now()),
452
+ headers: (decision) => rateLimitHeaders(decision, now()),
428
453
  async assert(key, bucketName, cost = 1) {
429
454
  const decision = await check(key, bucketName, cost);
430
455
  if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
@@ -0,0 +1,44 @@
1
+ // How one request arrived, as the frozen facts a token resolver is handed — never the raw
2
+ // `Headers`. A resolver given the headers would read `x-forwarded-for` itself and trust a hop the
3
+ // host never declared; the address here is the one the pipeline already resolved (`ctx.ip`).
4
+ // One shape for every bearer surface: this mount, and `@ultimat3/mcp`'s `POST /mcp`.
5
+
6
+ /**
7
+ * The request, as far as a credential decision may read it. Four facts and no more: a token bound
8
+ * to a network reads `address`, a resolver refusing browsers reads `origin`. Never the body (it is
9
+ * unread when the token is decided) and never another header — `Authorization` is the token itself.
10
+ */
11
+ export interface RequestFacts {
12
+ /**
13
+ * The caller's address AS THE HOST RESOLVED IT — `ctx.ip`, a declared proxy's `x-forwarded-for`
14
+ * honoured per `trustedProxyHops`, else the socket. `null` when the host resolved none: a surface
15
+ * that cannot tell a trusted hop from a forged one never derives one from a header.
16
+ */
17
+ readonly address: string | null;
18
+ /** `User-Agent`, verbatim — caller-controlled, so a hint for a rule and never an identity. */
19
+ readonly userAgent: string | null;
20
+ /**
21
+ * The `Origin` request header: which web origin a BROWSER says is calling. `null` when absent —
22
+ * every non-browser client — and the literal string `'null'` for an opaque origin, which is a
23
+ * different fact (a sandboxed frame or a `file:` page) and must not read as "no browser".
24
+ */
25
+ readonly origin: string | null;
26
+ /** The URL path the request was sent to. No query string. */
27
+ readonly path: string;
28
+ }
29
+
30
+ export interface RequestFactsInput {
31
+ readonly headers: Headers;
32
+ readonly url: URL | string;
33
+ /** What the host resolved — `ctx.ip`, or a transport's `seen.address`. Never a header read. */
34
+ readonly address: string | null | undefined;
35
+ }
36
+
37
+ export function requestFacts(input: RequestFactsInput): RequestFacts {
38
+ return Object.freeze({
39
+ address: input.address ?? null,
40
+ userAgent: input.headers.get('user-agent'),
41
+ origin: input.headers.get('origin'),
42
+ path: (typeof input.url === 'string' ? new URL(input.url) : input.url).pathname,
43
+ });
44
+ }
package/src/request.ts CHANGED
@@ -3,12 +3,11 @@
3
3
  // context so they cannot drift from what the pipeline resolved.
4
4
 
5
5
  import type { Actor } from '@ultimat3/core';
6
- import { readWithinLimit, renderThrowable } from '@ultimat3/core';
6
+ import { readCookie, readWithinLimit, renderThrowable } from '@ultimat3/core';
7
7
  import type { RequestContext } from './context';
8
8
  import { bodyInvalid, buildSkew } from './errors';
9
- import { readCookie } from './locale';
10
9
  import type { Schema } from './validate';
11
- import { validate, validateSync } from './validate';
10
+ import { validateBody, validateSync } from './validate';
12
11
 
13
12
  export type QueryValues = Readonly<Record<string, string | readonly string[]>>;
14
13
 
@@ -153,7 +152,7 @@ export class UltimateRequest {
153
152
  }
154
153
 
155
154
  async body<Out>(schema: Schema<Out>): Promise<Out> {
156
- const outcome = await validate(schema, await this.bodyRaw());
155
+ const outcome = await validateBody(schema, await this.bodyRaw());
157
156
  if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
158
157
  return outcome.value;
159
158
  }
@@ -203,7 +202,15 @@ export class UltimateRequest {
203
202
  // An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
204
203
  // `content-length: 0`, and reading that as `undefined` failed every schema with 400.
205
204
  const form = type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data';
206
- if (type === '') return undefined;
205
+ // No declared type is only "no body" when there are no bytes. `{"a":1}` sent bare used to
206
+ // read as `undefined`, so a schema whose fields are all optional validated a request nobody
207
+ // parsed. `bodyBytes()` stays the door for a body that is bytes by design (a signed upload).
208
+ if (type === '') {
209
+ if (declared === 0 || (await this.bodyBytes()).byteLength === 0) return undefined;
210
+ throw bodyInvalid(this.pathname, [
211
+ 'the request carries a body and no content-type header saying what it is',
212
+ ]);
213
+ }
207
214
  // A multipart body is only a FORM with the boundary its header must announce. Checked before
208
215
  // the empty-form shortcut below, so a zero-length body cannot launder a malformed header into
209
216
  // `{}` — without it the parser would have refused, and an empty body must refuse the same.
package/src/response.ts CHANGED
@@ -14,13 +14,13 @@ const withDefaults = (init: HeaderSource, defaults: Record<string, string>): Hea
14
14
  return headers;
15
15
  };
16
16
 
17
- export const json = <T>(body: T, init?: ResponseInit): Response =>
17
+ export const jsonResponse = <T>(body: T, init?: ResponseInit): Response =>
18
18
  new Response(JSON.stringify(body), {
19
19
  ...init,
20
20
  headers: withDefaults(init, { 'content-type': 'application/json; charset=utf-8' }),
21
21
  });
22
22
 
23
- export const text = (body: string, init?: ResponseInit): Response =>
23
+ export const textResponse = (body: string, init?: ResponseInit): Response =>
24
24
  new Response(body, {
25
25
  ...init,
26
26
  headers: withDefaults(init, { 'content-type': 'text/plain; charset=utf-8' }),
@@ -216,6 +216,14 @@ export const applyCacheHeaders = (response: Response, hint: CacheHint): Response
216
216
  response.headers.set('surrogate-key', hint.tags.join(' '));
217
217
  response.headers.set('cache-tag', hint.tags.join(','));
218
218
  }
219
+ // A response REWRITTEN to private or no-store may already carry keys: the `cache-headers` stage
220
+ // turns an `isr` document's shared header into `private` for a signed-in visitor, and the keys
221
+ // the render wrote beside it would stay on what is now a per-user document — purge keys an
222
+ // intermediary has no business storing it under.
223
+ if (!shared) {
224
+ response.headers.delete('surrogate-key');
225
+ response.headers.delete('cache-tag');
226
+ }
219
227
  // `cookie` is not optional on the shared path. A `public` response is stored by a CDN under the
220
228
  // URL, and every session in this framework travels in a cookie — so without it the first
221
229
  // signed-in render of a public page is what every later visitor is served. `SHARED_CACHE_VARY`
@@ -31,7 +31,7 @@ const DELTA_SECONDS_FIELDS = [
31
31
  * CLI's authorized-object hint all carry `maxAgeSeconds: 0`, so a floor of 1 would refuse the
32
32
  * framework at its own boot.
33
33
  *
34
- * Called once per route by `createRouter`, which is the one way a `Route` becomes matchable — so
34
+ * Called once per route by `httpRouter`, which is the one way a `Route` becomes matchable — so
35
35
  * every hint an app can serve has been through here, including the ones `@ultimat3/cli` mints for
36
36
  * favicons, dev assets and storage objects.
37
37
  */
package/src/router.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // PRECEDENCE (deterministic, tested in router.test.ts):
6
6
  // 1. static segment `/posts/new`
7
7
  // 2. param segment `/posts/:id`
8
- // 3. wildcard segment `/posts/*rest` (matches ONE OR MORE remaining segments)
8
+ // 3. wildcard segment `/posts/*rest` (matches ZERO or more remaining segments)
9
9
  // Depth-first with backtracking: `/posts/new` beats `/posts/:id` even though the
10
10
  // param branch would also match, and a dead end in the static branch still falls
11
11
  // back to the param branch. Two routes that would tie are a build error
@@ -76,6 +76,21 @@ export interface RouteMeta {
76
76
  * refuses a configured bucket of the same name that says something else.
77
77
  */
78
78
  readonly rateLimitBucket?: Bucket;
79
+ /**
80
+ * `'handler'`: the handler spends this route's own declared limit — an action's `invoke`, a
81
+ * query's read — because the primitive counts it on every surface (HTTP, MCP, the agent tool, a
82
+ * job). The `rate-limit` stage then spends no caller bucket for it, only the tenant allowance:
83
+ * spending `default` too would cap a primitive that declared more than `default` at `default`.
84
+ * Set, it wins: `rateLimit`/`rateLimitBucket` name a bucket the STAGE spends, and are not read.
85
+ */
86
+ readonly rateLimitedBy?: 'handler';
87
+ /**
88
+ * The primitive this route projects, written by `@ultimat3/action`'s `toRoute` and
89
+ * `@ultimat3/query`'s `toQueryRoute`; absent on a plain route. Read by the bucket check
90
+ * (`rate-limit-buckets.ts`): since 25.0.0 a `rateLimit.buckets.<name>` named after a primitive
91
+ * limits nothing and is refused, and only an explicit marker can say which routes those are.
92
+ */
93
+ readonly primitive?: 'action' | 'query';
79
94
  readonly tags?: readonly string[];
80
95
  readonly description?: string;
81
96
  /**
@@ -159,7 +174,7 @@ const segmentsOf = (path: string): readonly string[] =>
159
174
  .split('/')
160
175
  .filter((segment) => segment.length > 0);
161
176
 
162
- export const createRouter = (routes: readonly Route[]): RouteTable => {
177
+ export const httpRouter = (routes: readonly Route[]): RouteTable => {
163
178
  const root = node();
164
179
  for (const route of routes) {
165
180
  // Before the trie is touched: a hint that cannot be emitted is refused where it was WRITTEN,
@@ -241,19 +256,32 @@ const candidates = (
241
256
  ): void => {
242
257
  if (index === segments.length) {
243
258
  if (current.routes.size > 0) search.out.push({ node: current, params });
259
+ // A catch-all with an EMPTY rest: `/docs` for `/docs/*path`, after any static route at
260
+ // `/docs` itself. It is the path `@ultimat3/render`'s static build writes for that page
261
+ // (`fillPath` fills an absent rest with `''`), and its `compilePattern` matches it — a server
262
+ // that 404ed it served a page the export and the ISR lookup both said existed.
263
+ const rest = current.wildcard;
264
+ if (rest !== undefined && rest.node.routes.size > 0) {
265
+ search.out.push({ node: rest.node, params: { ...params, [rest.name]: '' } });
266
+ }
244
267
  return;
245
268
  }
246
269
  const segment = segments[index];
247
270
  if (segment === undefined) return;
271
+ // Decoded ONCE, for every branch. `undefined` is a malformed escape, which fails only the
272
+ // branches that needed the decoded form: the raw static lookup below still runs.
273
+ const decoded = decodeSegment(segment);
248
274
 
249
- // Static segments are compared raw, never decoded, so a malformed escape only ever fails the
250
- // branch that would have decoded it: a path that reaches no param or wildcard is the 404 it
251
- // always was, and a static route still wins the precedence it always won.
252
- const staticChild = current.statics.get(segment);
275
+ // Raw first, then the decoded spelling: a route file is named in Unicode (`precios-españa`) and
276
+ // `URL.pathname` encodes it (`precios-espa%C3%B1a`), so a raw-only lookup 404ed every non-ASCII
277
+ // literal. A static route still wins the precedence it always won.
278
+ const staticChild =
279
+ current.statics.get(segment) ??
280
+ (decoded !== undefined && decoded !== segment ? current.statics.get(decoded) : undefined);
253
281
  if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params, search);
254
282
 
255
283
  if (current.param !== undefined) {
256
- const value = decodeSegment(segment);
284
+ const value = decoded;
257
285
  if (value === undefined) search.undecodable ??= segment;
258
286
  else {
259
287
  const next = { ...params, [current.param.name]: value };
@@ -330,7 +358,7 @@ const paramsOf = (path: string): readonly string[] =>
330
358
  .filter((segment) => segment.startsWith(':') || segment.startsWith('*'))
331
359
  .map((segment) => segment.slice(1));
332
360
 
333
- /** Feeds the `/_x` dashboard, the manifest emitter and `x routes list --json`. */
361
+ /** Feeds the `/_x` dashboard, the manifest emitter and `x routes --json`. */
334
362
  export const describeRoutes = (table: RouteTable): readonly RouteDescription[] =>
335
363
  table.routes
336
364
  .map((route) => ({