@ultimat3/http 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rate-limit.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  rateLimitScopeUnset,
12
12
  tenantBucketUnknown,
13
13
  } from './rate-limit-errors';
14
+ import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
14
15
 
15
16
  /**
16
17
  * Where a limiter's counters live. A store says which it provides; `RateLimitConfig` says which
@@ -36,6 +37,12 @@ export interface RateLimitStore {
36
37
  /** Declared, never inferred: a driver knows where its counters live; nothing else does. */
37
38
  readonly scope: RateLimitScope;
38
39
  take(key: string, bucket: Bucket, cost: number, nowMs: number): Promise<RateLimitDecision>;
40
+ /**
41
+ * What `key` holds NOW, refill applied — READ ONLY: no write, no row, no entry created. A key
42
+ * never taken is full. Required, because the `auth` stage asks it on every request to a required
43
+ * route, and a zero-cost `take` there was a Postgres upsert on every signed-in page.
44
+ */
45
+ peek(key: string, bucket: Bucket, nowMs: number): Promise<RateLimitPeek>;
39
46
  reset(key: string): Promise<void>;
40
47
  }
41
48
 
@@ -298,6 +305,14 @@ export const memoryRateLimitStore = (
298
305
  if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
299
306
  return Promise.resolve(decision);
300
307
  },
