@ultimat3/http 22.15.0 → 23.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 CHANGED
@@ -203,7 +203,7 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
203
203
  | `navigation.ts` | the client router's server half: the gate `context` runs after the match (a prefetch to a route whose `meta.navigation` did not opt in → `204`; a soft GET to anything but a page of the router's `<app>:<surface>` → `204` + `x-ultimate-location`, before auth or app code) and the `response` stage's rewrite of a 3xx to a router request into the same hand-over, cookies kept |
204
204
  | `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
205
205
  | `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document (including the issue list and the opacity rule over it), the three terminal lines |
206
- | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |
206
+ | `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices`, `errorPage`, `explainMiss` (asked ONLY on a `not-found` match, synchronously, with the request line alone; its error replaces `X_ROUTE_NOT_FOUND`) + the app's `configureAuthenticator()` |
207
207
  | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |
208
208
  | `overlay.ts` | the dev error page: the same code/cause/fix as the terminal, plus any notices |
209
209
  | `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
package/README.md CHANGED
@@ -303,6 +303,9 @@ exactly three things. Apps declare it through `defineApi({ http: { mounts } })`
303
303
 
304
304
  `RouteMeta.authenticate` is the mechanism: a route that states one is reached only through it.
305
305
 
306
+ For API keys, `resolveToken` is `@ultimat3/auth`'s `apiKeyResolver(() => store)` — a live key is
307
+ `{ actor, scopes }`, every wrong one is the same `null`, and a store fault stays a throw.
308
+
306
309
  ## Inbound webhooks
307
310
 
308
311
  `verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
@@ -439,3 +442,9 @@ Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ult
439
442
  tiers 0 and 1, which is the whole rule. Authentication and policy evaluation arrive through
440
443
  `ServerHooks`, declared structurally, because `@ultimat3/policy` is a sibling tier. There is no
441
444
  plugin API: `Middleware` wraps a handler, the pipeline is everything else.
445
+
446
+ `hooks.explainMiss(method, pathname)` is the same kind of seam for a request the router matched
447
+ nothing for: the error it returns is thrown in place of `X_ROUTE_NOT_FOUND`, `undefined` keeps the
448
+ plain miss. Synchronous, given only the request line, never asked about a matched route or a 405.
449
+ `@ultimat3/action` answers it for a path that is one of its actions under a `pathStyle` this server
450
+ does not serve (`X_CONTRACT_DRIFT`, 404).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "22.15.0",
3
+ "version": "23.0.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,9 +32,9 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/core": "22.15.0",
35
- "@ultimat3/i18n": "22.15.0",
36
- "@ultimat3/schema": "22.15.0",
37
- "@ultimat3/time": "22.15.0"
35
+ "@ultimat3/core": "23.0.0",
36
+ "@ultimat3/i18n": "23.0.0",
37
+ "@ultimat3/schema": "23.0.0",
38
+ "@ultimat3/time": "23.0.0"
38
39
  }
39
40
  }
