@ultimat3/http 22.15.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/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
@@ -181,7 +189,9 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
181
189
  if (routed instanceof Response) return routed;
182
190
  const match = matchRoute(input.table, ctx.method, routed);
183
191
  if (!match.ok) {
184
- if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
192
+ if (match.reason === 'not-found') {
193
+ throw hooks.explainMiss?.(ctx.method, pathname) ?? routeNotFound(ctx.method, pathname);
194
+ }
185
195
  if (match.reason === 'path-invalid') throw pathInvalid(pathname, match.segment);
186
196
  ctx.headers.set('allow', match.allow.join(', '));
187
197
  throw methodNotAllowed(ctx.method, pathname, match.allow);
@@ -208,6 +218,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
208
218
  // A route's own authenticator REPLACES the app's, never runs beside it: a bearer mount must
209
219
  // not also accept the session cookie the app's hook would resolve (`RouteMeta.authenticate`).
210
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
+ }
211
226
  if (authenticate !== undefined) {
212
227
  // The hook says "anonymous" with null; the context says it with core's anonymous actor,
213
228
  // because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
@@ -219,53 +234,21 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
219
234
  }
220
235
  }
221
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);
222
240
  throw unauthenticated(ctx.url.pathname);
223
241
  }
224
242
  return undefined;
225
243
  },
226
244
 
227
245
  'rate-limit': async (_request, ctx) => {
228
- if (!config.rateLimit.enabled) return undefined;
229
- const actor = actorView(ctx.actor);
230
- // A LIST, and the second entry is why: the key builder used to pick ONE subject —
231
- // actor > org > ip, exclusive — so an authenticated request never touched a tenant bucket
232
- // and one org's 8,000 seats each ran their own allowance against one shared pool.
233
- const spends = rateLimitSpends(
234
- {
235
- actorId: actor?.id ?? null,
236
- orgId: actor?.orgId ?? null,
237
- ip: ctx.ip,
238
- routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
239
- },
240
- {
241
- route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
242
- tenant: config.rateLimit.tenantBucket,
243
- },
246
+ await spendRequestBuckets(
247
+ ctx,
248
+ limiter,
249
+ config.rateLimit,
250
+ ctx.route?.meta.name ?? UNMATCHED_ROUTE,
244
251
  );
245
- let answer: RateLimitDecision | undefined;
246
- let refusedKey: string | undefined;
247
- for (const spend of spends) {
248
- const decision = await limiter.check(spend.key, spend.bucket);
249
- // The bucket closest to refusing is the one the caller has to plan against: reporting
250
- // `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
251
- // tells a client it may proceed and then refuses its next call.
252
- if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
253
- if (!decision.allowed) {
254
- // The first refusal ends the spend, so a caller its own bucket already refused costs
255
- // its tenant nothing — one noisy actor may not drain the allowance it shares.
256
- answer = decision;
257
- refusedKey = spend.key;
258
- break;
259
- }
260
- }
261
- if (answer === undefined) return undefined;
262
- // Recorded before the throw so the 429 can carry Retry-After and the
263
- // RateLimit-* headers rather than making the client guess.
264
- ctx.rateLimit = answer;
265
- for (const [name, value] of Object.entries(limiter.headers(answer))) {
266
- ctx.headers.set(name, value);
267
- }
268
- if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
269
252
  return undefined;
270
253
  },
271
254
 
@@ -444,7 +427,12 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
444
427
  if (handedOver !== undefined) ctx.response = handedOver;
445
428
  const response = ctx.response;
446
429
  if (response === undefined) return undefined;
447
- 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
+ }
448
436
  for (const [name, value] of Object.entries(
449
437
  corsHeaders(config.cors, request.header('origin')),
450
438
  )) {
@@ -460,7 +448,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
460
448
  for (const [name, value] of Object.entries(
461
449
  responseSecurityHeaders(config.security, ctx.https),
462
450
  )) {
463
- 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);
464
456
  }
465
457
  response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
466
458
  return undefined;