@ultimat3/http 30.8.0 → 31.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/compress.ts CHANGED
@@ -32,6 +32,9 @@ const COMPRESSIBLE_TYPES: ReadonlySet<string> = new Set([
32
32
  'application/rss+xml',
33
33
  'application/atom+xml',
34
34
  'text/vtt',
35
+ // Exports and feeds: usually streamed with no declared length, so coded by `codedStream`.
36
+ 'application/x-ndjson',
37
+ 'text/csv',
35
38
  ]);
36
39
 
37
40
  /**
@@ -68,7 +71,10 @@ const compressibleKind = (headers: Headers, anonymous: boolean): boolean => {
68
71
  );
69
72
  };
70
73
 
71
- /** HEAD is GET's headers with no body (RFC 9110 §9.3.2), so it is coded exactly as GET is. */
74
+ /**
75
+ * HEAD is GET's headers with no body (RFC 9110 §9.3.2): coded as GET is when the handler hands it
76
+ * GET's body to measure. A HEAD answered with no body has nothing to measure and stays identity.
77
+ */
72
78
  const CODED_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD']);
73
79
 
74
80
  /** RFC 9110 §12.5.3, `gzip` by name. A `*` is not read as consent to a coding it never named. */
@@ -91,18 +97,57 @@ const inBounds = (length: string | null): length is string =>
91
97
  const rebuilt = (response: Response, body: BodyInit | null, headers: Headers): Response =>
92
98
  new Response(body, { status: response.status, statusText: response.statusText, headers });
93
99
 
