@ultimat3/action 1.2.0 → 3.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 +404 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +92 -0
- package/src/audit.ts +123 -0
- package/src/cache-gate.ts +32 -0
- package/src/client.ts +67 -13
- package/src/contract-test.ts +82 -13
- package/src/deprecation.ts +82 -0
- package/src/errors.ts +276 -3
- package/src/http.ts +90 -13
- package/src/idempotency-key.ts +47 -0
- package/src/idempotency-memory.ts +148 -0
- package/src/idempotency-postgres.ts +271 -0
- package/src/idempotency.ts +157 -48
- package/src/index.ts +81 -5
- package/src/invoke.ts +155 -10
- package/src/job-handle.ts +22 -3
- package/src/json-schema.ts +31 -13
- package/src/mcp-tool.ts +18 -4
- package/src/mutator.ts +8 -0
- package/src/naming.ts +7 -7
- package/src/policy-gate.ts +14 -2
- package/src/registry.ts +52 -1
- package/src/sample-input.ts +177 -0
- package/src/stable.ts +55 -20
- package/src/type-pins.ts +45 -0
- package/src/tags.ts +0 -17
package/src/errors.ts
CHANGED
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
* Every failure @ultimat3/action can produce, one subclass per stable code so
|
|
3
3
|
* callers `instanceof` a specific failure instead of string-matching a message.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
assertNever,
|
|
7
|
+
ERROR_DOCS_BASE,
|
|
8
|
+
errorDocsUrl,
|
|
9
|
+
hasErrorCode,
|
|
10
|
+
registerErrorCodes,
|
|
11
|
+
UltimateError,
|
|
12
|
+
} from '@ultimat3/core';
|
|
6
13
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
14
|
+
// Type-only: `idempotency.ts` imports the error classes below, and a runtime edge here would
|
|
15
|
+
// close the cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
|
|
16
|
+
import type { IdempotencyFailure } from './idempotency';
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
// Core's spelling, aliased — never a second one. A local template drifts from what
|
|
19
|
+
// `x errors explain` prints the moment `ERROR_DOCS_BASE` moves.
|
|
20
|
+
const docs = errorDocsUrl;
|
|
9
21
|
|
|
10
22
|
/**
|
|
11
23
|
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
@@ -15,11 +27,20 @@ const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;
|
|
|
15
27
|
*/
|
|
16
28
|
const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
17
29
|
X_ACTION_DUPLICATE: 'two actions are registered under one name',
|
|
30
|
+
X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
|
|
31
|
+
X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
|
|
32
|
+
X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
|
|
33
|
+
X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
|
|
18
34
|
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
|
|
19
35
|
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
|
|
20
36
|
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
|
|
21
37
|
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
|
|
22
38
|
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
|
|
39
|
+
X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
|
|
40
|
+
X_IDEMPOTENCY_NOT_SHARED:
|
|
41
|
+
'idempotency is declared fleet-wide and the installed store is per-process',
|
|
42
|
+
X_IDEMPOTENCY_REPLAYED_FAILURE:
|
|
43
|
+
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
23
44
|
X_INPUT_INVALID: 'input failed schema validation',
|
|
24
45
|
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
25
46
|
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
@@ -119,12 +140,36 @@ export class ActionDuplicateError extends UltimateError {
|
|
|
119
140
|
}
|
|
120
141
|
}
|
|
121
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Two distinct action names, one derived route. `X_ACTION_DUPLICATE` guards the NAME; nothing
|
|
145
|
+
* guarded the path, so `archiveOrder` and `archiveOrders` both registered, both projected to
|
|
146
|
+
* `POST /api/orders/archive`, and whichever the router seated last silently shadowed the other —
|
|
147
|
+
* while the shadowed action's OpenAPI operation and MCP tool went on advertising it.
|
|
148
|
+
*/
|
|
149
|
+
export class ActionPathDuplicateError extends UltimateError {
|
|
150
|
+
constructor(input: { name: string; existing: string; path: string }) {
|
|
151
|
+
super({
|
|
152
|
+
code: 'X_ACTION_PATH_DUPLICATE',
|
|
153
|
+
cause: `actions "${input.name}" and "${input.existing}" both derive ${input.path}`,
|
|
154
|
+
fix: `rename one export so the two derive different paths — x actions list --json prints every derived route`,
|
|
155
|
+
docs: docs('X_ACTION_PATH_DUPLICATE'),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The action's NAME goes in the cause and the permission's SHAPE goes in the fix — the two are not
|
|
162
|
+
* interchangeable. `can()` takes `resource:verb` (`Permission` is `` `${string}:${string}` ``), so
|
|
163
|
+
* the `can('<the action name>')` this used to emit was a snippet that could not compile, and
|
|
164
|
+
* `assertPermission` would refuse it once the app declared its own set. Same split
|
|
165
|
+
* `@ultimat3/policy`'s own `policyMissing()` makes, and the same repair `X_QUERY_POLICY_MISSING` took.
|
|
166
|
+
*/
|
|
122
167
|
export class ActionPolicyMissingError extends UltimateError {
|
|
123
168
|
constructor(name: string) {
|
|
124
169
|
super({
|
|
125
170
|
code: 'X_ACTION_POLICY_MISSING',
|
|
126
171
|
cause: `action "${name}" was registered without a policy`,
|
|
127
|
-
fix: `add \`policy: can('${name}
|
|
172
|
+
fix: `add \`policy: can('<resource>:<verb>')\` to the action() that exports "${name}" — a permission your definePermissions() call declares, never the action's own name — or \`allow('<resource>:<verb>')\` if the operation is genuinely public`,
|
|
128
173
|
docs: docs('X_ACTION_POLICY_MISSING'),
|
|
129
174
|
});
|
|
130
175
|
}
|
|
@@ -176,6 +221,179 @@ export class IdempotencyConflictError extends UltimateError {
|
|
|
176
221
|
}
|
|
177
222
|
}
|
|
178
223
|
|
|
224
|
+
export type IdempotencyKeyProblem = 'empty' | 'too-long';
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The header arrived and cannot name one request. Refused, never read as absent: `Headers.get()`
|
|
228
|
+
* answers `''` for `Idempotency-Key:` rather than `null`, so a blank value became a live key that
|
|
229
|
+
* every caller sending a blank header shared — and reading it as "no key" is the quieter failure,
|
|
230
|
+
* because a client whose key interpolation produced nothing would lose the protection silently
|
|
231
|
+
* and double-charge on its own retry. `@ultimat3/jobs` refuses an empty key at the enqueue for the
|
|
232
|
+
* same reason; it uses `assert` because the empty key there is the app's own declaration, while
|
|
233
|
+
* this one is a caller's header and therefore a 4xx.
|
|
234
|
+
*
|
|
235
|
+
* The length bound is the one the OpenAPI operation has always published (`maxLength: 255`).
|
|
236
|
+
* A contract that disagrees with the runtime is worse than no contract.
|
|
237
|
+
*/
|
|
238
|
+
export class IdempotencyKeyInvalidError extends UltimateError {
|
|
239
|
+
constructor(action: string, problem: IdempotencyKeyProblem, length: number) {
|
|
240
|
+
super({
|
|
241
|
+
code: 'X_IDEMPOTENCY_KEY_INVALID',
|
|
242
|
+
cause:
|
|
243
|
+
problem === 'empty'
|
|
244
|
+
? `action "${action}" was called with an empty Idempotency-Key, which every caller sending a blank header would share`
|
|
245
|
+
: `action "${action}" was called with an Idempotency-Key of ${length} characters, past the 255 its OpenAPI operation publishes`,
|
|
246
|
+
fix: 'set the Idempotency-Key header to a fresh crypto.randomUUID() on the client, one per request — or omit the header entirely to run this call without idempotency',
|
|
247
|
+
docs: docs('X_IDEMPOTENCY_KEY_INVALID'),
|
|
248
|
+
meta: { action, problem, length },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The deployment declared `scope: 'shared'` and the installed store cannot keep it. Refused at
|
|
255
|
+
* registration, before a socket opens, because the failure it replaces is silent and expensive:
|
|
256
|
+
* a per-process store under `replicas: 3` means the retry that lands on another replica finds no
|
|
257
|
+
* record, re-runs the handler, and charges the card again — with nothing anywhere saying it did.
|
|
258
|
+
* An UNDECLARED scope is refused the same way: what cannot be shown to be shared is not assumed
|
|
259
|
+
* to be, the rule `assertRouteBuckets` already applies to a limiter that publishes no table.
|
|
260
|
+
*/
|
|
261
|
+
export class IdempotencyNotSharedError extends UltimateError {
|
|
262
|
+
constructor(storeScope: string | undefined) {
|
|
263
|
+
super({
|
|
264
|
+
code: 'X_IDEMPOTENCY_NOT_SHARED',
|
|
265
|
+
cause:
|
|
266
|
+
storeScope === undefined
|
|
267
|
+
? "configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store declares no scope"
|
|
268
|
+
: `configureIdempotency({ scope: 'shared' }) is declared and the installed idempotency store is ${storeScope}`,
|
|
269
|
+
// NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` (it is a tagged template whose
|
|
270
|
+
// positional form is `unsafe`), so that line compiled and would have thrown on the first
|
|
271
|
+
// reservation. The framework's own boot already installs this store; a host booting the
|
|
272
|
+
// framework itself wraps the client it opened.
|
|
273
|
+
fix: "the framework boot installs a shared store — reach this only from a host that boots it itself: setIdempotencyStore(postgresIdempotencyStore({ executor: { query: (text, values) => client.query({ text, values }) } })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
|
|
274
|
+
docs: docs('X_IDEMPOTENCY_NOT_SHARED'),
|
|
275
|
+
meta: { storeScope: storeScope ?? null },
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guard()`
|
|
282
|
+
* and the input parse both run before the idempotency gate, so everything the gate can see throw
|
|
283
|
+
* is post-authorization and possibly post-commit — a handler that took the money and then failed
|
|
284
|
+
* its own `output:` schema is the case this exists for. Releasing the reservation there let the
|
|
285
|
+
* client's automatic retry charge a second time, which made idempotency the cause of the double
|
|
286
|
+
* charge it exists to prevent.
|
|
287
|
+
*
|
|
288
|
+
* The first attempt's code is re-used verbatim, the way `RemoteActionError` re-uses the server's:
|
|
289
|
+
* the caller is owed the failure it would have got, not a new one. `X_IDEMPOTENCY_REPLAYED_FAILURE`
|
|
290
|
+
* is the code only when the original throw carried none of its own.
|
|
291
|
+
*/
|
|
292
|
+
export class IdempotencyReplayedFailureError extends UltimateError {
|
|
293
|
+
/** The recorded first attempt, so a caller reads the original code without parsing a message. */
|
|
294
|
+
readonly failure: IdempotencyFailure;
|
|
295
|
+
|
|
296
|
+
constructor(key: string, failure: IdempotencyFailure | undefined) {
|
|
297
|
+
const recorded: IdempotencyFailure = failure ?? {
|
|
298
|
+
code: 'X_IDEMPOTENCY_REPLAYED_FAILURE',
|
|
299
|
+
cause: 'the first attempt under this key failed and the store kept no detail of it',
|
|
300
|
+
fix: 'read the first attempt in the logs, then send a fresh Idempotency-Key once the cause is fixed',
|
|
301
|
+
};
|
|
302
|
+
super({
|
|
303
|
+
code: recorded.code,
|
|
304
|
+
cause: `${recorded.cause} — replayed from the first attempt under Idempotency-Key "${key}", which may have committed before it failed`,
|
|
305
|
+
fix: recorded.fix,
|
|
306
|
+
...(recorded.docs === undefined ? {} : { docs: recorded.docs }),
|
|
307
|
+
// `replayed` is what tells an operator this is not a second execution: nothing ran here.
|
|
308
|
+
meta: { origin: 'idempotent-replay', key, replayed: true, code: recorded.code },
|
|
309
|
+
});
|
|
310
|
+
this.failure = recorded;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A `deprecated:` block whose dates cannot become the headers it promises. Refused where the
|
|
316
|
+
* declaration is converted, so the route and the OpenAPI operation refuse the same value — the
|
|
317
|
+
* same shape as `@ultimat3/http`'s `X_RATE_LIMIT_INVALID`, and for the same reason: a `Sunset` that
|
|
318
|
+
* renders `Invalid Date` is a contract statement no client can act on.
|
|
319
|
+
*/
|
|
320
|
+
export class ActionDeprecationInvalidError extends UltimateError {
|
|
321
|
+
constructor(action: string, field: string, value: string) {
|
|
322
|
+
super({
|
|
323
|
+
code: 'X_ACTION_DEPRECATION_INVALID',
|
|
324
|
+
cause: `action "${action}" declares deprecated.${field} as "${value}", which is not a date`,
|
|
325
|
+
fix: `edit \`deprecated: { ${field}: … }\` on ${action} to an ISO-8601 instant — e.g. '2026-12-31T23:59:59Z'`,
|
|
326
|
+
docs: docs('X_ACTION_DEPRECATION_INVALID'),
|
|
327
|
+
meta: { action, field, value },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** What the wire said, once `client.ts` has decided the body really is a problem document. */
|
|
333
|
+
export interface RemoteFailure {
|
|
334
|
+
/** The action that was called — `meta` carries it, so a report names the call, not just a code. */
|
|
335
|
+
readonly action: string;
|
|
336
|
+
readonly status: number;
|
|
337
|
+
/** The server's code, kept verbatim: matching on it is why problem+json carries one. */
|
|
338
|
+
readonly code: string;
|
|
339
|
+
readonly cause: string;
|
|
340
|
+
readonly fix: string;
|
|
341
|
+
/**
|
|
342
|
+
* The links the server offered, most specific first. Never synthesized from the code, and
|
|
343
|
+
* never trusted for being present — the first one that is an absolute HTTP(S) URL wins, so
|
|
344
|
+
* a `javascript:` in the preferred slot cannot suppress a usable link behind it.
|
|
345
|
+
*/
|
|
346
|
+
readonly docs?: readonly (string | undefined)[] | undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A link, not a string the server happened to put in a field the overlay renders as an href. */
|
|
350
|
+
const ABSOLUTE_HTTP_URL = /^https?:\/\//;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The docs link, or the honest absence of one. `UltimateError` resolves an unregistered code to
|
|
354
|
+
* `https://ultimate.dev/errors/<code>`, and a code the SERVER owns — `X_SIGNUP_CLOSED`, declared
|
|
355
|
+
* by the app through `registerErrorStatus` — is exactly the kind this bundle never registered:
|
|
356
|
+
* that URL is a 404 dressed as documentation, printed under `docs:` as if the framework wrote the
|
|
357
|
+
* page. So: the server's own link when it sent a resolvable one, this build's registered link
|
|
358
|
+
* when it knows the code, and otherwise the index — a page that exists.
|
|
359
|
+
*
|
|
360
|
+
* `sent` is ordered, not singular: a server that fills `docs` with a `javascript:` URI still
|
|
361
|
+
* sent RFC-9457's `type`, and taking the first *resolvable* candidate means the unusable one
|
|
362
|
+
* costs nothing. Testing only the preferred slot would have dropped a valid link on the floor.
|
|
363
|
+
*/
|
|
364
|
+
function remoteDocs(code: string, sent: readonly (string | undefined)[] = []): string | undefined {
|
|
365
|
+
const link = sent.find((value) => value !== undefined && ABSOLUTE_HTTP_URL.test(value));
|
|
366
|
+
if (link !== undefined) return link;
|
|
367
|
+
// `undefined` lets the constructor resolve the REGISTERED descriptor, whose docs a package
|
|
368
|
+
// may have declared as something other than the default URL.
|
|
369
|
+
return hasErrorCode(code) ? undefined : ERROR_DOCS_BASE;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A failure the SERVER raised, rebuilt from `application/problem+json` in the browser. The code
|
|
374
|
+
* is the server's — this package invents none, the same rule `ActionDeniedError` follows for a
|
|
375
|
+
* policy decision — but a code the server owns is one this bundle may never have registered, so
|
|
376
|
+
* the error says where it came from rather than passing as locally declared: `name` marks it in
|
|
377
|
+
* a stack trace and `meta.origin` marks it in `--json`, the dev overlay and the error reporter.
|
|
378
|
+
* `RpcFailedError` stays the answer when no framework code came back at all.
|
|
379
|
+
*/
|
|
380
|
+
export class RemoteActionError extends UltimateError {
|
|
381
|
+
override readonly name = 'RemoteActionError';
|
|
382
|
+
/** The status that carried it — typed, so a caller never digs it back out of `meta`. */
|
|
383
|
+
readonly status: number;
|
|
384
|
+
|
|
385
|
+
constructor(failure: RemoteFailure) {
|
|
386
|
+
super({
|
|
387
|
+
code: failure.code,
|
|
388
|
+
cause: failure.cause,
|
|
389
|
+
fix: failure.fix,
|
|
390
|
+
docs: remoteDocs(failure.code, failure.docs),
|
|
391
|
+
meta: { origin: 'remote', action: failure.action, status: failure.status },
|
|
392
|
+
});
|
|
393
|
+
this.status = failure.status;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
179
397
|
/** The client got a non-`problem+json` failure — a proxy, not our server, answered. */
|
|
180
398
|
export class RpcFailedError extends UltimateError {
|
|
181
399
|
constructor(name: string, status: number) {
|
|
@@ -188,6 +406,61 @@ export class RpcFailedError extends UltimateError {
|
|
|
188
406
|
}
|
|
189
407
|
}
|
|
190
408
|
|
|
409
|
+
/**
|
|
410
|
+
* `audit: true` with no sink installed. Raised before the input parse, so an action nobody can
|
|
411
|
+
* record has made no write to be inconsistent about — the one audit failure that costs nothing.
|
|
412
|
+
* There is deliberately no logger-backed default sink to fall back on: a line nobody stores
|
|
413
|
+
* satisfies the declaration while recording nothing, which is the silent pass this code exists
|
|
414
|
+
* to turn into a stop.
|
|
415
|
+
*/
|
|
416
|
+
export class AuditSinkMissingError extends UltimateError {
|
|
417
|
+
constructor(action: string) {
|
|
418
|
+
super({
|
|
419
|
+
code: 'X_AUDIT_SINK_MISSING',
|
|
420
|
+
cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
|
|
421
|
+
fix: "call setAuditSink(yourSink) from '@ultimat3/action' at boot, before registerActions()",
|
|
422
|
+
docs: docs('X_AUDIT_SINK_MISSING'),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The sink refused a record for an attempt that SUCCEEDED. The opposite call to the cache tier's
|
|
429
|
+
* `bestEffort`, and for the opposite reason: a dropped cache entry expires by TTL and the stack
|
|
430
|
+
* self-heals, while nothing ever re-derives an audit row that was never written. So the caller is
|
|
431
|
+
* told rather than left believing the operation was recorded.
|
|
432
|
+
*
|
|
433
|
+
* The cause says what the fix cannot undo: the handler already committed.
|
|
434
|
+
*
|
|
435
|
+
* **The fix branches, because only one of the two is true.** "Retry with the same
|
|
436
|
+
* Idempotency-Key" is safe exactly when this invocation went through the idempotency store —
|
|
437
|
+
* the settled record is then replayed and the record is re-attempted without re-running the
|
|
438
|
+
* handler. It did not when the action is not `idempotent`, and it did not when the action IS
|
|
439
|
+
* `idempotent` and the caller sent no key: `invoke` reads
|
|
440
|
+
* `def.idempotent === true ? (options.idempotencyKey ?? null) : null`, so both collapse to the
|
|
441
|
+
* same `null`. Telling either one to retry instructs a caller to apply a committed write twice
|
|
442
|
+
* — the worst possible advice for a mutator, and an axiom-4 violation dressed as a fix line.
|
|
443
|
+
* `replayable` is therefore the invocation's own fact (`record.idempotencyKey !== null`), never
|
|
444
|
+
* the declaration's: requiring `idempotent: true` at declaration would not have made the
|
|
445
|
+
* original message true, since a caller may still omit the header.
|
|
446
|
+
*/
|
|
447
|
+
export class AuditSinkFailedError extends UltimateError {
|
|
448
|
+
constructor(action: string, sourceError: unknown, replayable: boolean) {
|
|
449
|
+
super({
|
|
450
|
+
code: 'X_AUDIT_SINK_FAILED',
|
|
451
|
+
cause: `"${action}" ran and its audit sink refused the record, so the change is unrecorded — the handler had already committed`,
|
|
452
|
+
fix: replayable
|
|
453
|
+
? `fix the sink installed by setAuditSink, then retry with the same Idempotency-Key — the replay re-records without re-running the handler`
|
|
454
|
+
: `fix the sink installed by setAuditSink, then reconcile this one change by hand — do NOT retry: ${action} ran with no Idempotency-Key, so a second call runs the committed handler again. Add \`idempotent: true\` and send the header to make retries safe`,
|
|
455
|
+
docs: docs('X_AUDIT_SINK_FAILED'),
|
|
456
|
+
// Read by `--json` and the error reporter: whether a retry is safe is the one decision an
|
|
457
|
+
// operator makes here, so it is a field and not only a sentence.
|
|
458
|
+
meta: { action, replayable },
|
|
459
|
+
sourceError,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
191
464
|
export class ContractDriftError extends UltimateError {
|
|
192
465
|
constructor(cause: string, fix: string) {
|
|
193
466
|
super({ code: 'X_CONTRACT_DRIFT', cause, fix, docs: docs('X_CONTRACT_DRIFT') });
|
package/src/http.ts
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* there is no way to mount an action without them.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
+
import { isMcpExposed, isUltimateError } from '@ultimat3/core';
|
|
9
10
|
import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
10
|
-
|
|
11
|
+
// `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
|
|
12
|
+
// and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
|
|
13
|
+
// a copy here would be a second answer to "what does this limit mean" for the read half.
|
|
14
|
+
import { json, problem, redirect, takeRedirect, toBucket } from '@ultimat3/http';
|
|
11
15
|
import type { ActionRateLimit, AnyAction } from './action';
|
|
16
|
+
import type { Deprecation } from './deprecation';
|
|
17
|
+
import { applyHeaders, recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
18
|
+
import { ActionDeprecationInvalidError } from './errors';
|
|
12
19
|
import { actionName, defOf, invoke } from './invoke';
|
|
13
20
|
import {
|
|
14
21
|
derivePath,
|
|
@@ -17,12 +24,11 @@ import {
|
|
|
17
24
|
PROBLEM_SCHEMA_NAME,
|
|
18
25
|
schemaRef,
|
|
19
26
|
toOperationId,
|
|
20
|
-
toToolName,
|
|
21
27
|
} from './naming';
|
|
22
28
|
import { policyCapability } from './policy-gate';
|
|
23
|
-
import { tagKeys } from './tags';
|
|
24
29
|
|
|
25
|
-
/** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.
|
|
30
|
+
/** Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
|
|
31
|
+
* CLIENT's claim, never `ctx.buildId`, which is the build this process serves. */
|
|
26
32
|
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
27
33
|
export const IDEMPOTENCY_HEADER = 'idempotency-key';
|
|
28
34
|
export const REPLAYED_HEADER = 'x-ultimate-replayed';
|
|
@@ -36,8 +42,12 @@ export function toRoute(target: AnyAction): Route {
|
|
|
36
42
|
const name = actionName(target);
|
|
37
43
|
const { path, resource } = derivePath(name);
|
|
38
44
|
const def = defOf(target);
|
|
45
|
+
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
46
|
+
// not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
|
|
47
|
+
const sunsetting = deprecationHeadersFor(name, def.deprecated);
|
|
39
48
|
|
|
40
49
|
const handler = async (req: UltimateRequest): Promise<Response> => {
|
|
50
|
+
if (sunsetting !== undefined) recordDeprecatedCall('action', name);
|
|
41
51
|
try {
|
|
42
52
|
// The pipeline already parsed and size-capped the body; parsing it again here
|
|
43
53
|
// would be a second, differently-behaved parser for the same bytes.
|
|
@@ -51,14 +61,26 @@ export function toRoute(target: AnyAction): Route {
|
|
|
51
61
|
replayed = true;
|
|
52
62
|
},
|
|
53
63
|
});
|
|
54
|
-
|
|
64
|
+
// The one thing an action's return value cannot say. `setRedirect()` inside the handler
|
|
65
|
+
// is how a `<form method="post">` gets an answer a browser follows — a `Location` on the
|
|
66
|
+
// 200 this used to always return is a header browsers ignore, so a JS-less form left the
|
|
67
|
+
// reader staring at `{"ok":true}`. Only this projection honours it: a redirect is an HTTP
|
|
68
|
+
// fact, and the MCP tool and the job handle share none of it.
|
|
69
|
+
const to = takeRedirect(req.ctx);
|
|
70
|
+
const response = to === undefined ? json(result) : redirect(to.location, to.status);
|
|
55
71
|
if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
|
|
72
|
+
// On the failure path too, below: a client polling a deprecated endpoint that is currently
|
|
73
|
+
// 403ing still has to learn the endpoint is going away. Announcing it only on 200 hides the
|
|
74
|
+
// sunset from exactly the callers most likely to be stale.
|
|
75
|
+
if (sunsetting !== undefined) applyHeaders(response, sunsetting);
|
|
56
76
|
return response;
|
|
57
77
|
} catch (error) {
|
|
58
78
|
// Framework errors carry their own code, status and fix line; anything else is
|
|
59
79
|
// a bug and belongs to the server's error boundary, not to this route.
|
|
60
|
-
if (isUltimateError(error))
|
|
61
|
-
|
|
80
|
+
if (!isUltimateError(error)) throw error;
|
|
81
|
+
const response = problem(error);
|
|
82
|
+
if (sunsetting !== undefined) applyHeaders(response, sunsetting);
|
|
83
|
+
return response;
|
|
62
84
|
}
|
|
63
85
|
};
|
|
64
86
|
|
|
@@ -76,7 +98,12 @@ export function toRoute(target: AnyAction): Route {
|
|
|
76
98
|
input: def.input,
|
|
77
99
|
cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
|
|
78
100
|
tags: [resource],
|
|
79
|
-
|
|
101
|
+
// Name AND numbers. The name alone selected a bucket the limiter's table never held, so
|
|
102
|
+
// `bucketFor` fell through to `default` — 120 burst for an action that declared 5. The
|
|
103
|
+
// numbers ride along and `withRouteBuckets` registers them at construction.
|
|
104
|
+
...(def.rateLimit === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
|
|
80
107
|
...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
|
|
81
108
|
};
|
|
82
109
|
|
|
@@ -87,6 +114,9 @@ export interface OpenApiOperation {
|
|
|
87
114
|
readonly operationId: string;
|
|
88
115
|
readonly tags: readonly string[];
|
|
89
116
|
readonly summary: string;
|
|
117
|
+
/** OpenAPI's own flag. Absent — not `false` — when nothing is deprecated, so the spec bytes
|
|
118
|
+
* of an app that deprecates nothing are unchanged and `x verify`'s contract diff stays quiet. */
|
|
119
|
+
readonly deprecated?: boolean;
|
|
90
120
|
readonly parameters: readonly Record<string, unknown>[];
|
|
91
121
|
readonly requestBody: Record<string, unknown>;
|
|
92
122
|
readonly responses: Record<string, unknown>;
|
|
@@ -99,10 +129,12 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
99
129
|
const def = defOf(target);
|
|
100
130
|
const path = derivePath(name);
|
|
101
131
|
const idempotent = def.idempotent === true;
|
|
132
|
+
const deprecation = deprecationMetaFor(name, def.deprecated);
|
|
102
133
|
return {
|
|
103
134
|
operationId: toOperationId(name),
|
|
104
135
|
tags: [path.resource],
|
|
105
136
|
summary: def.mcp?.description ?? name,
|
|
137
|
+
...(deprecation === undefined ? {} : { deprecated: true }),
|
|
106
138
|
parameters: idempotent ? [IDEMPOTENCY_PARAMETER] : [],
|
|
107
139
|
requestBody: {
|
|
108
140
|
required: true,
|
|
@@ -121,14 +153,59 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
121
153
|
capability: policyCapability(def.policy),
|
|
122
154
|
idempotent,
|
|
123
155
|
invalidates: tagKeys(def.cache?.invalidates ?? []),
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
// The tool name an agent would call, or `null` when there is no tool. `!== false` here
|
|
157
|
+
// advertised one for every action, so an agent reading the spec asked for a tool the MCP
|
|
158
|
+
// catalog never listed — `isMcpExposed` is the same answer `toMcpTool` gives. The NAME was
|
|
159
|
+
// the second half of the same defect: `toToolName` published `publish_post` while
|
|
160
|
+
// `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not
|
|
161
|
+
// exist. Verbatim, and never derived — this is a published contract, not a label.
|
|
162
|
+
mcpTool: isMcpExposed(def.mcp) ? name : null,
|
|
163
|
+
rateLimit: rateLimitMeta(name, def.rateLimit),
|
|
164
|
+
// The dates as data, beside the boolean flag OpenAPI defines: `deprecated: true` says an
|
|
165
|
+
// operation is going away and nothing else, so a client that wants to plan the migration
|
|
166
|
+
// has to read the sunset out of prose. Absent, not null, for the same byte-stability
|
|
167
|
+
// reason `deprecated` is.
|
|
168
|
+
...(deprecation === undefined ? {} : { deprecation }),
|
|
126
169
|
},
|
|
127
170
|
};
|
|
128
171
|
}
|
|
129
172
|
|
|
130
|
-
|
|
131
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The headers this action's `deprecated:` block renders to, or nothing. The successor's URL comes
|
|
175
|
+
* from `derivePath` — the same derivation the route and the client use, never a second one.
|
|
176
|
+
*/
|
|
177
|
+
function deprecationHeadersFor(
|
|
178
|
+
name: string,
|
|
179
|
+
deprecated: Deprecation | undefined,
|
|
180
|
+
): Readonly<Record<string, string>> | undefined {
|
|
181
|
+
if (deprecated === undefined) return undefined;
|
|
182
|
+
const successor =
|
|
183
|
+
deprecated.replacedBy === undefined ? undefined : derivePath(deprecated.replacedBy).path;
|
|
184
|
+
const rendered = renderDeprecation(deprecated, successor);
|
|
185
|
+
if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
|
|
186
|
+
return rendered.headers;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The same declaration as spec data. Validated through the same render, so the two agree. */
|
|
190
|
+
function deprecationMetaFor(
|
|
191
|
+
name: string,
|
|
192
|
+
deprecated: Deprecation | undefined,
|
|
193
|
+
): Readonly<Record<string, string>> | undefined {
|
|
194
|
+
if (deprecated === undefined) return undefined;
|
|
195
|
+
const rendered = renderDeprecation(deprecated, undefined);
|
|
196
|
+
if (!rendered.ok) throw new ActionDeprecationInvalidError(name, rendered.field, rendered.value);
|
|
197
|
+
return rendered.meta;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rateLimitMeta(
|
|
201
|
+
name: string,
|
|
202
|
+
limit: ActionRateLimit | undefined,
|
|
203
|
+
): Record<string, number> | null {
|
|
204
|
+
if (limit === undefined) return null;
|
|
205
|
+
// Validated through the same call the route makes, so the spec cannot publish a pair the
|
|
206
|
+
// limiter would have refused to run on.
|
|
207
|
+
toBucket(name, limit);
|
|
208
|
+
return { limit: limit.limit, windowMs: limit.windowMs };
|
|
132
209
|
}
|
|
133
210
|
|
|
134
211
|
const IDEMPOTENCY_PARAMETER: Record<string, unknown> = {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an idempotency record is filed under: the action, the CALLER, and the caller's key —
|
|
3
|
+
* one JSON tuple, so no part of it can spell another. A key scoped to the action name alone was a
|
|
4
|
+
* key space every caller shared, and a blank header is refused rather than read as no key at all.
|
|
5
|
+
*/
|
|
6
|
+
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import { IdempotencyKeyInvalidError } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bound the OpenAPI operation has always published for the `Idempotency-Key` parameter. It is
|
|
11
|
+
* enforced here so the spec and the runtime say the same thing — and because the key is
|
|
12
|
+
* caller-chosen, which makes its length the caller's choice of how much store to occupy.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_IDEMPOTENCY_KEY_LENGTH = 255;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Keys are namespaced per action AND per caller: the same key under two actions is two keys, and
|
|
18
|
+
* the same key from two callers is two keys.
|
|
19
|
+
*
|
|
20
|
+
* The caller half is the fix for a real replay across identities — alice POSTs `charge` with a
|
|
21
|
+
* key, bob POSTs `charge` with the same key and was handed alice's stored response; with a
|
|
22
|
+
* differing payload bob got `X_IDEMPOTENCY_CONFLICT` instead, so any key he guessed was a key he
|
|
23
|
+
* could deny her.
|
|
24
|
+
*
|
|
25
|
+
* **The encoding is a JSON tuple and never a joined string**, the reasoning `@ultimat3/query`'s
|
|
26
|
+
* `readAuthority` states: an actor id is app data, so a caller who can choose one can spell
|
|
27
|
+
* whatever separator the key uses. Under `${action}:${id}:${key}`, id `alice:x` with key `y` and
|
|
28
|
+
* id `alice` with key `x:y` are one record. Fixed arity plus JSON escaping means no value can
|
|
29
|
+
* move a boundary.
|
|
30
|
+
*
|
|
31
|
+
* The limit it does NOT close: an anonymous actor has no identity to narrow to, so every
|
|
32
|
+
* anonymous caller of a public idempotent action still shares one key space. Nothing at this tier
|
|
33
|
+
* can tell two of them apart, and narrowing to something that is not identity (an IP, a session
|
|
34
|
+
* cookie) would break the retry it exists to serve.
|
|
35
|
+
*/
|
|
36
|
+
export function idempotencyKeyFor(actionName: string, key: string, actor: Actor): string {
|
|
37
|
+
if (key.trim().length === 0) {
|
|
38
|
+
throw new IdempotencyKeyInvalidError(actionName, 'empty', key.length);
|
|
39
|
+
}
|
|
40
|
+
if (key.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
|
|
41
|
+
throw new IdempotencyKeyInvalidError(actionName, 'too-long', key.length);
|
|
42
|
+
}
|
|
43
|
+
// The same three fields every other caller-scoped key in the framework is built from
|
|
44
|
+
// (`readAuthority`, `scopeKey`): kind, id, org. Two of them alone is one identity too few —
|
|
45
|
+
// a `service` and a `user` may hold the same id, and one actor's org is what a tenant sees.
|
|
46
|
+
return JSON.stringify([actionName, actor.kind, actor.id, actor.orgId ?? null, key]);
|
|
47
|
+
}
|