@ultimat3/http 11.3.0 → 12.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.
@@ -14,7 +14,7 @@ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =
14
14
  new HttpError({
15
15
  code: 'X_RATE_LIMITED',
16
16
  cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`,
17
- fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts',
17
+ fix: 'retry after the Retry-After header, or raise the bucket in configureHttp({ rateLimit: { buckets } }) at module scope in a file under apps/*/',
18
18
  meta: { key, retryAfterSeconds },
19
19
  });
20
20
 
@@ -31,7 +31,7 @@ export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
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
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 set http.rateLimit.scope: 'process' in app.config.ts to accept per-replica limits",
34
+ fix: "createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
35
35
  });
36
36
 
37
37
  /**
@@ -56,7 +56,7 @@ const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucke
56
56
  */
57
57
  export const rateLimitBucketConflict = (input: {
58
58
  bucket: string;
59
- /** `null` when the other declaration is `app.config.ts` rather than a second route. */
59
+ /** `null` when the other declaration is the app's `configureHttp()` rather than a second route. */
60
60
  otherRoute: string | null;
61
61
  route: string;
62
62
  other: BucketNumbers;
@@ -66,11 +66,11 @@ export const rateLimitBucketConflict = (input: {
66
66
  code: 'X_RATE_LIMIT_BUCKET_CONFLICT',
67
67
  cause: `bucket "${input.bucket}" has two declarations: ${
68
68
  input.otherRoute === null
69
- ? 'http.rateLimit.buckets in app.config.ts'
69
+ ? 'the rateLimit.buckets the app passed to configureHttp()'
70
70
  : `route "${input.otherRoute}"`
71
71
  } says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${
72
72
  input.otherRoute === null
73
- ? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and app.config.ts is not where to say so`
73
+ ? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and configureHttp() is not where to say so`
74
74
  : ''
75
75
  }`,
76
76
  // One edit, named. Two joined by "or" leaves the reader to decide which declaration is
@@ -78,7 +78,7 @@ export const rateLimitBucketConflict = (input: {
78
78
  // OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale.
79
79
  fix:
80
80
  input.otherRoute === null
81
- ? `delete http.rateLimit.buckets.${input.bucket} from app.config.ts — the route's declaration is the one the OpenAPI operation publishes, so edit the numbers there if ${numbers(input.declared)} is wrong`
81
+ ? `delete rateLimit.buckets.${input.bucket} from the app's configureHttp() call — the route's declaration is the one the OpenAPI operation publishes, so edit the numbers there if ${numbers(input.declared)} is wrong`
82
82
  : `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`,
83
83
  });
84
84
 
@@ -125,7 +125,23 @@ export const rateLimitScopeUnset = (): HttpError =>
125
125
  code: 'X_RATE_LIMIT_SCOPE_UNSET',
126
126
  cause:
127
127
  '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: "in app.config.ts set http.rateLimit.scope: 'process' if this app runs as ONE replica, or 'shared' plus createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit",
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",
129
+ });
130
+
131
+ /**
132
+ * At `defineHttpConfig`, never on the request, and the same shape as every other bucket-name
133
+ * refusal here: `bucketFor` resolves an unknown name to `default`, so a tenant allowance an author
134
+ * wrote as 5,000 would silently be the 120-burst read bucket — looser than the declaration, and
135
+ * visible nowhere. A whole tenant's cap is not a value to discover by watching a graph.
136
+ */
137
+ export const tenantBucketUnknown = (name: string, declared: readonly string[]): HttpError =>
138
+ new HttpError({
139
+ code: 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
140
+ cause: `rateLimit.tenantBucket names "${name}" and rateLimit.buckets declares ${
141
+ declared.length === 0 ? 'no buckets' : declared.join(', ')
142
+ }`,
143
+ fix: `add ${name} to the same rateLimit.buckets — configureHttp({ rateLimit: { tenantBucket: '${name}', buckets: { ${name}: { capacity: 5000, refillPerSecond: 100 } } } }) — or drop tenantBucket to leave this app with no per-tenant allowance`,
144
+ meta: { bucket: name },
129
145
  });
