@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.
- package/CLAUDE.md +75 -31
- package/README.md +89 -26
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +62 -32
- package/src/context.ts +11 -11
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +10 -8
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +62 -0
- package/src/error-map-tier-1.ts +73 -0
- package/src/error-map-tier-2.ts +120 -0
- package/src/error-map-tier-3.ts +100 -0
- package/src/error-map-tier-4.ts +116 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +3 -4
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +46 -96
- package/src/forwarded.ts +36 -10
- package/src/health-disclosure.ts +66 -0
- package/src/hooks.ts +6 -5
- package/src/html-render.ts +3 -17
- package/src/index.ts +36 -27
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +26 -1
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +32 -13
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +30 -21
- package/src/rate-limit-stage.ts +132 -0
- package/src/rate-limit.ts +40 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +12 -5
- package/src/response.ts +10 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +36 -8
- package/src/server.ts +101 -30
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +50 -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
|
|
2
|
-
// replicas count against one bucket. Without it `
|
|
3
|
-
// declaration nothing can satisfy while `docker/helm/values.yaml` runs `
|
|
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 `
|
|
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
|
-
*
|
|
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 `
|
|
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
|
-
// `
|
|
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
|
-
|
|
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
|
-
:
|
|
356
|
-
|
|
357
|
-
|
|
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: `
|
|
372
|
-
* it actually holds. `
|
|
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
|
|
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 `
|
|
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
|
-
|
|
424
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
package/src/route-cache.ts
CHANGED
|
@@ -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 `
|
|
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
|
|
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
|
|
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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
const staticChild =
|
|
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 =
|
|
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
|
|
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) => ({
|