@ultimat3/http 30.9.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/CLAUDE.md +44 -40
- package/README.md +48 -35
- package/package.json +7 -6
- package/src/adapter.ts +14 -0
- package/src/auth-redirect.ts +11 -3
- package/src/bearer-mount.ts +7 -5
- package/src/body-ceiling.ts +54 -0
- package/src/byte-range.ts +6 -2
- package/src/compress.ts +77 -6
- package/src/config.ts +29 -7
- package/src/context.ts +3 -2
- package/src/cors.ts +20 -0
- package/src/error-facts.ts +27 -8
- package/src/error-log-level.ts +16 -2
- package/src/error-map-http.ts +2 -0
- package/src/error-map-tier-1.ts +11 -3
- package/src/error-map-tier-2.ts +10 -0
- package/src/error-map-tier-3.ts +14 -0
- package/src/error-map-tier-4.ts +15 -0
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +9 -5
- package/src/hooks.ts +44 -1
- package/src/index.ts +31 -86
- package/src/locale.ts +2 -2
- package/src/pipeline.ts +40 -11
- package/src/problem-meta.ts +8 -5
- package/src/rate-limit-buckets.ts +21 -3
- package/src/rate-limit-config.ts +123 -0
- package/src/rate-limit-degrade.ts +19 -4
- package/src/rate-limit-errors.ts +4 -4
- package/src/rate-limit-installed.ts +43 -53
- package/src/rate-limit-postgres.ts +10 -9
- package/src/rate-limit-redis.ts +176 -0
- package/src/rate-limit-stage.ts +3 -2
- package/src/rate-limit-store-deadline.ts +24 -6
- package/src/rate-limit.ts +13 -108
- package/src/server.ts +19 -37
- package/src/stages.ts +5 -2
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
|
-
/**
|
|
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
|
-
|
|
95
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/error-facts.ts
CHANGED
|
@@ -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.
|
|
144
|
-
*
|
|
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 =
|
|
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
|
-
// `
|
|
188
|
-
// so sending it is a body that costs the wire and answers nothing
|
|
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);
|
package/src/error-log-level.ts
CHANGED
|
@@ -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
|
};
|
package/src/error-map-http.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-1.ts
CHANGED
|
@@ -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.
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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>>;
|
package/src/error-map-tier-2.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-3.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-4.ts
CHANGED
|
@@ -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>>;
|
package/src/error-titles.ts
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
};
|