130
146
 
131
147
  /**
package/src/rate-limit.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  rateLimitInvalid,
9
9
  rateLimitNotShared,
10
10
  rateLimitScopeUnset,
11
+ tenantBucketUnknown,
11
12
  } from './rate-limit-errors';
12
13
 
13
14
  /**
@@ -42,6 +43,19 @@ export interface RateLimitConfig {
42
43
  /** Named buckets; a route selects one via `meta.rateLimit`. `default` is required. */
43
44
  readonly buckets: Readonly<Record<string, Bucket>>;
44
45
  readonly defaultBucket: string;
46
+ /**
47
+ * The bucket a whole TENANT spends, beside — never instead of — the caller's own, or `null` for
48
+ * an app with no per-tenant allowance.
49
+ *
50
+ * `null` by default because no number is defensible without being told: one tenant is a person
51
+ * and the next is five thousand seats, so a framework-chosen allowance would throttle a real
52
+ * deployment on the day it installed the framework (axiom 8). It is still the one knob that
53
+ * answers the failure it exists for — `rateLimitKey` was `actor > org > ip`, EXCLUSIVE, so an
54
+ * authenticated request never touched an org bucket at all: a tenant with 8,000 seats whose
55
+ * integration entered a retry loop spent 8,000 per-actor bursts against one shared pool, every
56
+ * one of them under its own limit, and nothing an operator could set would have refused it.
57
+ */
58
+ readonly tenantBucket: string | null;
45
59
  /**
46
60
  * What this deployment requires of the store. `'shared'` says these numbers are the whole
47
61
  * fleet's allowance, and a per-process store then refuses to boot — because N replicas each
@@ -61,6 +75,7 @@ export interface RateLimitConfig {
61
75
  export const DEFAULT_RATE_LIMIT: Omit<RateLimitConfig, 'scope'> = {
62
76
  enabled: true,
63
77
  defaultBucket: 'default',
78
+ tenantBucket: null,
64
79
  buckets: {
65
80
  default: { capacity: 120, refillPerSecond: 2 },
66
81
  // Login/signup style endpoints: slow, no burst.
@@ -78,6 +93,12 @@ export const resolveRateLimitConfig = (
78
93
  input: Partial<RateLimitConfig> | undefined,
79
94
  ): RateLimitConfig => {
80
95
  const merged = { ...DEFAULT_RATE_LIMIT, ...input };
96
+ // Here and not at the first request, for `assertRateLimitScope`'s reason: an unknown name falls
97
+ // through `bucketFor` to `default`, so a tenant allowance somebody wrote as 5,000 would silently
98
+ // be the 120-burst read bucket — looser than what the author declared, and invisible.
99
+ if (merged.tenantBucket !== null && !Object.hasOwn(merged.buckets, merged.tenantBucket)) {
100
+ throw tenantBucketUnknown(merged.tenantBucket, Object.keys(merged.buckets));
101
+ }
81
102
  if (input?.scope !== undefined) return { ...merged, scope: input.scope };
82
103
  if (!merged.enabled) return { ...merged, scope: 'process' };
83
104
  throw rateLimitScopeUnset();
@@ -275,18 +296,53 @@ export interface RateLimitKeyParts {
275
296
  }
276
297
 
277
298
  /**
278
- * Key precedence: actor > org > ip. An authenticated actor gets its own bucket so
279
- * one noisy user cannot exhaust a whole tenant's allowance, and an anonymous
280
- * request falls back to the connection address.
299
+ * The namespace the tenant allowance is counted in. Deliberately NOT the route name the caller's
300
+ * own key carries: a per-route tenant bucket would give one org its whole allowance once per
301
+ * route, which is not a tenant cap at all — it is the same number multiplied by the route table.
281
302
  */