package/src/error-map.ts CHANGED
@@ -103,6 +103,12 @@ export const ERROR_STATUS = {
103
103
  // it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
104
104
  // and the `error-map` stage reported the caller's mistake to the on-call monitor.
105
105
  X_INPUT_INVALID: 400,
106
+ // The one way a SERVER answers this code: a POST to the path an action has under a path style
107
+ // this app does not serve (`hooks.explainMiss`). 404 because it is still a miss — nothing is
108
+ // routed there and nothing ran, so the status a proxy or a monitor counts is the one the plain
109
+ // `X_ROUTE_NOT_FOUND` had; the code and the `fix:` are what changed. Every other raise of it is
110
+ // client-side or a contract test, and never reaches this table.
111
+ X_CONTRACT_DRIFT: 404,
106
112
  // A retried `Idempotency-Key` naming a different payload, or one still in flight. 409 because
107
113
  // that is what the action's own OpenAPI operation publishes for it — the runtime answered 500
108
114
  // while the document promised 409, and a client written against the spec read the framework
@@ -215,6 +221,10 @@ export const ERROR_STATUS = {
215
221
  // 500, the same shelf as `X_SEARCH_UNDECLARED`: a column with no machine is a declaration the
216
222
  // app has not written, and no request changes that.
217
223
  X_STATE_UNDECLARED: 500,
224
+ // A sealed column named where the database would compare it, or in a view. Both are the app's
225
+ // own declaration or call, never the caller's input — the same shelf as the two above.
226
+ X_ENTITY_SEALED_PREDICATE: 500,
227
+ X_ENTITY_SEALED_IN_VIEW: 500,
218
228
  // @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
219
229
  // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
220
230
  // same event one layer up — is 409 above; a foreign key rides with it because both halves of it
@@ -313,6 +323,12 @@ export const ERROR_STATUS = {
313
323
  // reaching `formatDate` / `formatMoney` / `describeCron`. Those raised it and answered 500,
314
324
  // paging the on-call for a string the caller typed.
315
325
  X_LOCALE_INVALID: 400,
326
+ // @ultimat3/core — `seal()` / `open()`. All three are reachable from a request (a sealed column
327
+ // read inside an action) and none is the caller's doing: the deploy has no key, the ring lost a
328
+ // retired key, or a stored value did not authenticate. 500, stated rather than defaulted.
329
+ X_SEAL_INVALID: 500,
330
+ X_SEAL_KEY_MISSING: 500,
331
+ X_SEAL_KEY_UNKNOWN: 500,
316
332
  // @ultimat3/money — a well-formed code this process carries no row for. The currency table is
317
333
  // OPEN (`registerCurrency`), and every surface between the wire and the throw accepts any
318
334
  // `^[A-Z]{3}$`: `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the OpenAPI `pattern` emitted from
@@ -457,6 +473,12 @@ export const ERROR_STATUS = {
457
473
  X_RECORD_REJECTED: 500,
458
474
  X_SYNC_UNCONFIGURED: 500,
459
475
  X_INTERNAL: 500,
476
+ // @ultimat3/jobs — the job is running and cannot be removed
477
+ X_JOB_NOT_REMOVABLE: 409,
478
+ // @ultimat3/jobs — the job is not waiting on its run time
479
+ X_JOB_NOT_PROMOTABLE: 409,
480
+ // @ultimat3/jobs — a job list page was asked for outside its bounds
481
+ X_JOB_PAGE_INVALID: 400,
460
482
  // The keys are LITERAL — deliberately not `Readonly<Record<string, number>>`, which is what the
461
483
  // annotation used to say. This table is the closed one, so `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
462
484
  // has to be a compile error rather than an `undefined` a test then asserts `toBeNumber()` on.
package/src/hooks.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // be imported here), and — a seam of a different kind, deciding nothing — what a dev diagnostic
4
4
  // found. All three are declared structurally, which keeps the import boundary intact and keeps
5
5
  // the pipeline testable.
6
- import type { Actor } from '@ultimat3/core';
6
+ import type { Actor, UltimateError } from '@ultimat3/core';
7
7
  import type { RequestContext } from './context';
8
8
  import type { OverlayNotice } from './overlay';
9
9
  import type { UltimateRequest } from './request';
@@ -51,6 +51,17 @@ export interface ServerHooks {
51
51
  status: number,
52
52
  ctx: RequestContext,
53
53
  ) => Promise<string | undefined> | string | undefined;
54
+ /**
55
+ * Why nothing is routed at this path, when a higher tier knows more than "no route". Consulted
56
+ * ONLY on a miss — never for a matched route, a refused method or an unreadable path — and the
57
+ * error it returns is thrown in place of `X_ROUTE_NOT_FOUND`; `undefined` keeps the plain miss.
58
+ * `@ultimat3/action` answers here for a path that is one of its actions under a path style this
59
+ * server does not serve.
60
+ *
61
+ * Synchronous and given only the request line: a miss is reached by any anonymous caller, so
62
+ * what runs on it must not read a body, a session or a database.
63
+ */
64
+ readonly explainMiss?: (method: string, pathname: string) => UltimateError | undefined;
54
65
  }
55
66
 
56
67
  export type Authenticator = NonNullable<ServerHooks['authenticate']>;
package/src/stages.ts CHANGED
@@ -181,7 +181,9 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
181
181
  if (routed instanceof Response) return routed;
182
182
  const match = matchRoute(input.table, ctx.method, routed);
183
183
  if (!match.ok) {
184
- if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
184
+ if (match.reason === 'not-found') {
185
+ throw hooks.explainMiss?.(ctx.method, pathname) ?? routeNotFound(ctx.method, pathname);
186
+ }
185
187
  if (match.reason === 'path-invalid') throw pathInvalid(pathname, match.segment);
186
188
  ctx.headers.set('allow', match.allow.join(', '));
187
189
  throw methodNotAllowed(ctx.method, pathname, match.allow);