94
- const coded = (response: Response, bytes: Uint8Array<ArrayBuffer>, head: boolean): Response => {
95
- const gzipped = Bun.gzipSync(bytes, { level: GZIP_LEVEL });
100
+ /**
101
+ * A feed delivers each line as it is written, and `CompressionStream` emits only its header until
102
+ * the source closes — so an undeclared feed is never coded. Declared, it is a document.
103
+ */
104
+ const FEED_TYPES: ReadonlySet<string> = new Set(['application/x-ndjson']);
105
+
106
+ const isFeed = (contentType: string | null): boolean =>
107
+ FEED_TYPES.has(contentType?.split(';')[0]?.trim().toLowerCase() ?? '');
108
+
109
+ /** Past the ceiling: coded as the client pulls (`CompressionStream`), never on the event loop. */
110
+ const pastCeiling = (length: string): boolean =>
111
+ DECLARED_LENGTH.test(length) && Number(length) > COMPRESS_MAX_BYTES;
112
+
113
+ /** The headers of a gzip answer: everything that described the identity bytes, corrected. */
114
+ const codedHeaders = (response: Response, length: number | null): Headers => {
96
115
  const headers = new Headers(response.headers);
97
116
  headers.set('content-encoding', 'gzip');
98
- headers.set('content-length', String(gzipped.byteLength));
117
+ if (length === null) headers.delete('content-length');
118
+ else headers.set('content-length', String(length));
99
119
  // A byte offset into the identity bytes means nothing in these.
100
120
  headers.delete('accept-ranges');
101
121
  headers.delete('transfer-encoding');
102
122
  const etag = headers.get('etag');
103
123
  // A strong validator promises these exact bytes, and the identity answer has other ones.
104
124
  if (etag !== null && !etag.startsWith('W/')) headers.set('etag', `W/${etag}`);
105
- return rebuilt(response, head ? null : gzipped, headers);
125
+ return headers;
126
+ };
127
+
128
+ const coded = (response: Response, bytes: Uint8Array<ArrayBuffer>, head: boolean): Response => {
129
+ const gzipped = Bun.gzipSync(bytes, { level: GZIP_LEVEL });
130
+ return rebuilt(response, head ? null : gzipped, codedHeaders(response, gzipped.byteLength));
131
+ };
132
+
133
+ /** Bytes over a plain `ArrayBuffer`, the only kind the coder's `BufferSource` side accepts. */
134
+ const ownsPlainBuffer = (chunk: Uint8Array): chunk is Uint8Array<ArrayBuffer> =>
135
+ chunk.buffer instanceof ArrayBuffer;
136
+
137
+ const codedStream = (response: Response, body: ReadableStream<Uint8Array>): Response => {
138
+ // A chunk over shared memory is copied; every other one passes through untouched.
139
+ const plain = body.pipeThrough(
140
+ new TransformStream<Uint8Array, Uint8Array<ArrayBuffer>>({
141
+ transform(chunk, controller) {
142
+ controller.enqueue(ownsPlainBuffer(chunk) ? chunk : new Uint8Array(chunk));
143
+ },
144
+ }),
145
+ );
146
+ return rebuilt(
147
+ response,
148
+ plain.pipeThrough(new CompressionStream('gzip')),
149
+ codedHeaders(response, null),
150
+ );
106
151
  };
107
152
 
108
153
  /** Identity after a read began. Never the declared length: the failed read just disproved it. */
@@ -116,6 +161,30 @@ const identity = (
116
161
  return rebuilt(response, body, headers);
117
162
  };
118
163
 
164
+ /**
165
+ * No declared length (`new Response(string)` declares none in Bun): read up to the floor first. A
166
+ * body that ends under it leaves identity, whole, with its length; one that reaches it is coded as
167
+ * a stream, the bytes already read first. A feed is never read. Bounded by the request's deadline.
168
+ */
169
+ const codeUndeclared = async (
170
+ response: Response,
171
+ body: ReadableStream<Uint8Array>,
172
+ signal: AbortSignal | undefined,
173
+ ): Promise<Response> => {
174
+ if (isFeed(response.headers.get('content-type'))) return response;
175
+ const reader = body.getReader();
176
+ const drained = await drainCounted(reader, COMPRESS_MIN_BYTES - 1, signal);
177
+ if (drained.complete) return identity(response, concatChunks(drained.chunks));
178
+ const rest = replayDrained(drained.chunks, reader, drained.pending);
179
+ if (drained.reason === 'longer-than-declared') return codedStream(response, rest);
180
+ logger.warn('http.compress_failed', {
181
+ reason: drained.reason,
182
+ declaredBytes: null,
183
+ ...(drained.failure === undefined ? {} : { error: drained.failure }),
184
+ });
185
+ return identity(response, rest);
186
+ };
187
+
119
188
  export interface CompressContext {
120
189
  /** `isAnonymous(ctx.actor)`. Declared, never defaulted: a forgotten actor must not code. */
121
190
  readonly anonymous: boolean;
@@ -143,12 +212,14 @@ export const compressResponse = async (
143
212
  !acceptsGzip(request.headers.get('accept-encoding')) ||
144
213
  response.headers.has('content-encoding') ||
145
214
  response.headers.has('content-range') ||
146
- !inBounds(length) ||
147
215
  response.body === null ||
148
216
  response.bodyUsed
149
217
  ) {
150
218
  return response;
151
219
  }
220
+ if (length === null) return codeUndeclared(response, response.body, signal);
221
+ if (pastCeiling(length)) return codedStream(response, response.body);
222
+ if (!inBounds(length)) return response;
152
223
  const reader = response.body.getReader();
153
224
  const drained = await drainCounted(reader, Number(length), signal);
154
225
  if (!drained.complete) {
package/src/config.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  MAX_TIMER_MS,
10
10
  tryResolveEnvironment,
11
11
  } from '@ultimat3/core';
12
+ import { DEFAULT_MAX_REQUEST_BODY_BYTES } from './body-ceiling';
12
13
  import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
13
14
  import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
14
15
  import {
@@ -24,7 +25,8 @@ import {
24
25
  type LocaleConfig,
25
26
  type TimeZoneConfig,
26
27
  } from './locale';
27
- import { type RateLimitConfig, resolveRateLimitConfig } from './rate-limit';
28
+ import type { RateLimitConfig } from './rate-limit';
29
+ import { resolveRateLimitConfig } from './rate-limit-config';
28
30
  import { assertCspExtend, DEFAULT_SECURITY, type SecurityConfig } from './security-headers';
29
31
 
30
32
  export interface HttpConfig {
@@ -70,6 +72,13 @@ export interface HttpConfig {
70
72
  */
71
73
  readonly healthDetailPeers: readonly string[];
72
74
  readonly bodyLimitBytes: number;
75
+ /**
76
+ * The most this process reads in ONE request, for any route — `bodyLimitBytes` is the parsed
77
+ * RPC body's cap, this is the transport's (a signed upload reads past the first, never past
78
+ * this). Over it is `X_BODY_TOO_LARGE` (413), declared or streamed. Never below
79
+ * `bodyLimitBytes`; defaults to 128 MiB, Bun's own former default.
80
+ */
81
+ readonly maxRequestBodyBytes: number;
73
82
  /**
74
83
  * How long one request may run before it is aborted and answered `X_TIMEOUT` (504). `0`
75
84
  * disables it, which is a deployment saying it would rather hold a connection forever than
@@ -103,6 +112,7 @@ export interface HttpConfigInput {
103
112
  readonly trustClientCertHeader?: boolean;
104
113
  readonly healthDetailPeers?: readonly string[];
105
114
  readonly bodyLimitBytes?: number;
115
+ readonly maxRequestBodyBytes?: number;
106
116
  readonly requestTimeoutMs?: number;
107
117
  readonly maxInflight?: number;
108
118
  readonly locale?: Partial<LocaleConfig>;
@@ -258,6 +268,13 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
258
268
  // judged, and a directive name that is not a token would otherwise be a bare `TypeError` out of
259
269
  // the first response's header build — or worse, a second directive nobody declared.
260
270
  assertCspExtend(csp.extend);
271
+ const bodyLimitBytes = assertFiniteCount(
272
+ 'bodyLimitBytes',
273
+ input.bodyLimitBytes ?? 1_048_576,
274
+ Number.MAX_SAFE_INTEGER,
275
+ 'a whole number of bytes',
276
+ 'bodyLimitBytes: 1_048_576',
277
+ );
261
278
  return {
262
279
  // `Number.parseInt(env('PORT'), 10)` is `NaN` for `PORT=web`, and a config that carries NaN
263
280
  // into `Bun.serve` binds a port nobody asked for.
@@ -283,12 +300,17 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
283
300
  healthDetailPeers: assertHealthDetailPeers(
284
301
  input.healthDetailPeers === undefined ? DEFAULT_HEALTH_DETAIL_PEERS : input.healthDetailPeers,
285
302
  ),
286
- bodyLimitBytes: assertFiniteCount(
287
- 'bodyLimitBytes',
288
- input.bodyLimitBytes ?? 1_048_576,
289
- Number.MAX_SAFE_INTEGER,
290
- 'a whole number of bytes',
291
- 'bodyLimitBytes: 1_048_576',
303
+ bodyLimitBytes,
304
+ // Never below the parsed-body cap: a body `bodyLimitBytes` allows is never refused under it.
305
+ maxRequestBodyBytes: Math.max(
306
+ bodyLimitBytes,
307
+ assertFiniteCount(
308
+ 'maxRequestBodyBytes',
309
+ input.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES,
310
+ Number.MAX_SAFE_INTEGER,
311
+ 'a whole number of bytes',
312
+ `maxRequestBodyBytes: ${DEFAULT_MAX_REQUEST_BODY_BYTES}`,
313
+ ),
292
314
  ),
293
315
  // 30s: longer than any request a browser waits out, shorter than the 15s drain budget times
294
316
  // two, so a rolling restart cannot be held open by work started just before SIGTERM.
package/src/context.ts CHANGED
@@ -9,12 +9,12 @@ import {
9
9
  ctxOf,
10
10
  isAnonymous,
11
11
  type Logger,
12
+ liveClock,
12
13
  traceId as newTraceId,
13
14
  type Role,
14
15
  readCookie,
15
16
  runWithContext,
16
17
  type ServiceBag,
17
- systemClock,
18
18
  useContext,
19
19
  uuidV7,
20
20
  withChildContext,
@@ -176,7 +176,8 @@ export interface RequestContextInit {
176
176
  }
177
177
 
178
178
  export const requestContext = (init: RequestContextInit): RequestContext => {
179
- const clock = init.clock ?? systemClock;
179
+ // The `clock` port's clock as of THIS request, never one captured at boot.
180
+ const clock = init.clock ?? liveClock;
180
181
  const requestId = init.requestId ?? uuidV7(clock);
181
182
  // core's `traceId()`, never `uuid()`: a dashed UUIDv7 is not a 32-hex W3C trace id, and a
182
183
  // collector rejects the span that carries one — while the log lines beside it, which quote the
package/src/cors.ts CHANGED
@@ -74,6 +74,26 @@ export const assertCorsConfig = (config: CorsConfig): void => {
74
74
  "list the scheme too: configureHttp({ cors: { origins: ['http://localhost:3000'] } })",
75
75
  );
76
76
  }
77
+ for (const origin of config.origins) {
78
+ if (origin === '*') continue;
79
+ const canonical = canonicalOrigin(origin);
80
+ if (canonical === origin) continue;
81
+ throw corsConfigInvalid(
82
+ `origins includes ${JSON.stringify(origin)}, which no browser sends as an Origin — it is compared byte for byte, so it matches nothing`,
83
+ `configureHttp({ cors: { origins: ['${canonical}'] } }) # scheme, lower-case host, a port only when not the scheme's default — no path, no trailing slash`,
84
+ );
85
+ }
86
+ };
87
+
88
+ /**
89
+ * What a browser would send for this entry: `URL.origin`. A bare host gets `https://` prefixed so
90
+ * the fix names a value rather than a rule; an entry that does not parse at all keeps the shape.
91
+ */
92
+ const canonicalOrigin = (origin: string): string => {
93
+ const parsed = URL.parse(origin);
94
+ if (parsed !== null && parsed.origin !== OPAQUE_ORIGIN) return parsed.origin;
95
+ const withScheme = URL.parse(`https://${origin}`);
96
+ return withScheme === null ? 'https://<host>[:port]' : withScheme.origin;
77
97
  };
78
98
 
79
99
  /**
@@ -15,7 +15,7 @@ import {
15
15
  stringField,
16
16
  } from '@ultimat3/core';
17
17
  import type { ValidationIssue } from '@ultimat3/schema';
18
- import { issueRuleFacts } from '@ultimat3/schema';
18
+ import { issueRuleFacts, MAX_ISSUES } from '@ultimat3/schema';
19
19
  import { declaredStatusFor, statusFor } from './error-status';
20
20
  import { HTTP_ERROR_TITLES } from './errors';
21
21
  import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
@@ -140,11 +140,10 @@ export function retryAfterOf(error: unknown): number | undefined {
140
140
  }
141
141
 
142
142
  /**
143
- * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
144
- * same bound `@ultimat3/action`'s `issuesFromWire` applies on arrival, restated because that
145
- * package is tier 3 and this one is tier 2 — `error-facts.test.ts` pins the number on this side.
143
+ * A list this long is not a form's worth of rejections; it is a body meant to be expensive. Schema's
144
+ * own `MAX_ISSUES` — where validation stops collecting — imported, never restated (tier 0).
146
145
  */
147
- const MAX_PROBLEM_ISSUES = 100;
146
+ const MAX_PROBLEM_ISSUES = MAX_ISSUES;
148
147
 
149
148
  /**
150
149
  * The rejections a validation failure carried, addressed by path — or `undefined`.
@@ -184,9 +183,8 @@ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
184
183
  //
185
184
  // TOO LONG is `undefined` too, and dropped WHOLE rather than truncated: a subset is the
186
185
  // silent-drop this reader refuses everywhere else. The typed client bounds the same list at
187
- // `MAX_WIRE_ISSUES` and would refuse it on arrival anyway (`packages/action/src/wire-issues.ts`),
188
- // so sending it is a body that costs the wire and answers nothing — and that package is tier 3,
189
- // so the number is restated here rather than imported.
186
+ // schema's `MAX_ISSUES` and would refuse it on arrival anyway (`packages/action/src/wire-issues.ts`),
187
+ // so sending it is a body that costs the wire and answers nothing.
190
188
  if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PROBLEM_ISSUES) {
191
189
  return undefined;
192
190
  }
@@ -287,6 +285,13 @@ export interface ProblemDocument {
287
285
  * `[]` says "validated clean", which is a different and false claim.
288
286
  */
289
287
  readonly issues?: readonly ValidationIssue[] | undefined;
288
+ /**
289
+ * `true` when validation stopped at `MAX_ISSUES` and `issues` is a prefix — off the error's
290
+ * `meta.issuesTruncated`, which action's and query's `X_INPUT_INVALID` both set. ABSENT unless
291
+ * `issues` is carried AND the mark is literally `true`: it qualifies a list, so it never rides
292
+ * without one, and a client that renders `issues` instead of `cause` must not call a prefix whole.
293
+ */
294
+ readonly issuesTruncated?: true | undefined;
290
295
  /**
291
296
  * The keys of the error's `meta` that `registerProblemMeta` declared for its code, JSON-safe
292
297
  * and bounded — and nothing else off `meta`, which is the operator-only bag the rest of this
@@ -352,6 +357,7 @@ export const toProblem = (
352
357
  // withhold — it names the fields and the expectations of something the caller was never meant to
353
358
  // see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
354
359
  const issues = hidden ? undefined : issuesOf(error);
360
+ const truncated = issues !== undefined && issuesTruncatedOf(error);
355
361
  // The same condition, for the same reason: a code nobody classified cannot have declared any
356
362
  // key either, so this is the belt on `registerProblemMeta`'s "both registrations are needed".
357
363
  const carried = opaque ? undefined : metaOf(error, facts.code);
@@ -378,10 +384,23 @@ export const toProblem = (
378
384
  retry: retryOf(error),
379
385
  requestId: meta.requestId,
380
386
  ...(issues === undefined ? {} : { issues }),
387
+ ...(truncated ? { issuesTruncated: true as const } : {}),
381
388
  ...(carried === undefined ? {} : { meta: carried }),
382
389
  };
383
390
  };
384
391
 
392
+ /** `meta.issuesTruncated === true`, read totally — `issuesOf`'s reason: a value http did not build. */
393
+ function issuesTruncatedOf(error: unknown): boolean {
394
+ if (typeof error !== 'object' || error === null) return false;
395
+ try {
396
+ const meta: unknown = (error as Record<string, unknown>)['meta'];
397
+ if (typeof meta !== 'object' || meta === null) return false;
398
+ return (meta as Record<string, unknown>)['issuesTruncated'] === true;
399
+ } catch {
400
+ return false;
401
+ }
402
+ }
403
+
385
404
  /** The exact three lines the terminal prints, reused by the overlay and `--json`. */
386
405
  export const renderErrorLines = (error: unknown): string => {
387
406
  const facts = factsOf(error);
@@ -1,12 +1,26 @@
1
1
  // Single responsibility: the log level of the one line the `error-map` stage writes per failed
2
2
  // request. Every 4xx was logged at `error`, so an alert keyed on error lines paged for a typo'd URL.
3
3
 
4
+ /**
5
+ * Refusals that are routine by construction: `unauthenticated()` — a signed-in page asked by
6
+ * nobody, mostly scanners in production. Marked at the factory, so the same CODE raised by a
7
+ * credential checker that rejected something (`@ultimat3/auth`'s wrong password) stays `warn`.
8
+ */
9
+ const routine = new WeakSet<object>();
10
+
11
+ /** Marks a refusal the `error-map` line logs at `info` whatever its status. Returns it. */
12
+ export const routineRefusal = <E extends object>(error: E): E => {
13
+ routine.add(error);
14
+ return error;
15
+ };
16
+
4
17
  /**
5
18
  * `error` for 5xx — the server failed. `warn` for 401/403/429 — a refused credential, a refused
6
19
  * permission or a limit, each worth seeing in bulk. `info` for every other 4xx — the caller's own
7
- * mistake, which the problem document already explained to them.
20
+ * mistake, which the problem document already explained to them — and for a `routineRefusal`.
8
21
  */
9
- export const errorLogLevel = (status: number): 'error' | 'warn' | 'info' => {
22
+ export const errorLogLevel = (status: number, error?: unknown): 'error' | 'warn' | 'info' => {
10
23
  if (status >= 500) return 'error';
24
+ if (typeof error === 'object' && error !== null && routine.has(error)) return 'info';
11
25
  return status === 401 || status === 403 || status === 429 ? 'warn' : 'info';
12
26
  };
@@ -96,4 +96,6 @@ export const HTTP_ERROR_STATUS = {
96
96
  X_ROUTE_NAVIGATION_INVALID: 500,
97
97
  // @ultimat3/http — an exercise entry's request is not one a browser could send
98
98
  X_EXERCISE_REQUEST_INVALID: 500,
99
+ // @ultimat3/http — request body over the transport ceiling
100
+ X_BODY_TOO_LARGE: 413,
99
101
  } satisfies Readonly<Record<string, number>>;
@@ -42,9 +42,9 @@ export const TIER_1_ERROR_STATUS = {
42
42
  // not an attacker — reporting it as 403 sends the on-call hunting somebody who is not there.
43
43
  X_STORAGE_URL_UNVERIFIABLE: 500,
44
44
  // 409, not the 500 it fell through to: the object exists and the request is well formed — the
45
- // STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
46
- // refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
47
- // can do. A 500 would have read as "the server broke" for a workflow working exactly as built.
45
+ // STATE is wrong. The app's `uploadScanner` answered `hold` (a review, an async vendor scan), so
46
+ // `promoteAttachment` keeps the upload pending until it answers clean, and a retry then succeeds.
47
+ // A 500 would have read as "the server broke" for a workflow working exactly as built.
48
48
  X_STORAGE_QUARANTINED: 409,
49
49
  // 409 for the same reason: `promoteAttachment` on a key that is not a pending upload — promoted
50
50
  // twice, or never uploaded — is a state the caller can see and fix, not a server fault.
@@ -106,4 +106,12 @@ export const TIER_1_ERROR_STATUS = {
106
106
  X_MONEY_INPUT_INVALID: 422,
107
107
  // @ultimat3/seo — a route meta og declares both image and capture
108
108
  X_SEO_OG_IMAGE_CONFLICT: 500,
109
+ // @ultimat3/money — a fromDecimal input that is not a plain decimal string
110
+ X_MONEY_NOT_DECIMAL: 422,
111
+ // @ultimat3/storage — the upload scanner found malware in the file
112
+ X_STORAGE_UPLOAD_INFECTED: 422,
113
+ // @ultimat3/storage — the upload scanner could not give a verdict
114
+ X_STORAGE_SCAN_UNAVAILABLE: 503,
115
+ // @ultimat3/storage — a targeted upload grant would bypass the upload scanner
116
+ X_STORAGE_TARGET_UNSCANNED: 500,
109
117
  } satisfies Readonly<Record<string, number>>;
@@ -137,4 +137,14 @@ export const TIER_2_ERROR_STATUS = {
137
137
  X_PASSKEY_STEP_UP_REQUIRED: 401,
138
138
  // @ultimat3/entity — a read-only entity was asked to write a row
139
139
  X_ENTITY_READ_ONLY: 500,
140
+ // @ultimat3/entity — a custom driver was asked for a capability it does not declare
141
+ X_DRIVER_UNSUPPORTED: 501,
142
+ // @ultimat3/entity — a custom driver returned a row outside the plan it was given
143
+ X_DRIVER_ROW_OUT_OF_SCOPE: 500,
144
+ // @ultimat3/auth — a saml response failed its signature, wrapping, audience, recipient, time, InResponseTo or replay check
145
+ X_SAML_RESPONSE_INVALID: 400,
146
+ // @ultimat3/auth — saml IdP metadata has no entity id, HTTP-Redirect sign-on endpoint or signing certificate
147
+ X_SAML_METADATA_INVALID: 500,
148
+ // @ultimat3/auth — the URL named an identity provider the sso routes were not given
149
+ X_IDENTITY_PROVIDER_UNKNOWN: 404,
140
150
  } satisfies Readonly<Record<string, number>>;
@@ -127,4 +127,18 @@ export const TIER_3_ERROR_STATUS = {
127
127
  X_METER_EXCEEDED: 429,
128
128
  // @ultimat3/action — an action's meter cannot be counted against
129
129
  X_METER_INVALID: 500,
130
+ // @ultimat3/realtime — a channel's catch-up read decides with another policy
131
+ X_CHANNEL_CATCHUP_POLICY_MISMATCH: 500,
132
+ // @ultimat3/jobs — withLock could not take the lock within its wait bound
133
+ X_LOCK_NOT_ACQUIRED: 409,
134
+ // @ultimat3/jobs — a lock withLock held expired before its work finished
135
+ X_LOCK_LOST: 409,
136
+ // @ultimat3/jobs — withLock found no lease store to hold the lock in
137
+ X_LOCK_STORE_MISSING: 500,
138
+ // @ultimat3/jobs — a ledger posting does not sum to zero in every currency
139
+ X_LEDGER_UNBALANCED: 422,
140
+ // @ultimat3/jobs — a ledger entry is not a whole-minor Money amount on a named account
141
+ X_LEDGER_ENTRY_INVALID: 422,
142
+ // @ultimat3/jobs — a ledger posting key is already stored with other entries
143
+ X_LEDGER_POSTING_CONFLICT: 409,
130
144
  } satisfies Readonly<Record<string, number>>;
@@ -180,4 +180,19 @@ export const TIER_4_ERROR_STATUS = {
180
180
  X_MCP_TENANT_REQUIRED: 400,
181
181
  // @ultimat3/mcp — the tenant of an MCP call could not be resolved
182
182
  X_MCP_TENANT_UNRESOLVED: 500,
183
+ // @ultimat3/scraping — pdf() has no origin to render the route on
184
+ X_SCRAPE_PDF_ORIGIN_MISSING: 500,
185
+ // @ultimat3/scraping — a pdf() route is not a path on this app
186
+ X_SCRAPE_PDF_ROUTE_INVALID: 500,
187
+ // @ultimat3/scraping — the route pdf() printed answered an error status
188
+ X_SCRAPE_PDF_ROUTE_FAILED: 502,
189
+ // @ultimat3/scraping — the raw-CDP browser (`@ultimat3/scraping/cdp`), reached by a request
190
+ // through pdf(): no browser on this host and one that never started are this server's 503; a
191
+ // refused call is the upstream browser's 502, an unanswered one its 504.
192
+ X_CDP_BROWSER_MISSING: 503,
193
+ X_CDP_LAUNCH_FAILED: 503,
194
+ X_CDP_CALL_FAILED: 502,
195
+ X_CDP_TIMEOUT: 504,
196
+ // @ultimat3/scraping — every pdf() render slot stayed busy past the queue deadline
197
+ X_SCRAPE_PDF_BUSY: 503,
183
198
  } satisfies Readonly<Record<string, number>>;
@@ -39,6 +39,7 @@ export const HTTP_OWNED_ERROR_CODES = [
39
39
  'X_BEARER_MOUNT_INVALID',
40
40
  'X_RATE_LIMIT_STORE_TIMEOUT',
41
41
  'X_EXERCISE_REQUEST_INVALID',
42
+ 'X_BODY_TOO_LARGE',
42
43
  ] as const;
43
44
 
44
45
  export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
@@ -75,6 +76,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
75
76
  X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
76
77
  X_RATE_LIMIT_STORE_TIMEOUT: 'the shared rate-limit store did not answer inside its own deadline',
77
78
  X_EXERCISE_REQUEST_INVALID: "an exercise entry's request is not one a browser could send",
79
+ X_BODY_TOO_LARGE: 'request body over the transport ceiling',
78
80
  };
79
81
 
80
82
  // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
package/src/errors.ts CHANGED
@@ -6,6 +6,7 @@ import { deniedCallerFix, renderFixShellArg, renderThrowable, UltimateError } fr
6
6
  // `sideEffects` lists, imported bare here and from the barrel — so this module is side-effect-free
7
7
  // and a chunk keeps only the classes it constructs. Re-exported: every name still reads from here.
8
8
  import './error-titles';
9
+ import { routineRefusal } from './error-log-level';
9
10
  import { HTTP_OWNED_ERROR_CODES } from './error-titles';
10
11
 
11
12
  export {
@@ -143,12 +144,15 @@ export const bearerMountInvalid = (prefix: string, reason: string): HttpError =>
143
144
  fix: "declare mounts: [{ prefix: '/v1', scopes: { 'cases:read': ['caseList'] }, resolveToken }] in defineApi({ http }) — a prefix of lowercase segments that is not /api or /_x, naming only registered actions and queries (x routes --json lists them)",
144
145
  });
145
146
 
147
+ /** No actor on a required route: the routine login wall, logged at `info` (`routineRefusal`). */
146
148
  export const unauthenticated = (pathname: string): HttpError =>
147
- new HttpError({
148
- code: 'X_UNAUTHENTICATED',
149
- cause: `${pathname} declares auth: 'required' and no actor was resolved`,
150
- fix: "send a session cookie or Authorization header, or set meta.auth to 'public'",
151
- });
149
+ routineRefusal(
150
+ new HttpError({
151
+ code: 'X_UNAUTHENTICATED',
152
+ cause: `${pathname} declares auth: 'required' and no actor was resolved`,
153
+ fix: "send a session cookie or Authorization header, or set meta.auth to 'public'",
154
+ }),
155
+ );
152
156
 
153
157
  /**
154
158
  * `x policy explain` resolves a policy SUBJECT — a permission, an action name or a query name.
package/src/hooks.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // found. All three are declared structurally, which keeps the import boundary intact and keeps
5
5
  // the pipeline testable.
6
6
  import type { Actor, UltimateError } from '@ultimat3/core';
7
+ import { assert, finiteCount } from '@ultimat3/core';
7
8
  import type { RequestContext } from './context';
8
9
  import type { OverlayNotice } from './overlay';
9
10
  import type { UltimateRequest } from './request';
@@ -79,15 +80,57 @@ export type Authenticator = NonNullable<ServerHooks['authenticate']>;
79
80
  * place both are in scope, and that is where the wire belongs.
80
81
  */
81
82
  let configured: Authenticator | undefined;
83
+ let credentialTtl: number | undefined;
82
84
 
83
- export const configureAuthenticator = (authenticate: Authenticator): void => {
85
+ /**
86
+ * The longest `credentialTtlMs` may be: the `sync` node's own re-check window. The option may only
87
+ * SHORTEN how long a websocket grant stands — a longer one turns off the re-check that makes
88
+ * `logout`, `revokeSession` and `disableUser` close a socket, for as long as the token lives.
89
+ */
90
+ export const MAX_CREDENTIAL_TTL_MS = 5 * 60_000;
91
+
92
+ export interface AuthenticatorOptions {
93
+ /**
94
+ * How long an identity this authenticator resolved may stand on a LONG-LIVED connection — a
95
+ * websocket, which carries no request to re-authenticate — before the host asks again. The
96
+ * credential's lifetime is this function's fact (it knows what a token is worth), so it is
97
+ * declared here and not as a second authenticator on the boot. Omitted, the host's own default
98
+ * (the `sync` node's five minutes). A whole number of milliseconds, 1 to `MAX_CREDENTIAL_TTL_MS`.
99
+ */
100
+ readonly credentialTtlMs?: number;
101
+ }
102
+
103
+ /**
104
+ * Declare the app's authenticator, and with it what its credentials are worth. A second call
105
+ * replaces the first WHOLE — a lifetime left over from an earlier declaration would be a fact
106
+ * about a function that no longer answers.
107
+ */
108
+ export const configureAuthenticator = (
109
+ authenticate: Authenticator,
110
+ options: AuthenticatorOptions = {},
111
+ ): void => {
112
+ // Screened BEFORE the swap, so a refused call leaves the previous declaration standing.
113
+ const ttl =
114
+ options.credentialTtlMs === undefined
115
+ ? undefined
116
+ : finiteCount('configureAuthenticator', 'credentialTtlMs', options.credentialTtlMs, 1);
117
+ assert(
118
+ ttl === undefined || ttl <= MAX_CREDENTIAL_TTL_MS,
119
+ `configureAuthenticator credentialTtlMs is ${String(ttl)}, past the ${String(MAX_CREDENTIAL_TTL_MS)}ms re-check window — a longer grant keeps a revoked actor's websocket open for as long as the token lives`,
120
+ `credentialTtlMs: ${String(MAX_CREDENTIAL_TTL_MS)} # or less: it shortens the sync re-check, never lengthens it`,
121
+ );
84
122
  configured = authenticate;
123
+ credentialTtl = ttl;
85
124
  };
86
125
 
87
126
  /** What a host passes as `hooks.authenticate`. `undefined` means every request is anonymous. */
88
127
  export const configuredAuthenticator = (): Authenticator | undefined => configured;
89
128
 
129
+ /** The declared `credentialTtlMs`, or `undefined` for "the host's default". */
130
+ export const configuredCredentialTtlMs = (): number | undefined => credentialTtl;
131
+
90
132
  /** Test seam. Production configures once at boot and never unconfigures. */
91
133
  export const resetAuthenticator = (): void => {
92
134
  configured = undefined;
135
+ credentialTtl = undefined;
93
136
  };