282
- export const rateLimitKey = (parts: RateLimitKeyParts): string => {
303
+ export const TENANT_SCOPE = 'tenant';
304
+
305
+ /** One key and the bucket it is spent from. A request spends a LIST of these, never one. */
306
+ export interface RateLimitSpend {
307
+ readonly key: string;
308
+ /** A name resolved against `config.rateLimit.buckets` by the limiter, never a `Bucket`. */
309
+ readonly bucket: string;
310
+ }
311
+
312
+ /**
313
+ * Every bucket one request spends, in the order it spends them.
314
+ *
315
+ * The CALLER's key first — actor > org > ip, so an authenticated actor gets its own bucket and an
316
+ * anonymous request falls back to the connection address — and then, when the app declared a
317
+ * tenant bucket and this caller has an org, that org's own key.
318
+ *
319
+ * The second entry is the finding this function was rewritten for. The precedence used to be
320
+ * EXCLUSIVE: `orgId` was consulted only when `actorId` was null, which `actorView` makes
321
+ * unreachable for every authenticated request, so no request ever touched an org bucket. A tenant
322
+ * with 8,000 seats therefore had 8,000 × the per-actor burst against one shared connection pool,
323
+ * with every individual bucket comfortably inside its limit.
324
+ *
325
+ * The caller's key is spent FIRST so a single hostile actor is refused by its own allowance before
326
+ * it can spend its tenant's — and, because the stage stops at the first refusal, a throttled
327
+ * caller costs the tenant nothing.
328
+ */
329
+ export const rateLimitSpends = (
330
+ parts: RateLimitKeyParts,
331
+ buckets: { readonly route: string; readonly tenant: string | null },
332
+ ): readonly RateLimitSpend[] => {
283
333
  const subject =
284
334
  parts.actorId !== null
285
335
  ? `actor:${parts.actorId}`
286
336
  : parts.orgId !== null
287
337
  ? `org:${parts.orgId}`
288
338
  : `ip:${parts.ip ?? 'unknown'}`;
289
- return `${parts.routeName}|${subject}`;
339
+ const spends: RateLimitSpend[] = [
340
+ { key: `${parts.routeName}|${subject}`, bucket: buckets.route },
341
+ ];
342
+ if (buckets.tenant !== null && parts.orgId !== null) {
343
+ spends.push({ key: `${TENANT_SCOPE}|org:${parts.orgId}`, bucket: buckets.tenant });
344
+ }
345
+ return spends;
290
346
  };
291
347
 
292
348
  export interface RateLimiter {
package/src/response.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Response constructors. Every response in the framework is built here so that
2
2
  // content types, charsets and cache semantics are decided once instead of per route.
3
3
  import { TIMEZONE_HEADER } from '@ultimat3/time';
4
- import { toProblem } from './error-map';
4
+ import { toProblem } from './error-facts';
5
5
 
6
6
  type HeaderSource = { readonly headers?: HeadersInit | undefined } | undefined;
7
7
 
package/src/stages.ts CHANGED
@@ -19,7 +19,7 @@ import { type HttpConfig, stripBasePath } from './config';
19
19
  import { actorView, elapsedMs, type RequestContext } from './context';
20
20
  import { corsHeaders, preflight } from './cors';
21
21
  import { checkCsrf, selfOrigin } from './csrf';
22
- import { factsOf, retryAfterOf } from './error-map';
22
+ import { factsOf, retryAfterOf } from './error-facts';
23
23
  import { errorPageResponse } from './error-page';
24
24
  import {
25
25
  bodyInvalid,
@@ -37,7 +37,7 @@ import { acceptsHtml } from './html-render';
37
37
  import { readCookie } from './locale';
38
38
  import { compose, type Middleware } from './middleware';
39
39
  import { overlayResponse } from './overlay';
40
- import { type RateLimiter, rateLimitKey } from './rate-limit';
40
+ import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
41
41
  import { rateLimited } from './rate-limit-errors';
42
42
  import type { UltimateRequest } from './request';
43
43
  import { addVary, applyCacheHeaders, problem, redirect, SHARED_CACHE_VARY } from './response';
@@ -211,23 +211,45 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
211
211
  'rate-limit': async (_request, ctx) => {
212
212
  if (!config.rateLimit.enabled) return undefined;
213
213
  const actor = actorView(ctx.actor);
214
- const key = rateLimitKey({
215
- actorId: actor?.id ?? null,
216
- orgId: actor?.orgId ?? null,
217
- ip: ctx.ip,
218
- routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
219
- });
220
- const decision = await limiter.check(
221
- key,
222
- ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
214
+ // A LIST, and the second entry is why: the key builder used to pick ONE subject —
215
+ // actor > org > ip, exclusive — so an authenticated request never touched a tenant bucket
216
+ // and one org's 8,000 seats each ran their own allowance against one shared pool.
217
+ const spends = rateLimitSpends(
218
+ {
219
+ actorId: actor?.id ?? null,
220
+ orgId: actor?.orgId ?? null,
221
+ ip: ctx.ip,
222
+ routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
223
+ },
224
+ {
225
+ route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
226
+ tenant: config.rateLimit.tenantBucket,
227
+ },
223
228
  );
229
+ let answer: RateLimitDecision | undefined;
230
+ let refusedKey: string | undefined;
231
+ for (const spend of spends) {
232
+ const decision = await limiter.check(spend.key, spend.bucket);
233
+ // The bucket closest to refusing is the one the caller has to plan against: reporting
234
+ // `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
235
+ // tells a client it may proceed and then refuses its next call.
236
+ if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
237
+ if (!decision.allowed) {
238
+ // The first refusal ends the spend, so a caller its own bucket already refused costs
239
+ // its tenant nothing — one noisy actor may not drain the allowance it shares.
240
+ answer = decision;
241
+ refusedKey = spend.key;
242
+ break;
243
+ }
244
+ }
245
+ if (answer === undefined) return undefined;
224
246
  // Recorded before the throw so the 429 can carry Retry-After and the
225
247
  // RateLimit-* headers rather than making the client guess.
226
- ctx.rateLimit = decision;
227
- for (const [name, value] of Object.entries(limiter.headers(decision))) {
248
+ ctx.rateLimit = answer;
249
+ for (const [name, value] of Object.entries(limiter.headers(answer))) {
228
250
  ctx.headers.set(name, value);
229
251
  }
230
- if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
252
+ if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
231
253
  return undefined;
232
254
  },
233
255
 
package/src/type-pins.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // test file and a claim written there can never fail. Nothing here emits or is imported — a
4
4
  // regression is a build error, the only enforcement that counts (axiom 3).
5
5
 
6
+ import type { HttpConfig, HttpConfigInput } from './config';
6
7
  import type { AuthzDecision } from './hooks';
7
8
 
8
9
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
@@ -46,3 +47,26 @@ export type _AuthzDenyNeedsAReason = Assert<
46
47
  export type _AuthzDenyCodeIsOptional = Assert<
47
48
  { allowed: false; reason: string } extends AuthzDecision ? true : false
48
49
  >;
50
+
51
+ /**
52
+ * Every key of the RESOLVED config is settable on the input, so nothing this package tunes is
53
+ * reachable only by editing this package.
54
+ *
55
+ * The whole HTTP tuning surface was unreachable from a shipped app until 12.0.0 — one fixed
56
+ * literal in `@ultimat3/cli` was its only construction — and the half of that defect a rule can
57
+ * see is this one: a key added to `HttpConfig` and forgotten on `HttpConfigInput` has a default
58
+ * nobody can override, silently, forever. `scripts/config-readers.ts` cannot see it either: that
59
+ * ratchet walks `AppConfig` and asks whether a key is READ, and this is the mirror question — can
60
+ * a key be WRITTEN. A build error naming the key beats both.
61
+ */
62
+ type UnsettableHttpKey = Exclude<keyof HttpConfig, keyof HttpConfigInput>;
63
+
64
+ export type _EveryHttpConfigKeyIsSettable = Assert<
65
+ [UnsettableHttpKey] extends [never] ? true : false
66
+ >;
67
+
68
+ // There is deliberately NO second pin claiming "every settable key is app-declarable or
69
+ // boot-owned". `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>`, so that union is
70
+ // `keyof HttpConfigInput` by construction and the assertion is vacuously true whatever anyone
71
+ // edits — a claim that cannot fail is not a claim. The derivation IS the enforcement there; this
72
+ // file only pins what a derivation cannot say.