308
+ peek(key, bucket, nowMs) {
309
+ const state = buckets.get(key);
310
+ const tokens =
311
+ state === undefined
312
+ ? bucket.capacity
313
+ : refilledTokens(bucket, state.tokens, state.lastMs, nowMs);
314
+ return Promise.resolve(rateLimitPeek(bucket, tokens));
315
+ },
301
316
  reset(key) {
302
317
  buckets.delete(key);
303
318
  return Promise.resolve();
@@ -377,6 +392,8 @@ export interface RateLimiter {
377
392
  */
378
393
  readonly buckets?: Readonly<Record<string, Bucket>> | undefined;
379
394
  check(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
395
+ /** The store's read-only `peek`, against the named bucket. Spends nothing, writes nothing. */
396
+ peek(key: string, bucketName: string): Promise<RateLimitPeek>;
380
397
  headers(decision: RateLimitDecision): Record<string, string>;
381
398
  /** Throws `X_RATE_LIMITED` when the bucket is empty. */
382
399
  assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
@@ -420,6 +437,7 @@ export const createRateLimiter = (options: {
420
437
  // no other way to learn what this limiter can enforce.
421
438
  buckets: options.config.buckets,
422
439
  check,
440
+ peek: (key, bucketName) => store.peek(key, bucketFor(bucketName), now()),
423
441
  headers: (decision) => ({
424
442
  'ratelimit-limit': String(decision.limit),
425
443
  'ratelimit-remaining': String(decision.remaining),
package/src/request.ts CHANGED
@@ -3,10 +3,9 @@
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
10
  import { validate, validateSync } from './validate';
12
11
 
@@ -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
@@ -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/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
@@ -241,19 +241,32 @@ const candidates = (
241
241
  ): void => {
242
242
  if (index === segments.length) {
243
243
  if (current.routes.size > 0) search.out.push({ node: current, params });
244
+ // A catch-all with an EMPTY rest: `/docs` for `/docs/*path`, after any static route at
245
+ // `/docs` itself. It is the path `@ultimat3/render`'s static build writes for that page
246
+ // (`fillPath` fills an absent rest with `''`), and its `compilePattern` matches it — a server
247
+ // that 404ed it served a page the export and the ISR lookup both said existed.
248
+ const rest = current.wildcard;
249
+ if (rest !== undefined && rest.node.routes.size > 0) {
250
+ search.out.push({ node: rest.node, params: { ...params, [rest.name]: '' } });
251
+ }
244
252
  return;
245
253
  }
246
254
  const segment = segments[index];
247
255
  if (segment === undefined) return;
256
+ // Decoded ONCE, for every branch. `undefined` is a malformed escape, which fails only the
257
+ // branches that needed the decoded form: the raw static lookup below still runs.
258
+ const decoded = decodeSegment(segment);
248
259
 
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);
260
+ // Raw first, then the decoded spelling: a route file is named in Unicode (`precios-españa`) and
261
+ // `URL.pathname` encodes it (`precios-espa%C3%B1a`), so a raw-only lookup 404ed every non-ASCII
262
+ // literal. A static route still wins the precedence it always won.
263
+ const staticChild =
264
+ current.statics.get(segment) ??
265
+ (decoded !== undefined && decoded !== segment ? current.statics.get(decoded) : undefined);
253
266
  if (staticChild !== undefined) candidates(staticChild, segments, index + 1, params, search);
254
267
 
255
268
  if (current.param !== undefined) {
256
- const value = decodeSegment(segment);
269
+ const value = decoded;
257
270
  if (value === undefined) search.undecodable ??= segment;
258
271
  else {
259
272
  const next = { ...params, [current.param.name]: value };
@@ -330,7 +343,7 @@ const paramsOf = (path: string): readonly string[] =>
330
343
  .filter((segment) => segment.startsWith(':') || segment.startsWith('*'))
331
344
  .map((segment) => segment.slice(1));
332
345
 
333
- /** Feeds the `/_x` dashboard, the manifest emitter and `x routes list --json`. */
346
+ /** Feeds the `/_x` dashboard, the manifest emitter and `x routes --json`. */
334
347
  export const describeRoutes = (table: RouteTable): readonly RouteDescription[] =>
335
348
  table.routes
336
349
  .map((route) => ({
package/src/server.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  beginWork,
8
8
  configureLifecycle,
9
9
  drain,
10
+ healthBody,
10
11
  healthzPayload,
11
12
  lifecycleState,
12
13
  logger,
@@ -18,6 +19,7 @@ import {
18
19
  import type { Server } from 'bun';
19
20
  import { defineHttpConfig, type HttpConfig } from './config';
20
21
  import { HttpError, serverNotStarted } from './errors';
22
+ import { disclosesHealthDetail } from './health-disclosure';
21
23
  import type { ServerHooks } from './hooks';
22
24
  import type { Middleware } from './middleware';
23
25
  import { createPipeline, type Pipeline } from './pipeline';
@@ -39,7 +41,7 @@ const websocketPathTaken = (path: string, answered: string): HttpError =>
39
41
  new HttpError({
40
42
  code: 'X_ROUTE_CONFLICT',
41
43
  cause: `the websocket mount claims ${path}, and ${answered} already answers it — Bun matches its native route table before \`fetch\`, so the upgrade would never reach the mount`,
42
- fix: `x routes list --json # then move the mount's path, or the declaration at ${path}`,
44
+ fix: `x routes --json # then move the mount's path, or the declaration at ${path}`,
43
45
  });
44
46
 
45
47
  /** Core owns the state machine; this alias exists so callers need one import. */
@@ -98,6 +100,8 @@ export interface ServerOptions {
98
100
  */
99
101
  export interface UpgradeTarget {
100
102
  upgrade(request: Request, options: { data: unknown }): boolean;
103
+ /** The peer's socket address — a mount that answers its own health paths decides detail by it. */
104
+ requestIP(request: Request): { readonly address: string } | null;
101
105
  }
102
106
 
103
107
  /**
@@ -193,10 +197,21 @@ export const createServer = (options: ServerOptions): ServerHandle => {
193
197
  * Core owns the health state, the in-flight count and the drain deadline so every
194
198
  * role reports identically. `HealthPayload` is `{ ok, status, body }` — core stays
195
199
  * HTTP-free and hands us the status code as data, which we render here.
200
+ *
201
+ * The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a
202
+ * peer `healthDetailPeers` lists: these two paths answer outside the pipeline, unauthenticated.
196
203
  */
197
- const healthResponse = (payload: HealthPayload): Response =>
204
+ const healthResponse = (payload: HealthPayload, request: Request, socket: BunServer): Response =>
198
205
  json(
199
- { ...payload.body, role },
206
+ healthBody(
207
+ payload.body,
208
+ role,
209
+ disclosesHealthDetail({
210
+ config,
211
+ headers: request.headers,
212
+ socketAddress: socket.requestIP(request)?.address ?? null,
213
+ }),
214
+ ),
200
215
  { status: payload.status, headers: { 'cache-control': 'no-store' } },
201
216
  );
202
217
 
@@ -232,7 +247,10 @@ export const createServer = (options: ServerOptions): ServerHandle => {
232
247
  const out: Record<string, NativeHandler> = {};
233
248
  for (const description of describeRoutes(table)) {
234
249
  if (description.params.length > 0) continue;
235
- out[`${prefix}${description.path}`] = dispatch;
250
+ // `encodeURI`: `Bun.serve` refuses a non-ASCII key with a bare `TypeError`, so one route named
251
+ // `/precios-españa` kept the whole server from starting. Encoded as `URL.pathname` spells
252
+ // it; any other spelling falls through to `fetch`, where the router decodes it.
253
+ out[encodeURI(`${prefix}${description.path}`)] = dispatch;
236
254
  }
237
255
  return out;
238
256
  };
@@ -289,11 +307,14 @@ export const createServer = (options: ServerOptions): ServerHandle => {
289
307
  ...nativeRoutes(),
290
308
  // Health endpoints answer outside the pipeline on purpose: a draining or
291
309
  // rate-limited process must still be able to say what it is doing.
292
- '/healthz': () => healthResponse(healthzPayload()),
310
+ '/healthz': (request: Request, socket: BunServer) =>
311
+ healthResponse(healthzPayload(), request, socket),
293
312
  // `?deep=1` always answers on the dependencies, whatever `health.readiness` says.
294
- '/readyz': (request: Request) =>
313
+ '/readyz': (request: Request, socket: BunServer) =>
295
314
  healthResponse(
296
315
  readyzPayload({ deep: new URL(request.url).searchParams.get('deep') === '1' }),
316
+ request,
317
+ socket,
297
318
  ),
298
319
  },
299
320
  };
package/src/stages.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  import { signInRedirect } from './auth-redirect';
16
16
  import { finalizeCacheHeaders } from './cache-stage';
17
17
  import { type HttpConfig, stripBasePath } from './config';
18
- import { actorView, elapsedMs, type RequestContext } from './context';
18
+ import { elapsedMs, type RequestContext } from './context';
19
19
  import { corsHeaders, preflight } from './cors';
20
20
  import { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
21
21
  import { factsOf, retryAfterOf } from './error-facts';
@@ -38,8 +38,12 @@ import { compose, type Middleware } from './middleware';
38
38
  import { navigationGate, redirectForRouter } from './navigation';
39
39
  import { overlayResponse } from './overlay';
40
40
  import { resolvePreferences } from './preferences';
41
- import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
42
- import { rateLimited } from './rate-limit-errors';
41
+ import type { RateLimiter } from './rate-limit';
42
+ import {
43
+ refuseExhaustedAddress,
44
+ spendRequestBuckets,
45
+ spendUnauthenticated,
46
+ } from './rate-limit-stage';
43
47
  import type { UltimateRequest } from './request';
44
48
  import { addVary, problem, redirect } from './response';
45
49
  import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
@@ -93,6 +97,10 @@ export interface Stage extends StageDoc {
93
97
  */
94
98
  const SHED_RETRY_AFTER_SECONDS = '1';
95
99
 
100
+ /** The two names a policy travels under — the enforced one and `reportOnly`'s. */
101
+ const isCspHeader = (name: string): boolean =>
102
+ name === 'content-security-policy' || name === 'content-security-policy-report-only';
103
+
96
104
  /**
97
105
  * The one label a request with no matched route may carry. Every 404 and every scan of `/wp-admin`
98
106
  * would otherwise be its own rate-limit bucket and its own metric series — an attacker choosing
@@ -210,6 +218,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
210
218
  // A route's own authenticator REPLACES the app's, never runs beside it: a bearer mount must
211
219
  // not also accept the session cookie the app's hook would resolve (`RouteMeta.authenticate`).
212
220
  const authenticate = ctx.route?.meta.authenticate ?? hooks.authenticate;
221
+ // A required route is where credentials are guessed: an address that has spent its failures
222
+ // is refused before the credential store is asked, a right guess included.
223
+ if (ctx.route?.meta.auth === 'required') {
224
+ await refuseExhaustedAddress(ctx, limiter, config.rateLimit);
225
+ }
213
226
  if (authenticate !== undefined) {
214
227
  // The hook says "anonymous" with null; the context says it with core's anonymous actor,
215
228
  // because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
@@ -221,53 +234,21 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
221
234
  }
222
235
  }
223
236
  if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
237
+ // This refusal leaves before `rate-limit` runs, so it is metered here or nowhere: the
238
+ // address's own allowance, spent only by a failure (`spendUnauthenticated`).
239
+ await spendUnauthenticated(ctx, limiter, config.rateLimit);
224
240
  throw unauthenticated(ctx.url.pathname);
225
241
  }
226
242
  return undefined;
227
243
  },
228
244
 
229
245
  'rate-limit': async (_request, ctx) => {
230
- if (!config.rateLimit.enabled) return undefined;
231
- const actor = actorView(ctx.actor);
232
- // A LIST, and the second entry is why: the key builder used to pick ONE subject —
233
- // actor > org > ip, exclusive — so an authenticated request never touched a tenant bucket
234
- // and one org's 8,000 seats each ran their own allowance against one shared pool.
235
- const spends = rateLimitSpends(
236
- {
237
- actorId: actor?.id ?? null,
238
- orgId: actor?.orgId ?? null,
239
- ip: ctx.ip,
240
- routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
241
- },
242
- {
243
- route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
244
- tenant: config.rateLimit.tenantBucket,
245
- },
246
+ await spendRequestBuckets(
247
+ ctx,
248
+ limiter,
249
+ config.rateLimit,
250
+ ctx.route?.meta.name ?? UNMATCHED_ROUTE,
246
251
  );
247
- let answer: RateLimitDecision | undefined;
248
- let refusedKey: string | undefined;
249
- for (const spend of spends) {
250
- const decision = await limiter.check(spend.key, spend.bucket);
251
- // The bucket closest to refusing is the one the caller has to plan against: reporting
252
- // `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
253
- // tells a client it may proceed and then refuses its next call.
254
- if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
255
- if (!decision.allowed) {
256
- // The first refusal ends the spend, so a caller its own bucket already refused costs
257
- // its tenant nothing — one noisy actor may not drain the allowance it shares.
258
- answer = decision;
259
- refusedKey = spend.key;
260
- break;
261
- }
262
- }
263
- if (answer === undefined) return undefined;
264
- // Recorded before the throw so the 429 can carry Retry-After and the
265
- // RateLimit-* headers rather than making the client guess.
266
- ctx.rateLimit = answer;
267
- for (const [name, value] of Object.entries(limiter.headers(answer))) {
268
- ctx.headers.set(name, value);
269
- }
270
- if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
271
252
  return undefined;
272
253
  },
273
254
 
@@ -446,7 +427,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
446
427
  if (handedOver !== undefined) ctx.response = handedOver;
447
428
  const response = ctx.response;
448
429
  if (response === undefined) return undefined;
449
- for (const [name, value] of ctx.headers) response.headers.set(name, value);
430
+ // `Set-Cookie` is the one header that is a LIST of field lines: `set` in this loop kept the
431
+ // last cookie only, and dropped any the handler had put on its own Response.
432
+ for (const [name, value] of ctx.headers) {
433
+ if (name === 'set-cookie') response.headers.append(name, value);
434
+ else response.headers.set(name, value);
435
+ }
450
436
  for (const [name, value] of Object.entries(
451
437
  corsHeaders(config.cors, request.header('origin')),
452
438
  )) {
@@ -462,7 +448,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
462
448
  for (const [name, value] of Object.entries(
463
449
  responseSecurityHeaders(config.security, ctx.https),
464
450
  )) {
465
- response.headers.set(name, value);
451
+ // A policy the handler set is KEPT and the app's is added beside it — comma-joined, two
452
+ // policies, both enforced — so a handler can only narrow (a `sandbox` on an uploaded file),
453
+ // never widen. Overwritten, that `sandbox` never reached a browser.
454
+ if (isCspHeader(name) && response.headers.has(name)) response.headers.append(name, value);
455
+ else response.headers.set(name, value);
466
456
  }
467
457
  response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
468
458
  return undefined;