@ultimat3/http 11.3.0 → 13.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 +159 -4
- package/README.md +109 -6
- package/package.json +5 -5
- package/src/app-config.ts +146 -0
- package/src/config.ts +5 -2
- package/src/context.ts +65 -33
- package/src/cors.ts +2 -2
- package/src/deadline.ts +18 -1
- package/src/error-facts.ts +286 -0
- package/src/error-map.ts +130 -193
- package/src/errors.ts +46 -6
- package/src/index.ts +32 -7
- package/src/overlay.ts +1 -1
- package/src/pipeline.ts +4 -0
- package/src/rate-limit-errors.ts +23 -7
- package/src/rate-limit.ts +61 -5
- package/src/response.ts +1 -1
- package/src/stages.ts +36 -14
- package/src/type-pins.ts +39 -0
- package/src/webhook-verify.ts +133 -0
package/src/error-map.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The one place a framework error code becomes an HTTP status. A table, not a
|
|
2
2
|
// switch chain: adding a code elsewhere in the framework means adding a row here,
|
|
3
3
|
// and a missing row is a loud 500 rather than a silently wrong 200.
|
|
4
|
-
|
|
5
|
-
import { errorStatusInvalid
|
|
4
|
+
// Rendering a throwable for a reader is `error-facts.ts`; this file answers only the status.
|
|
5
|
+
import { errorStatusInvalid } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* code -> status. Codes owned by other packages are listed here on purpose: HTTP
|
|
@@ -48,8 +48,10 @@ export const ERROR_STATUS = {
|
|
|
48
48
|
X_RATE_LIMIT_BUCKET_CONFLICT: 500,
|
|
49
49
|
// Construction time as well: the limiter installed cannot enforce a bucket a route declares.
|
|
50
50
|
X_RATE_LIMIT_BUCKET_UNBOUND: 500,
|
|
51
|
-
// `defineHttpConfig` time,
|
|
51
|
+
// `defineHttpConfig` time, all three: a declaration the deployment owes and did not make, or
|
|
52
|
+
// made against a bucket nothing declares.
|
|
52
53
|
X_RATE_LIMIT_SCOPE_UNSET: 500,
|
|
54
|
+
X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 500,
|
|
53
55
|
X_TRUST_PROXY_UNSET: 500,
|
|
54
56
|
// Raised by `toBucket` while a route or an action is being projected, never on the request.
|
|
55
57
|
X_RATE_LIMIT_INVALID: 500,
|
|
@@ -67,6 +69,15 @@ export const ERROR_STATUS = {
|
|
|
67
69
|
// 403 and never 401: the caller IS authenticated — that is what makes the forged write work —
|
|
68
70
|
// so a 401 would send a signed-in user to a sign-in page they are already past.
|
|
69
71
|
X_CSRF_BLOCKED: 403,
|
|
72
|
+
// 401 for both, and never 400: an inbound webhook is well formed and carries a CREDENTIAL — a
|
|
73
|
+
// timestamped hmac over its own bytes — so what failed is authentication, not the request. Never
|
|
74
|
+
// 403 either, which means an authenticated caller was refused, and there is no authenticated
|
|
75
|
+
// caller here. Two codes rather than one because the repairs differ and a sender's dashboard
|
|
76
|
+
// shows the status: `INVALID` is the wrong secret or a rewritten body, `STALE` is a skewed clock
|
|
77
|
+
// or a delivery being replayed off a capture. Neither triggers `signInRedirect`, which keys on
|
|
78
|
+
// `X_UNAUTHENTICATED` alone — a webhook sender is not a browser and has no session to go get.
|
|
79
|
+
X_WEBHOOK_SIGNATURE_INVALID: 401,
|
|
80
|
+
X_WEBHOOK_SIGNATURE_STALE: 401,
|
|
70
81
|
// @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
|
|
71
82
|
// the primitive declared. 400 because that is what the published OpenAPI operation promises for
|
|
72
83
|
// it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
|
|
@@ -138,6 +149,46 @@ export const ERROR_STATUS = {
|
|
|
138
149
|
// signed-in caller to a sign-in page that cannot give them a tenant.
|
|
139
150
|
X_TENANCY_ACTOR_ORG_REQUIRED: 403,
|
|
140
151
|
X_TENANCY_CROSS_DENIED: 403,
|
|
152
|
+
// The three aggregate refusals, all 500 and all deliberately NOT a 4xx, for the reason
|
|
153
|
+
// `X_QUERY_NOT_PAGEABLE` below is one: nothing the caller sends changes the answer, and the fix
|
|
154
|
+
// is an edit to the read itself. They earn ROWS rather than a pin in `scripts/error-map-backlog.ts`
|
|
155
|
+
// because each carries an instruction the app's author needs and an unmapped 5xx is blanked —
|
|
156
|
+
// `toProblem` replaces an undeclared code's cause with `INTERNAL_CAUSE`, so pinning them would
|
|
157
|
+
// answer "the server failed while handling this request" for a fault whose own `fix:` names the
|
|
158
|
+
// exact call to write instead. A row costs no extra page: `stages.ts` reports every `status >= 500`
|
|
159
|
+
// either way.
|
|
160
|
+
//
|
|
161
|
+
// Reached with a request waiting, all three, which is why they are not in the backlog's entity
|
|
162
|
+
// group ("misuse a handler's author makes") — the mixed-currency and the ±2^53 refusals are
|
|
163
|
+
// decided by the ROWS, so a read that answered for two years starts failing on the day the data
|
|
164
|
+
// crosses the line, and `approximateCount()` on a chain whose predicates came from the caller's
|
|
165
|
+
// own optional filters is one query string away.
|
|
166
|
+
X_AGGREGATE_UNSUPPORTED: 500,
|
|
167
|
+
X_AGGREGATE_MIXED_CURRENCY: 500,
|
|
168
|
+
X_APPROXIMATE_COUNT_FILTERED: 500,
|
|
169
|
+
// The two search refusals, 500 for the reason the three above are: nothing the caller sends
|
|
170
|
+
// changes either answer. An entity with no searchable column needs a `searchable()` on one, and
|
|
171
|
+
// a driver that cannot answer a full-text match needs the Postgres one — both are edits to the
|
|
172
|
+
// app, and both carry a `fix:` that an unmapped 5xx would blank (`toProblem` replaces an
|
|
173
|
+
// undeclared code's cause with `INTERNAL_CAUSE`).
|
|
174
|
+
X_SEARCH_UNDECLARED: 500,
|
|
175
|
+
X_SEARCH_IN_MEMORY: 500,
|
|
176
|
+
// The three state-machine refusals, and they are deliberately THREE statuses rather than one:
|
|
177
|
+
// the machine says the transition does not exist, the row says it is somewhere else, or the
|
|
178
|
+
// column says there is no machine at all — three different readers and three different repairs.
|
|
179
|
+
//
|
|
180
|
+
// 422 and not 400: the request is well formed and its schema passed. The transition the caller
|
|
181
|
+
// named is not one this machine has, which is the same shape as `X_INVARIANT_VIOLATED` above and
|
|
182
|
+
// takes its status. Refused before any statement opens a connection, so nothing was written.
|
|
183
|
+
X_STATE_TRANSITION_ILLEGAL: 422,
|
|
184
|
+
// 409, the lost update caught. The row moved between the read the caller decided on and the
|
|
185
|
+
// write it asked for — nothing is wrong with either, and the repair is re-read and retry, which
|
|
186
|
+
// is precisely what a 409 tells a client to do. A 422 would say "your request is unusable",
|
|
187
|
+
// which is false: the identical request succeeds a moment later.
|
|
188
|
+
X_STATE_CONFLICT: 409,
|
|
189
|
+
// 500, the same shelf as `X_SEARCH_UNDECLARED`: a column with no machine is a declaration the
|
|
190
|
+
// app has not written, and no request changes that.
|
|
191
|
+
X_STATE_UNDECLARED: 500,
|
|
141
192
|
// @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
|
|
142
193
|
// violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
|
|
143
194
|
// same event one layer up — is 409 above; a foreign key rides with it because both halves of it
|
|
@@ -157,6 +208,46 @@ export const ERROR_STATUS = {
|
|
|
157
208
|
// it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
|
|
158
209
|
// closed one, and a code with no row is a 500 anyway.
|
|
159
210
|
X_ACTION_JOB_UNBRIDGED: 500,
|
|
211
|
+
// Every `X_WEBHOOK_*` below is OUTBOUND and is thrown inside a worker: `ROLE=worker` opens no
|
|
212
|
+
// HTTP port, so none of them ever answers a request. The rows exist for the reason
|
|
213
|
+
// `X_ACTION_JOB_UNBRIDGED`'s does — this table is the closed one, and a code with no row is a
|
|
214
|
+
// 500 anyway. The INBOUND pair (`X_WEBHOOK_SIGNATURE_*`, 401) is @ultimat3/http's and sits with
|
|
215
|
+
// the rest of this package's codes above; these are the ones a delivery ends on.
|
|
216
|
+
X_WEBHOOK_ENDPOINT_UNKNOWN: 500,
|
|
217
|
+
X_WEBHOOK_ENDPOINT_INVALID: 500,
|
|
218
|
+
X_WEBHOOK_ENDPOINT_DISABLED: 500,
|
|
219
|
+
X_WEBHOOK_EVENT_UNKNOWN: 500,
|
|
220
|
+
X_WEBHOOK_EVENT_INVALID: 500,
|
|
221
|
+
X_WEBHOOK_DELIVERY_FAILED: 500,
|
|
222
|
+
X_WEBHOOK_DELIVERY_THROTTLED: 500,
|
|
223
|
+
X_WEBHOOK_DELIVERY_REJECTED: 500,
|
|
224
|
+
// Same class again: an export pass runs in a worker, and both codes refuse the DECLARATION —
|
|
225
|
+
// a `row()` that answers columns nobody declared, and a page too big to hold. Neither is
|
|
226
|
+
// anything a caller sent.
|
|
227
|
+
X_EXPORT_ROW_INVALID: 500,
|
|
228
|
+
X_EXPORT_PART_TOO_LARGE: 500,
|
|
229
|
+
// @ultimat3/notify — five 500s and one 502, and the split is who failed.
|
|
230
|
+
//
|
|
231
|
+
// The five are the app's own declaration: a notifier with no channels, one channel named twice,
|
|
232
|
+
// a digest window on a bulk channel, a store nothing installed, and a fan-out past the per-run
|
|
233
|
+
// ceiling. Every `fix:` on those five names a code edit or a boot call, so nothing a caller
|
|
234
|
+
// sends changes any of them — `X_NOTIFY_FANOUT_TOO_WIDE` is the only one a request can even
|
|
235
|
+
// INFLUENCE (an action that notifies a whole org), and the repair is still `bulkChannel()` or a
|
|
236
|
+
// paged `backfill()`, never the request.
|
|
237
|
+
X_NOTIFY_CHANNELS_EMPTY: 500,
|
|
238
|
+
X_NOTIFY_CHANNEL_DUPLICATE: 500,
|
|
239
|
+
X_NOTIFY_FANOUT_TOO_WIDE: 500,
|
|
240
|
+
X_NOTIFY_STORE_MISSING: 500,
|
|
241
|
+
X_NOTIFY_DIGEST_UNSUPPORTED: 500,
|
|
242
|
+
// 502, and it is the one row on this table that answers for somebody else's server. This code
|
|
243
|
+
// WRAPS a provider rejection — `NotifyDeliveryFailedError` takes the caught value and renders it
|
|
244
|
+
// — so the thing that failed is the channel's upstream, not this process. It is thrown inside a
|
|
245
|
+
// job step today (`x jobs show <notifier> --json` is its own `fix:`), so nothing reaches a
|
|
246
|
+
// request and the number is unobservable either way; the row is chosen for the day that stops
|
|
247
|
+
// being true, and the asymmetry decides it. A wrong 502 costs nothing. A wrong 500 pages the
|
|
248
|
+
// on-call for an email provider's outage, because `stages.ts` reports every `status >= 500` to
|
|
249
|
+
// the error monitor — which is the failure this whole table exists to stop.
|
|
250
|
+
X_NOTIFY_DELIVERY_FAILED: 502,
|
|
160
251
|
// @ultimat3/policy
|
|
161
252
|
X_POLICY_MISSING: 500,
|
|
162
253
|
X_PERMISSION_UNKNOWN: 500,
|
|
@@ -210,12 +301,36 @@ export const ERROR_STATUS = {
|
|
|
210
301
|
// 404, deliberately NOT 403: the org check fires before anything is read, so answering
|
|
211
302
|
// "forbidden" would confirm that a key exists to the one caller who must not learn it.
|
|
212
303
|
X_STORAGE_ORG_MISMATCH: 404,
|
|
304
|
+
// @ultimat3/ui — a form control whose `name` is not a usable field path. The owning slice argued
|
|
305
|
+
// for NO ROW, on the grounds that this is a render-time developer error that can never reach
|
|
306
|
+
// HTTP, and the argument is right about the code and wrong about the table.
|
|
307
|
+
//
|
|
308
|
+
// `scripts/error-map-backlog.ts` is the only "no row" this table has, and its own header says
|
|
309
|
+
// what an entry there means: "NOT a claim that the code can never cross HTTP … a claim that
|
|
310
|
+
// nobody has decided yet", with the ratchet promising only that the undecided set never grows.
|
|
311
|
+
// This code HAS been decided, so a pin would record the opposite of what is known and grow the
|
|
312
|
+
// one list that may not grow.
|
|
313
|
+
//
|
|
314
|
+
// So it takes the answer every other decided-and-unreachable code takes — `X_CORS_CONFIG_INVALID`,
|
|
315
|
+
// `X_ACTION_JOB_UNBRIDGED`, `X_RATE_LIMIT_NOT_SHARED`. The row is NOT a claim that it reaches a
|
|
316
|
+
// request. A code with no row already answers 500 (`DEFAULT_STATUS`); the row changes nothing at
|
|
317
|
+
// runtime and makes that answer a reviewed one instead of an accident, which is the whole reason
|
|
318
|
+
// this table is closed.
|
|
319
|
+
X_UI_FORM_PATH_INVALID: 500,
|
|
213
320
|
// @ultimat3/mail
|
|
214
321
|
// The deployment configured no transport. It reaches a caller only through an inline
|
|
215
322
|
// `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
|
|
216
323
|
// configuration fault either way, so 500 and never a 4xx — nothing the caller sent is wrong, and
|
|
217
324
|
// this is exactly the condition somebody should be paged for.
|
|
218
325
|
X_MAIL_CREDENTIAL_MISSING: 500,
|
|
326
|
+
// @ultimat3/mcp — the one MCP code that is answered on a REQUEST rather than inside a JSON-RPC
|
|
327
|
+
// envelope, which is what the rest of that package's backlog group says about the others: the
|
|
328
|
+
// transport refused before dispatch, so there is no call to answer. 429 because
|
|
329
|
+
// `mcpHttpRoute` already builds that response by hand (`transport-http.ts`'s `throttled`), with
|
|
330
|
+
// `retry-after` beside it. The row is what keeps the two surfaces from disagreeing the day the
|
|
331
|
+
// MCP host is mounted inside this pipeline — a code that renders 429 on one and 500 on the other
|
|
332
|
+
// is exactly the split this table exists to prevent.
|
|
333
|
+
X_MCP_RATE_LIMITED: 429,
|
|
219
334
|
// @ultimat3/core
|
|
220
335
|
// The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
|
|
221
336
|
// unsupported representation, which is 415 — not a 500, which would blame the server for it.
|
|
@@ -303,197 +418,19 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
|
|
|
303
418
|
/** Test seam. Production registers once at boot and never unregisters. */
|
|
304
419
|
export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
305
420
|
|
|
306
|
-
// Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
307
|
-
// belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
308
|
-
// even if a future caller reaches the map some other way.
|
|
309
|
-
// `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
|
|
310
|
-
// prefer one for anything keyed by a value a caller chose.
|
|
311
|
-
export const statusFor = (code: string): number =>
|
|
312
|
-
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
|
|
313
|
-
|
|
314
|
-
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
315
|
-
export interface ErrorFacts {
|
|
316
|
-
readonly code: string;
|
|
317
|
-
readonly title: string;
|
|
318
|
-
readonly cause: string;
|
|
319
|
-
readonly fix: string;
|
|
320
|
-
readonly docs: string;
|
|
321
|
-
readonly status: number;
|
|
322
|
-
/** Present only when the process is in dev mode; never sent to a client in prod. */
|
|
323
|
-
readonly stack: string | undefined;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* One string field off the throwable, through core's `stringField`. The read is a getter call —
|
|
328
|
-
* or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
|
|
329
|
-
* place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
|
|
330
|
-
* the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
|
|
331
|
-
* renderings and `handle()` rejected against its own contract.
|
|
332
|
-
*/
|
|
333
|
-
const str = (source: unknown, key: string): string | undefined => {
|
|
334
|
-
const value = stringField(source, key);
|
|
335
|
-
return value !== undefined && value.length > 0 ? value : undefined;
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Normalises any throwable into the framework's error contract. Non-Ultimate
|
|
340
|
-
* throwables still get a code and a fix, because "errors are instructions" has to
|
|
341
|
-
* hold for the accidental `TypeError` too.
|
|
342
|
-
*/
|
|
343
|
-
export const factsOf = (error: unknown): ErrorFacts => {
|
|
344
|
-
const code = str(error, 'code') ?? 'X_INTERNAL';
|
|
345
|
-
// The error's own title first: every `UltimateError` resolves one from the code registry at
|
|
346
|
-
// construction, so this renders the OWNING package's title — including the codes http only
|
|
347
|
-
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
|
|
348
|
-
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
|
|
349
|
-
const title =
|
|
350
|
-
str(error, 'title') ??
|
|
351
|
-
// `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
|
|
352
|
-
// function off the prototype and put it in `title`, which is rendered into the problem
|
|
353
|
-
// document and the terminal.
|
|
354
|
-
(Object.hasOwn(HTTP_ERROR_TITLES, code)
|
|
355
|
-
? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
|
|
356
|
-
: undefined) ??
|
|
357
|
-
str(error, 'message') ??
|
|
358
|
-
'unhandled server error';
|
|
359
|
-
// The last fallback is the only one that touches the throwable whole, and every throwable a
|
|
360
|
-
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
|
|
361
|
-
// took the request down took the 500 renderer with it and the server had nothing left to send.
|
|
362
|
-
const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
|
|
363
|
-
return {
|
|
364
|
-
code,
|
|
365
|
-
title,
|
|
366
|
-
cause,
|
|
367
|
-
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
|
|
368
|
-
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
369
|
-
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
370
|
-
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
371
|
-
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
|
|
372
|
-
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
|
|
373
|
-
// resolved this at construction, so the fallback only fires for a throwable the framework did
|
|
374
|
-
// not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
|
|
375
|
-
// 404 on every problem document this package has ever rendered.
|
|
376
|
-
docs: str(error, 'docs') ?? ERROR_DOCS_URL,
|
|
377
|
-
status: statusFor(code),
|
|
378
|
-
stack: str(error, 'stack'),
|
|
379
|
-
};
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
|
|
384
|
-
*
|
|
385
|
-
* The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
|
|
386
|
-
* `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
|
|
387
|
-
* HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
|
|
388
|
-
* same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
|
|
389
|
-
* both told the caller to come back and never said when — which is the shed-with-no-delay pattern
|
|
390
|
-
* the `admit` stage exists to avoid, one layer in.
|
|
391
|
-
*
|
|
392
|
-
* Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
|
|
393
|
-
* not build, and it is read in the frame that decides what the caller sees.
|
|
394
|
-
*/
|
|
395
|
-
export function retryAfterOf(error: unknown): number | undefined {
|
|
396
|
-
if (typeof error !== 'object' || error === null) return undefined;
|
|
397
|
-
try {
|
|
398
|
-
const meta: unknown = (error as Record<string, unknown>)['meta'];
|
|
399
|
-
if (typeof meta !== 'object' || meta === null) return undefined;
|
|
400
|
-
const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
|
|
401
|
-
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
402
|
-
// At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
|
|
403
|
-
// as "retry now", which is the stampede a Retry-After exists to spread.
|
|
404
|
-
return Math.max(1, Math.ceil(seconds));
|
|
405
|
-
} catch {
|
|
406
|
-
return undefined;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
|
|
412
|
-
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
|
|
413
|
-
* to read about it, and those stopped being the same string when `docs` became one wiki page for
|
|
414
|
-
* every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
|
|
415
|
-
* forbidden the same identifier, which is the one thing a `type` may not do.
|
|
416
|
-
*
|
|
417
|
-
* A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
|
|
418
|
-
* — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
|
|
419
|
-
* `code` carries the same string as a plain member for a reader that would rather not parse a URI.
|
|
420
|
-
*/
|
|
421
|
-
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
|
|
422
|
-
|
|
423
|
-
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
|
|
424
|
-
export interface ProblemDocument {
|
|
425
|
-
readonly type: string;
|
|
426
|
-
readonly title: string;
|
|
427
|
-
readonly status: number;
|
|
428
|
-
readonly detail: string;
|
|
429
|
-
readonly instance: string | undefined;
|
|
430
|
-
readonly code: string;
|
|
431
|
-
readonly cause: string;
|
|
432
|
-
readonly fix: string;
|
|
433
|
-
readonly docs: string;
|
|
434
|
-
readonly requestId: string | undefined;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/** The title a caller gets for a failure the framework cannot name. */
|
|
438
|
-
const INTERNAL_TITLE = 'unhandled server error';
|
|
439
|
-
|
|
440
|
-
/**
|
|
441
|
-
* The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
|
|
442
|
-
* falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
|
|
443
|
-
* absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
|
|
444
|
-
* out of exactly this and said so in its header; the two audiences then disagreed about one
|
|
445
|
-
* condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
|
|
446
|
-
* reports every 5xx to the error monitor, both keyed by the request id below.
|
|
447
|
-
*/
|
|
448
|
-
const INTERNAL_CAUSE =
|
|
449
|
-
'the server failed while handling this request; the details are in this process\u2019s logs and ' +
|
|
450
|
-
'error reports, under this request id';
|
|
451
|
-
|
|
452
421
|
/**
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
422
|
+
* The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
|
|
423
|
+
* nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
|
|
424
|
+
* "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
|
|
425
|
+
* reads to decide whether a 5xx may carry the throwable's own words back to the caller.
|
|
457
426
|
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* `
|
|
462
|
-
*
|
|
427
|
+
* Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
428
|
+
* belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
429
|
+
* even if a future caller reaches the map some other way.
|
|
430
|
+
* `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
|
|
431
|
+
* prefer one for anything keyed by a value a caller chose.
|
|
463
432
|
*/
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
(code === 'X_INTERNAL' || (frameworkStatus(code) === undefined && !APP_ERROR_STATUS.has(code)));
|
|
433
|
+
export const declaredStatusFor = (code: string): number | undefined =>
|
|
434
|
+
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
|
|
467
435
|
|
|
468
|
-
export const
|
|
469
|
-
error: unknown,
|
|
470
|
-
meta: { instance?: string; requestId?: string; dev?: boolean } = {},
|
|
471
|
-
): ProblemDocument => {
|
|
472
|
-
const facts = factsOf(error);
|
|
473
|
-
const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
|
|
474
|
-
return {
|
|
475
|
-
type: problemTypeFor(facts.code),
|
|
476
|
-
title: opaque ? INTERNAL_TITLE : facts.title,
|
|
477
|
-
status: facts.status,
|
|
478
|
-
detail: opaque ? INTERNAL_CAUSE : facts.cause,
|
|
479
|
-
instance: meta.instance,
|
|
480
|
-
code: facts.code,
|
|
481
|
-
cause: opaque ? INTERNAL_CAUSE : facts.cause,
|
|
482
|
-
fix: facts.fix,
|
|
483
|
-
docs: facts.docs,
|
|
484
|
-
requestId: meta.requestId,
|
|
485
|
-
};
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
/** The exact three lines the terminal prints, reused by the overlay and `--json`. */
|
|
489
|
-
export const renderErrorLines = (error: unknown): string => {
|
|
490
|
-
const facts = factsOf(error);
|
|
491
|
-
// The newlines here are the format's own. Every interpolated field goes through `singleLine`
|
|
492
|
-
// so a caller-controlled value cannot add a third one — this string is rendered into the dev
|
|
493
|
-
// overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
|
|
494
|
-
return [
|
|
495
|
-
`${singleLine(facts.code)}: ${singleLine(facts.title)}`,
|
|
496
|
-
` cause: ${singleLine(facts.cause)}`,
|
|
497
|
-
` fix: ${singleLine(facts.fix)}`,
|
|
498
|
-
].join('\n');
|
|
499
|
-
};
|
|
436
|
+
export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
|
package/src/errors.ts
CHANGED
|
@@ -30,9 +30,12 @@ export const HTTP_OWNED_ERROR_CODES = [
|
|
|
30
30
|
'X_RATE_LIMIT_SCOPE_UNSET',
|
|
31
31
|
'X_RATE_LIMIT_INVALID',
|
|
32
32
|
'X_RATE_LIMIT_STORE_UNAVAILABLE',
|
|
33
|
+
'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
|
|
33
34
|
'X_TRUST_PROXY_UNSET',
|
|
34
35
|
'X_OVERLOADED',
|
|
35
36
|
'X_CSRF_BLOCKED',
|
|
37
|
+
'X_WEBHOOK_SIGNATURE_INVALID',
|
|
38
|
+
'X_WEBHOOK_SIGNATURE_STALE',
|
|
36
39
|
] as const;
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -83,9 +86,12 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
|
83
86
|
X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
|
|
84
87
|
X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
|
|
85
88
|
X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
|
|
89
|
+
X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
|
|
86
90
|
X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
|
|
87
91
|
X_OVERLOADED: 'in-flight requests are at the configured ceiling',
|
|
88
92
|
X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
|
|
93
|
+
X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
|
|
94
|
+
X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
|
|
89
95
|
};
|
|
90
96
|
|
|
91
97
|
// Registered at module load, unconditionally, in one call, so core's registry renders OUR title
|
|
@@ -289,7 +295,7 @@ export const corsConfigInvalid = (reason: string): HttpError =>
|
|
|
289
295
|
new HttpError({
|
|
290
296
|
code: 'X_CORS_CONFIG_INVALID',
|
|
291
297
|
cause: `cors config rejected: ${reason}`,
|
|
292
|
-
fix: "
|
|
298
|
+
fix: "call configureHttp({ cors: { credentials: false } }) at module scope in a file under apps/*/, or replace origins: ['*'] with the exact origins allowed to call this app",
|
|
293
299
|
});
|
|
294
300
|
|
|
295
301
|
/**
|
|
@@ -303,7 +309,7 @@ export const cspDirectiveInvalid = (where: string, value: string): HttpError =>
|
|
|
303
309
|
new HttpError({
|
|
304
310
|
code: 'X_CSP_DIRECTIVE_INVALID',
|
|
305
311
|
cause: `${where} is not a csp token: ${JSON.stringify(value)}`,
|
|
306
|
-
fix: 'in
|
|
312
|
+
fix: 'in the configureHttp({ security: { csp: { extend } } }) call write one entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon',
|
|
307
313
|
});
|
|
308
314
|
|
|
309
315
|
export const routeConflict = (path: string, detail: string): HttpError =>
|
|
@@ -325,7 +331,7 @@ export const trustProxyUnset = (): HttpError =>
|
|
|
325
331
|
code: 'X_TRUST_PROXY_UNSET',
|
|
326
332
|
cause:
|
|
327
333
|
'http.trustProxy is true and http.trustedProxyHops is not set, so x-forwarded-for would be read from a position the client controls',
|
|
328
|
-
fix: 'in
|
|
334
|
+
fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
|
|
329
335
|
});
|
|
330
336
|
|
|
331
337
|
/**
|
|
@@ -351,7 +357,7 @@ export const overloaded = (inflight: number, ceiling: number): HttpError =>
|
|
|
351
357
|
new HttpError({
|
|
352
358
|
code: 'X_OVERLOADED',
|
|
353
359
|
cause: `${inflight} requests are already in flight and http.maxInflight is ${ceiling}`,
|
|
354
|
-
fix: 'retry after the Retry-After header; to serve more at once
|
|
360
|
+
fix: 'retry after the Retry-After header; to serve more at once call configureHttp({ maxInflight: 2000 }) at module scope in a file under apps/*/, and add replicas to match',
|
|
355
361
|
});
|
|
356
362
|
|
|
357
363
|
/**
|
|
@@ -362,7 +368,7 @@ export const csrfBlocked = (pathname: string, reason: string): HttpError =>
|
|
|
362
368
|
new HttpError({
|
|
363
369
|
code: 'X_CSRF_BLOCKED',
|
|
364
370
|
cause: `${pathname} refused a credentialed write: ${reason}`,
|
|
365
|
-
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to
|
|
371
|
+
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
|
|
366
372
|
});
|
|
367
373
|
|
|
368
374
|
/**
|
|
@@ -374,6 +380,40 @@ export const requestTimedOut = (method: string, pathname: string, timeoutMs: num
|
|
|
374
380
|
new HttpError({
|
|
375
381
|
code: 'X_TIMEOUT',
|
|
376
382
|
cause: `${method} ${pathname} did not finish within ${timeoutMs}ms`,
|
|
377
|
-
fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or
|
|
383
|
+
fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or call configureHttp({ requestTimeoutMs: 60_000 }) at module scope in a file under apps/*/',
|
|
378
384
|
meta: { timeoutMs },
|
|
379
385
|
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The inbound delivery is not signed by the holder of this route's secret — a wrong secret, a
|
|
389
|
+
* body something rewrote in transit, or a header this format does not define.
|
|
390
|
+
*
|
|
391
|
+
* 401 rather than 400: the request is well formed and carried a CREDENTIAL, and the credential is
|
|
392
|
+
* what failed. Rather than 403, which means an authenticated caller was refused, and there is no
|
|
393
|
+
* authenticated caller here. `reason` names only what the framework chose — never the signature
|
|
394
|
+
* that arrived, never the secret, and never the body — because a `cause` reaches both the caller
|
|
395
|
+
* and the log store, and a credential in either is a leak wearing a diagnostic's clothes.
|
|
396
|
+
*/
|
|
397
|
+
export const webhookSignatureInvalid = (pathname: string, reason: string): HttpError =>
|
|
398
|
+
new HttpError({
|
|
399
|
+
code: 'X_WEBHOOK_SIGNATURE_INVALID',
|
|
400
|
+
cause: `${pathname} refused an inbound webhook: ${reason}`,
|
|
401
|
+
fix: 'sign the delivery with the secret this endpoint was registered under, or re-read the secret from your sender dashboard and pass it as verifyWebhookSignature(request, { secret })',
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Signed correctly, and outside the replay window. Its own code because the repair is a different
|
|
406
|
+
* one: a sender's clock, or a delivery being replayed off a capture. Same 401 — the credential is
|
|
407
|
+
* a TIMESTAMPED one, and this is the expiry half of it.
|
|
408
|
+
*/
|
|
409
|
+
export const webhookSignatureStale = (
|
|
410
|
+
pathname: string,
|
|
411
|
+
skewMs: number,
|
|
412
|
+
toleranceMs: number,
|
|
413
|
+
): HttpError =>
|
|
414
|
+
new HttpError({
|
|
415
|
+
code: 'X_WEBHOOK_SIGNATURE_STALE',
|
|
416
|
+
cause: `${pathname} received a valid signature ${skewMs}ms from this clock, and the window is ${toleranceMs}ms`,
|
|
417
|
+
fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that',
|
|
418
|
+
meta: { skewMs, toleranceMs },
|
|
419
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
// listed here is an implementation detail and may change without a major bump.
|
|
3
3
|
|
|
4
4
|
export type { RenderMode } from '@ultimat3/core';
|
|
5
|
+
// The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
|
|
6
|
+
// the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
|
|
7
|
+
// one, and neither may import the other. Re-exported here so a receiver route needs one import.
|
|
8
|
+
export {
|
|
9
|
+
isCanonicalWebhookField,
|
|
10
|
+
WEBHOOK_ID_HEADER,
|
|
11
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
12
|
+
WEBHOOK_SIGNATURE_VERSION,
|
|
13
|
+
WEBHOOK_TOPIC_HEADER,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
|
|
16
|
+
export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
|
|
5
17
|
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
6
18
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
7
19
|
export { defineHttpConfig, stripBasePath } from './config';
|
|
@@ -24,18 +36,20 @@ export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
|
24
36
|
export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
|
|
25
37
|
export type { Deadline } from './deadline';
|
|
26
38
|
export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
|
|
27
|
-
export type { ErrorFacts, ProblemDocument } from './error-
|
|
39
|
+
export type { ErrorFacts, ProblemDocument } from './error-facts';
|
|
28
40
|
export {
|
|
29
|
-
DEFAULT_STATUS,
|
|
30
|
-
ERROR_STATUS,
|
|
31
41
|
factsOf,
|
|
32
42
|
problemTypeFor,
|
|
33
|
-
registerErrorStatus,
|
|
34
43
|
renderErrorLines,
|
|
35
|
-
resetErrorStatus,
|
|
36
44
|
retryAfterOf,
|
|
37
|
-
statusFor,
|
|
38
45
|
toProblem,
|
|
46
|
+
} from './error-facts';
|
|
47
|
+
export {
|
|
48
|
+
DEFAULT_STATUS,
|
|
49
|
+
ERROR_STATUS,
|
|
50
|
+
registerErrorStatus,
|
|
51
|
+
resetErrorStatus,
|
|
52
|
+
statusFor,
|
|
39
53
|
} from './error-map';
|
|
40
54
|
export type {
|
|
41
55
|
ErrorPageAction,
|
|
@@ -73,6 +87,8 @@ export {
|
|
|
73
87
|
serverNotStarted,
|
|
74
88
|
trustProxyUnset,
|
|
75
89
|
unauthenticated,
|
|
90
|
+
webhookSignatureInvalid,
|
|
91
|
+
webhookSignatureStale,
|
|
76
92
|
} from './errors';
|
|
77
93
|
export type { ForwardedInput, ForwardedSplit } from './forwarded';
|
|
78
94
|
export {
|
|
@@ -107,6 +123,7 @@ export type {
|
|
|
107
123
|
RateLimiter,
|
|
108
124
|
RateLimitKeyParts,
|
|
109
125
|
RateLimitScope,
|
|
126
|
+
RateLimitSpend,
|
|
110
127
|
RateLimitStore,
|
|
111
128
|
} from './rate-limit';
|
|
112
129
|
export {
|
|
@@ -116,8 +133,9 @@ export {
|
|
|
116
133
|
DEFAULT_RATE_LIMIT,
|
|
117
134
|
memoryRateLimitStore,
|
|
118
135
|
rateLimitDecision,
|
|
119
|
-
|
|
136
|
+
rateLimitSpends,
|
|
120
137
|
resolveRateLimitConfig,
|
|
138
|
+
TENANT_SCOPE,
|
|
121
139
|
toBucket,
|
|
122
140
|
} from './rate-limit';
|
|
123
141
|
export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
@@ -129,6 +147,7 @@ export {
|
|
|
129
147
|
rateLimitNotShared,
|
|
130
148
|
rateLimitScopeUnset,
|
|
131
149
|
rateLimitStoreUnavailable,
|
|
150
|
+
tenantBucketUnknown,
|
|
132
151
|
} from './rate-limit-errors';
|
|
133
152
|
export type {
|
|
134
153
|
PgExecutor,
|
|
@@ -185,3 +204,9 @@ export { createServer } from './server';
|
|
|
185
204
|
export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
|
|
186
205
|
export type { InferOutput, Schema, ValidationOutcome } from './validate';
|
|
187
206
|
export { formatIssue, validate, validateSync } from './validate';
|
|
207
|
+
export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
|
|
208
|
+
export {
|
|
209
|
+
DEFAULT_WEBHOOK_BODY_LIMIT,
|
|
210
|
+
DEFAULT_WEBHOOK_TOLERANCE_MS,
|
|
211
|
+
verifyWebhookSignature,
|
|
212
|
+
} from './webhook-verify';
|
package/src/overlay.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `--json` emits, so a code/cause/fix string can never differ between the three
|
|
3
3
|
// surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
|
|
4
4
|
// error contract, not UI copy, so they are not routed through the i18n catalog.
|
|
5
|
-
import { factsOf, renderErrorLines, toProblem } from './error-
|
|
5
|
+
import { factsOf, renderErrorLines, toProblem } from './error-facts';
|
|
6
6
|
import { acceptsHtml, escapeHtml } from './html-render';
|
|
7
7
|
import { OVERLAY_STYLE } from './overlay-style';
|
|
8
8
|
import { html } from './response';
|
package/src/pipeline.ts
CHANGED
|
@@ -237,6 +237,10 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
|
|
|
237
237
|
// address — an identity from an untrusted hop authenticates, which is worse than none.
|
|
238
238
|
peer: peerIdentity(forwarded),
|
|
239
239
|
signal: deadline.signal,
|
|
240
|
+
// The number behind that signal. `traceHeaders()` in core reads it off the ambient
|
|
241
|
+
// context, so every outbound hop this request makes carries what is LEFT of the budget
|
|
242
|
+
// rather than letting the next service start a fresh one of its own.
|
|
243
|
+
deadlineAt: deadline.deadlineAt,
|
|
240
244
|
// The context is what app code reaches through core's ALS; without the inbound headers
|
|
241
245
|
// on it, a cookie the server itself set could never be read back on the next request,
|
|
242
246
|
// and `ctx.session` had no way to exist.
|