@ultimat3/action 23.0.0 → 25.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 +91 -42
- package/README.md +167 -47
- package/package.json +10 -9
- package/src/action.ts +25 -69
- package/src/api-declaration.ts +1 -1
- package/src/audit-gate.ts +3 -4
- package/src/audit-memory.ts +5 -3
- package/src/audit-postgres.ts +16 -8
- package/src/audit.ts +7 -106
- package/src/cache-gate.ts +25 -6
- package/src/client.ts +18 -6
- package/src/contract-test.ts +50 -14
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +57 -5
- package/src/errors.ts +44 -61
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +92 -8
- package/src/idempotency-postgres.ts +91 -39
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +58 -7
- package/src/index.ts +37 -88
- package/src/invoke.ts +57 -21
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +6 -10
- package/src/mutator.ts +14 -4
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/openapi.ts +25 -2
- package/src/policy-gate.ts +8 -51
- package/src/rate-limit-gate.ts +114 -0
- package/src/record-wire.ts +2 -2
- package/src/registry.ts +5 -4
- package/src/request-deadline.ts +25 -0
- package/src/stable.ts +0 -7
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
- package/src/type-pins.ts +98 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -87
- package/src/wire-headers.ts +0 -20
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ultimat3/action's error TITLES, registered at import — the one module `sideEffects` names, so
|
|
2
|
+
// `errors.ts` (the classes) is unlisted and a chunk carries it only where it constructs a class:
|
|
3
|
+
// Bun >= 1.4.1 honours the array and keeps a listed module wherever the barrel reaches it.
|
|
4
|
+
// `index.ts` imports this bare, because `problemError` rebuilds a server's refusal by code and reads
|
|
5
|
+
// its title from the registry; `errors.ts` and its two siblings import it bare as well.
|
|
6
|
+
|
|
7
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
11
|
+
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
|
|
12
|
+
* enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
|
|
13
|
+
* Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
|
|
14
|
+
*/
|
|
15
|
+
export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
|
|
16
|
+
X_ACTION_DUPLICATE: 'two actions are registered under one name',
|
|
17
|
+
X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
|
|
18
|
+
X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
|
|
19
|
+
X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
|
|
20
|
+
X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
|
|
21
|
+
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
|
|
22
|
+
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
|
|
23
|
+
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
|
|
24
|
+
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
|
|
25
|
+
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
|
|
26
|
+
X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
|
|
27
|
+
X_IDEMPOTENCY_NOT_SHARED:
|
|
28
|
+
'idempotency is declared fleet-wide and the installed store is per-process',
|
|
29
|
+
X_IDEMPOTENCY_REPLAYED_FAILURE:
|
|
30
|
+
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
31
|
+
X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
|
|
32
|
+
X_INPUT_INVALID: 'input failed schema validation',
|
|
33
|
+
X_MUTATOR_CLOCK_MISSING:
|
|
34
|
+
"a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
|
|
35
|
+
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
36
|
+
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
37
|
+
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
|
|
38
|
+
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
39
|
+
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
40
|
+
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
41
|
+
X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
|
|
42
|
+
X_IDEMPOTENCY_RESERVATION_LOST:
|
|
43
|
+
"an idempotent action's reservation was taken over before its transaction could settle it",
|
|
44
|
+
X_IDEMPOTENT_REPLAY_REDACTED: 'an idempotent replay would return a field redacted at rest',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
48
|
+
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
49
|
+
registerErrorCodes(
|
|
50
|
+
Object.fromEntries(Object.entries(ACTION_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
51
|
+
);
|
package/src/errors-http.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The refusals of the HTTP declaration an app makes about its actions: a pinned `http.path`, the
|
|
3
3
|
* app's `pathStyle`, and the `openapi` block. Split from `errors.ts` at its ceiling; the codes and
|
|
4
|
-
* titles
|
|
4
|
+
* titles are registered in `error-titles.ts`, one registry, imported bare so constructing one of
|
|
5
|
+
* these registers its title first.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
9
|
+
import './error-titles';
|
|
8
10
|
|
|
9
11
|
export class ActionHttpPathInvalidError extends UltimateError {
|
|
10
12
|
constructor(input: { name: string; path: unknown; reason: string }) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* stable code, exactly as there — the codes and their titles stay in `
|
|
2
|
+
* The idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
|
|
3
|
+
* stable code, exactly as there — the codes and their titles stay in `error-titles.ts`'s one
|
|
4
4
|
* `registerErrorCodes` call, because a second registration is how two modules end up deciding a
|
|
5
|
-
* title by load order.
|
|
5
|
+
* title by load order. Imported bare, so constructing one of these registers its title first.
|
|
6
6
|
*/
|
|
7
7
|
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
8
|
+
import './error-titles';
|
|
8
9
|
// Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
|
|
9
10
|
// cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
|
|
10
11
|
import type { IdempotencyFailure } from './idempotency';
|
|
@@ -77,14 +78,14 @@ export class IdempotencyNotSharedError extends UltimateError {
|
|
|
77
78
|
// positional form is `unsafe`), so that line compiled and would have thrown on the first
|
|
78
79
|
// reservation. The framework's own boot already installs this store; a host booting the
|
|
79
80
|
// framework itself wraps the client it opened.
|
|
80
|
-
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' })",
|
|
81
|
+
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 }) }, origin: () => client, reclaimAfterMs: requestDeadlineMs })) from '@ultimat3/action', or drop the declaration to configureIdempotency({ scope: 'process' })",
|
|
81
82
|
meta: { storeScope: storeScope ?? null },
|
|
82
83
|
});
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `
|
|
88
|
+
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guardAction()`
|
|
88
89
|
* and the input parse both run before the idempotency gate, so everything the gate can see throw
|
|
89
90
|
* is post-authorization and possibly post-commit — a handler that took the money and then failed
|
|
90
91
|
* its own `output:` schema is the case this exists for. Releasing the reservation there let the
|
|
@@ -138,3 +139,54 @@ export class IdempotencyStatusUnknownError extends UltimateError {
|
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A mutator declared without `idempotent: true`. Refused at declaration: the client replays a
|
|
145
|
+
* mutator's write under one `Idempotency-Key` (a retry, an offline queue draining after a lost
|
|
146
|
+
* response), and `toRoute` reads that key only for an idempotent action — so the alternative is a
|
|
147
|
+
* toggle that flips back, with nothing in any log to say the handler ran twice.
|
|
148
|
+
*/
|
|
149
|
+
export class MutatorNotIdempotentError extends UltimateError {
|
|
150
|
+
constructor() {
|
|
151
|
+
super({
|
|
152
|
+
code: 'X_MUTATOR_NOT_IDEMPOTENT',
|
|
153
|
+
cause:
|
|
154
|
+
'a mutator is declared without idempotent: true, so a replay of its write under the same Idempotency-Key would run the server half a second time',
|
|
155
|
+
fix: 'add `idempotent: true` to the mutator definition',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A settlement inside the handler's transaction matched no record: the attempt ran past the
|
|
162
|
+
* request deadline, a retry reclaimed the key, and this one no longer owns it. THROWN, where the
|
|
163
|
+
* autocommit path only logs — nothing here has committed, so the throw rolls this attempt's
|
|
164
|
+
* writes back and the retry's are the only ones stored.
|
|
165
|
+
*/
|
|
166
|
+
export class IdempotencyReservationLostError extends UltimateError {
|
|
167
|
+
constructor(key: string) {
|
|
168
|
+
super({
|
|
169
|
+
code: 'X_IDEMPOTENCY_RESERVATION_LOST',
|
|
170
|
+
cause: `idempotency key "${key}" was reclaimed by a retry while this attempt was still in flight past the request deadline, so its transaction is rolled back rather than committed beside the retry's`,
|
|
171
|
+
fix: 'resend this request with the same Idempotency-Key — the attempt that took the key over answers it, and this one was rolled back',
|
|
172
|
+
meta: { key },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A replay of a settled answer that had a field redacted at rest. Refused, never served: the
|
|
179
|
+
* stored copy holds `[redacted]` where the first caller got a credential, and handing that back
|
|
180
|
+
* as "the answer" is a silent wrong value. The first attempt RAN — a fresh key runs it again, so
|
|
181
|
+
* the fix names reading its effect as the way to recover the result.
|
|
182
|
+
*/
|
|
183
|
+
export class IdempotentReplayRedactedError extends UltimateError {
|
|
184
|
+
constructor(key: string) {
|
|
185
|
+
super({
|
|
186
|
+
code: 'X_IDEMPOTENT_REPLAY_REDACTED',
|
|
187
|
+
cause: `the answer stored for idempotency key "${key}" had a secret-named field redacted when it settled, so it cannot be replayed faithfully`,
|
|
188
|
+
fix: "x actions describe '<action>' --json # the call already ran and showed its secret once — read its effect with a query; a new Idempotency-Key runs it again (a second credential)",
|
|
189
|
+
meta: { key, replayed: true },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
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
|
-
* The idempotency
|
|
4
|
+
* The idempotency failures live in `errors-idempotency.ts` — this file reached the line ceiling —
|
|
5
5
|
* and are re-exported here, so `./errors` stays the one import path for all of them.
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
@@ -9,15 +9,20 @@ import {
|
|
|
9
9
|
deniedCallerFix,
|
|
10
10
|
ERROR_DOCS_URL,
|
|
11
11
|
hasErrorCode,
|
|
12
|
-
registerErrorCodes,
|
|
13
12
|
renderFixShellArg,
|
|
14
13
|
retryForStatus,
|
|
15
14
|
UltimateError,
|
|
15
|
+
withStatedDelay,
|
|
16
16
|
} from '@ultimat3/core';
|
|
17
17
|
import type { SurfaceDenial } from '@ultimat3/policy';
|
|
18
18
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
19
|
+
// The titles are `error-titles.ts`, imported bare: this module is then side-effect-free and the
|
|
20
|
+
// array does not list it, so a chunk keeps only the classes it constructs, and a chunk that
|
|
21
|
+
// constructs one still registers every title first — anchored by use. The barrel imports it bare
|
|
22
|
+
// too, for the browser that rebuilds a SERVER's refusal by code and constructs nothing from here.
|
|
23
|
+
import './error-titles';
|
|
19
24
|
|
|
20
|
-
// Re-exported, not re-declared: the
|
|
25
|
+
// Re-exported, not re-declared: the idempotency failures moved to their own file when this
|
|
21
26
|
// one reached the line ceiling, and every importer still reads them from `./errors`.
|
|
22
27
|
export type { IdempotencyConflictReason, IdempotencyKeyProblem } from './errors-idempotency';
|
|
23
28
|
export {
|
|
@@ -25,49 +30,12 @@ export {
|
|
|
25
30
|
IdempotencyKeyInvalidError,
|
|
26
31
|
IdempotencyNotSharedError,
|
|
27
32
|
IdempotencyReplayedFailureError,
|
|
33
|
+
IdempotencyReservationLostError,
|
|
28
34
|
IdempotencyStatusUnknownError,
|
|
35
|
+
IdempotentReplayRedactedError,
|
|
36
|
+
MutatorNotIdempotentError,
|
|
29
37
|
} from './errors-idempotency';
|
|
30
38
|
|
|
31
|
-
/**
|
|
32
|
-
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
33
|
-
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
|
|
34
|
-
* enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
|
|
35
|
-
* Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
|
|
36
|
-
*/
|
|
37
|
-
const OWNED_TITLES: Readonly<Record<string, string>> = {
|
|
38
|
-
X_ACTION_DUPLICATE: 'two actions are registered under one name',
|
|
39
|
-
X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
|
|
40
|
-
X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
|
|
41
|
-
X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
|
|
42
|
-
X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
|
|
43
|
-
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
|
|
44
|
-
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
|
|
45
|
-
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
|
|
46
|
-
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
|
|
47
|
-
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
|
|
48
|
-
X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
|
|
49
|
-
X_IDEMPOTENCY_NOT_SHARED:
|
|
50
|
-
'idempotency is declared fleet-wide and the installed store is per-process',
|
|
51
|
-
X_IDEMPOTENCY_REPLAYED_FAILURE:
|
|
52
|
-
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
53
|
-
X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
|
|
54
|
-
X_INPUT_INVALID: 'input failed schema validation',
|
|
55
|
-
X_MUTATOR_CLOCK_MISSING:
|
|
56
|
-
"a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
|
|
57
|
-
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
58
|
-
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
59
|
-
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
|
|
60
|
-
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
61
|
-
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
62
|
-
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
66
|
-
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
67
|
-
registerErrorCodes(
|
|
68
|
-
Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
|
|
69
|
-
);
|
|
70
|
-
|
|
71
39
|
/** Thrown when a projection needs a name the action does not have yet. */
|
|
72
40
|
export class ActionUnregisteredError extends UltimateError {
|
|
73
41
|
constructor() {
|
|
@@ -124,7 +92,7 @@ export function denialReason(denial: SurfaceDenial): string {
|
|
|
124
92
|
}
|
|
125
93
|
|
|
126
94
|
/**
|
|
127
|
-
* An authz denial, thrown by `
|
|
95
|
+
* An authz denial, thrown by `guardAction()`. The code and reason come from the policy
|
|
128
96
|
* decision — this package never invents an authz code — and the surface-shaped
|
|
129
97
|
* denial rides along for projections that render it themselves.
|
|
130
98
|
*/
|
|
@@ -219,7 +187,7 @@ export class InputInvalidError extends UltimateError {
|
|
|
219
187
|
super({
|
|
220
188
|
code: 'X_INPUT_INVALID',
|
|
221
189
|
cause: `input for ${subject} "${name}" failed validation: ${detail}`,
|
|
222
|
-
fix: `x actions describe ${name} --json # prints the expected input schema`,
|
|
190
|
+
fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # prints the expected input schema`,
|
|
223
191
|
// A remote caller holds the published schema — the MCP `inputSchema`, the OpenAPI request
|
|
224
192
|
// body — and cannot run `x actions describe`.
|
|
225
193
|
callerFix:
|
|
@@ -240,7 +208,7 @@ export class OutputInvalidError extends UltimateError {
|
|
|
240
208
|
super({
|
|
241
209
|
code: 'X_OUTPUT_INVALID',
|
|
242
210
|
cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
|
|
243
|
-
fix: `x actions describe ${name} --json # compare the handler's return against \`output:\``,
|
|
211
|
+
fix: `x actions describe ${renderFixShellArg(name, '<action-name>')} --json # compare the handler's return against \`output:\``,
|
|
244
212
|
});
|
|
245
213
|
}
|
|
246
214
|
}
|
|
@@ -289,6 +257,13 @@ export interface RemoteFailure {
|
|
|
289
257
|
* so an island reads `error.meta.sessionId` where it used to run a regex over `cause`.
|
|
290
258
|
*/
|
|
291
259
|
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
260
|
+
/**
|
|
261
|
+
* The document's `title`, already read by core's `remoteTitleOf` — used only when this bundle
|
|
262
|
+
* registered no title for `code`, which is every http refusal in an island.
|
|
263
|
+
*/
|
|
264
|
+
readonly title?: string | undefined;
|
|
265
|
+
/** The delay the answer's `Retry-After` named, in seconds (core's `retryAfterSecondsOf`). */
|
|
266
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
292
267
|
}
|
|
293
268
|
|
|
294
269
|
/** A link, not a string the server happened to put in a field the overlay renders as an href. */
|
|
@@ -341,19 +316,27 @@ export class RemoteActionError extends UltimateError {
|
|
|
341
316
|
// this build may never have heard of. `UltimateError` otherwise fills `retry` from
|
|
342
317
|
// `retryFor(code)`, which fails closed — so a 503 out of a typed call announced itself as
|
|
343
318
|
// `terminal` on the one field the framework promises a client never has to infer.
|
|
344
|
-
retry
|
|
345
|
-
meta
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
319
|
+
// A 429 or 503 that stated a delay is `retry-after` whoever registered the code, and the
|
|
320
|
+
// delay rides on `meta.retryAfterSeconds`, the one key core's `statedDelayMs` reads.
|
|
321
|
+
retry: retryForStatus(failure.code, failure.status, failure.retryAfterSeconds),
|
|
322
|
+
remoteTitle: failure.title,
|
|
323
|
+
// Core's `withStatedDelay`, the rule `problemError` applies too: a `retryAfterSeconds` off the
|
|
324
|
+
// body is dropped and only the header's is carried — the server never writes one there.
|
|
325
|
+
meta: withStatedDelay(
|
|
326
|
+
{
|
|
327
|
+
// The server's declared keys FIRST, so the four this class owns win a collision: a
|
|
328
|
+
// server meta naming `status` would otherwise overwrite the one a report reads the HTTP
|
|
329
|
+
// status from, and `origin: 'remote'` marks the code as one this bundle never declared.
|
|
330
|
+
...failure.meta,
|
|
331
|
+
origin: 'remote',
|
|
332
|
+
action: failure.action,
|
|
333
|
+
status: failure.status,
|
|
334
|
+
// Absent rather than `undefined`: `meta` is rendered into `--json` and the error
|
|
335
|
+
// reporter, and a null member reads as "the server sent an empty list".
|
|
336
|
+
...(failure.issues === undefined ? {} : { issues: failure.issues }),
|
|
337
|
+
},
|
|
338
|
+
failure.retryAfterSeconds,
|
|
339
|
+
),
|
|
357
340
|
});
|
|
358
341
|
this.status = failure.status;
|
|
359
342
|
}
|
|
@@ -370,7 +353,7 @@ export class RpcFailedError extends UltimateError {
|
|
|
370
353
|
super({
|
|
371
354
|
code: 'X_RPC_FAILED',
|
|
372
355
|
cause: `${name} returned HTTP ${status} without a problem+json body`,
|
|
373
|
-
fix: `check the gateway in front of the app, then: x actions describe ${name} --json`,
|
|
356
|
+
fix: `check the gateway in front of the app, then: x actions describe ${renderFixShellArg(name, '<action-name>')} --json`,
|
|
374
357
|
retry: retryForStatus('X_RPC_FAILED', status),
|
|
375
358
|
});
|
|
376
359
|
}
|
|
@@ -388,7 +371,7 @@ export class AuditSinkMissingError extends UltimateError {
|
|
|
388
371
|
super({
|
|
389
372
|
code: 'X_AUDIT_SINK_MISSING',
|
|
390
373
|
cause: `action "${action}" declares \`audit: true\` and no audit sink is installed`,
|
|
391
|
-
fix: "call setAuditSink(yourSink) from '@ultimat3/
|
|
374
|
+
fix: "call setAuditSink(yourSink) from '@ultimat3/core' at boot, before registerActions()",
|
|
392
375
|
});
|
|
393
376
|
}
|
|
394
377
|
}
|
package/src/facade.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The fluent surface: every projection reachable as a method on the action
|
|
3
|
-
* `publishPost.
|
|
4
|
-
* field lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
-
* functions stay exported for the framework's own call sites — this file only
|
|
6
|
-
*
|
|
2
|
+
* The fluent surface: every projection this package owns reachable as a method on the action
|
|
3
|
+
* itself, `publishPost.openapi()` rather than `toOpenApiOperation(publishPost)`, and every
|
|
4
|
+
* declared field lifted off `def` so app code never reaches through `.def`. The projection
|
|
5
|
+
* functions stay exported for the framework's own call sites — this file only binds them to the
|
|
6
|
+
* action, it never re-implements one. There is no `.tool()`: the MCP tool is `@ultimat3/mcp`'s
|
|
7
|
+
* one projection (`toolFrom(publishPost)`), a tier this package cannot import.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
@@ -13,7 +14,6 @@ import { contractTestsFor } from './contract-test';
|
|
|
13
14
|
import { toOpenApiOperation } from './http';
|
|
14
15
|
import { actionName, invoke } from './invoke';
|
|
15
16
|
import { toJobHandle } from './job-handle';
|
|
16
|
-
import { toMcpTool } from './mcp-tool';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* `self` is a thunk on purpose: the façade is attached while the action is still
|
|
@@ -33,7 +33,6 @@ export function facadeFor<TInput extends StandardSchemaV1, TOutput extends Stand
|
|
|
33
33
|
// the output type is this action's by construction.
|
|
34
34
|
as: (actor, input, options) =>
|
|
35
35
|
invoke(self(), input, { ...options, actor }) as Promise<InferOutput<TOutput>>,
|
|
36
|
-
tool: () => toMcpTool(self()),
|
|
37
36
|
openapi: () => toOpenApiOperation(self()),
|
|
38
37
|
client: (options) => clientMethodFor(actionName(self()), options, def.http?.path),
|
|
39
38
|
job: () => toJobHandle(self()),
|
package/src/http-path.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function setActionPathStyle(next: unknown): ActionPathStyle {
|
|
|
69
69
|
return style;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/** Test seam, beside `
|
|
72
|
+
/** Test seam, beside `resetActions`. */
|
|
73
73
|
/**
|
|
74
74
|
* Forget which paths were handed out, keeping the style. `@ultimat3/testing` calls it between two
|
|
75
75
|
* test files of one worker (22.7, no `--isolate`): a path an earlier FILE derived is not one this
|
package/src/http.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
+
import type { Deprecation } from '@ultimat3/core';
|
|
9
10
|
import {
|
|
11
|
+
IDEMPOTENCY_HEADER,
|
|
10
12
|
isMcpExposed,
|
|
11
13
|
RECORDS_OPENAPI_HEADER,
|
|
14
|
+
recordDeprecatedCall,
|
|
12
15
|
recordEnvelopeSchema,
|
|
16
|
+
renderDeprecation,
|
|
17
|
+
useContext,
|
|
13
18
|
withWriteOrigin,
|
|
14
19
|
writeDigest,
|
|
15
20
|
} from '@ultimat3/core';
|
|
@@ -17,10 +22,9 @@ import type { Route, RouteMeta, UltimateRequest } from '@ultimat3/http';
|
|
|
17
22
|
// `toBucket` is `@ultimat3/http`'s, not this package's: http owns `Bucket` and the limiter maths,
|
|
18
23
|
// and `@ultimat3/query` needs the identical conversion while being the same tier as this one — so
|
|
19
24
|
// a copy here would be a second answer to "what does this limit mean" for the read half.
|
|
20
|
-
import {
|
|
25
|
+
import { jsonResponse, publishRateLimit, redirect, takeRedirect, toBucket } from '@ultimat3/http';
|
|
26
|
+
import { admitsAnonymous, policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
21
27
|
import type { ActionRateLimit, AnyAction } from './action';
|
|
22
|
-
import type { Deprecation } from './deprecation';
|
|
23
|
-
import { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
24
28
|
import { ActionDeprecationInvalidError } from './errors';
|
|
25
29
|
import { actionPathStyle, servedActionRoute } from './http-path';
|
|
26
30
|
import { actionName, defOf, invoke } from './invoke';
|
|
@@ -31,16 +35,7 @@ import {
|
|
|
31
35
|
schemaRef,
|
|
32
36
|
toOperationId,
|
|
33
37
|
} from './naming';
|
|
34
|
-
import { admitsAnonymous, policyCapability, policyPermissions } from './policy-gate';
|
|
35
38
|
import { carriesRecords, recordResponse } from './record-wire';
|
|
36
|
-
import { IDEMPOTENCY_HEADER } from './wire-headers';
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Re-exported, never re-declared. The two strings moved to `wire-headers.ts` so `client.ts` can
|
|
40
|
-
* name them without pulling this file's server graph into a browser bundle; every importer of
|
|
41
|
-
* `./http` still reads them from here, which is what kept the move from touching a call site.
|
|
42
|
-
*/
|
|
43
|
-
export { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './wire-headers';
|
|
44
39
|
|
|
45
40
|
export const REPLAYED_HEADER = 'x-ultimate-replayed';
|
|
46
41
|
|
|
@@ -90,6 +85,9 @@ function projectRoute(
|
|
|
90
85
|
// Rendered ONCE, at projection: a date that cannot become a header is a mount-time refusal,
|
|
91
86
|
// not a surprise on the first request — the same rule `toBucket` follows for a rate limit.
|
|
92
87
|
const sunsetting = deprecationHeadersFor(name, def.deprecated);
|
|
88
|
+
// Same rule, for a limit the limiter could not run on: refused at mount (`X_RATE_LIMIT_INVALID`),
|
|
89
|
+
// never on the first request that tries to spend it.
|
|
90
|
+
if (def.rateLimit !== undefined) toBucket(name, def.rateLimit);
|
|
93
91
|
const enveloped = carriesRecords(target.output);
|
|
94
92
|
|
|
95
93
|
const handler = async (req: UltimateRequest): Promise<Response> => {
|
|
@@ -121,6 +119,9 @@ function projectRoute(
|
|
|
121
119
|
onReplay: () => {
|
|
122
120
|
replayed = true;
|
|
123
121
|
},
|
|
122
|
+
clientAddress: req.ctx.ip,
|
|
123
|
+
onRateLimit: (decision) =>
|
|
124
|
+
publishRateLimit(req.ctx, decision, useContext().now().getTime()),
|
|
124
125
|
}),
|
|
125
126
|
);
|
|
126
127
|
// The one thing an action's return value cannot say. `setRedirect()` inside the handler
|
|
@@ -134,7 +135,7 @@ function projectRoute(
|
|
|
134
135
|
? redirect(to.location, to.status)
|
|
135
136
|
: enveloped
|
|
136
137
|
? recordResponse(target.output, result)
|
|
137
|
-
:
|
|
138
|
+
: jsonResponse(result);
|
|
138
139
|
if (key !== null) response.headers.set(REPLAYED_HEADER, replayed ? '1' : '0');
|
|
139
140
|
return response;
|
|
140
141
|
};
|
|
@@ -154,6 +155,9 @@ function projectRoute(
|
|
|
154
155
|
// stage deciding first would decide from `row: null` — a denial for the row's own
|
|
155
156
|
// author, from an authz system that never saw the row.
|
|
156
157
|
enforcedBy: 'handler',
|
|
158
|
+
// Which primitive this is, for `@ultimat3/http`'s bucket check: a `rateLimit.buckets.<name>`
|
|
159
|
+
// named after an action limits nothing since 25.0.0, and is refused rather than ignored.
|
|
160
|
+
primitive: 'action',
|
|
157
161
|
// `input` stays ABSENT, deliberately, exactly as `@ultimat3/query`'s `toQueryRoute` leaves it.
|
|
158
162
|
// Setting it hands the action's schema to the pipeline's `body` stage, which throws
|
|
159
163
|
// `bodyInvalid` — so this route answered `422 X_BODY_INVALID` for every malformed body while
|
|
@@ -167,12 +171,11 @@ function projectRoute(
|
|
|
167
171
|
// failing a plain `route.ts`'s own schema, where no primitive owns the input.
|
|
168
172
|
cache: { mode: 'no-store', tags: tagKeys(def.cache?.invalidates ?? []) },
|
|
169
173
|
tags: [operationTagOf(target)],
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
: { rateLimit: name, rateLimitBucket: toBucket(name, def.rateLimit) }),
|
|
174
|
+
// `invoke` spends the declared bucket for every surface, so the stage spends no caller bucket
|
|
175
|
+
// for this route — not the action's (that was the second, HTTP-only enforcement point MCP,
|
|
176
|
+
// the agent tool and `.job()` never reached) and not `default` (a ceiling an action declaring
|
|
177
|
+
// more than it never had). The tenant allowance is still the stage's.
|
|
178
|
+
...(def.rateLimit === undefined ? {} : { rateLimitedBy: 'handler' as const }),
|
|
176
179
|
...(def.mcp?.description === undefined ? {} : { description: def.mcp.description }),
|
|
177
180
|
};
|
|
178
181
|
|
|
@@ -254,7 +257,7 @@ export function toOpenApiOperation(target: AnyAction): OpenApiOperation {
|
|
|
254
257
|
invalidates: tagKeys(def.cache?.invalidates ?? []),
|
|
255
258
|
// The tool name an agent would call, or `null` when there is no tool. `!== false` here
|
|
256
259
|
// advertised one for every action, so an agent reading the spec asked for a tool the MCP
|
|
257
|
-
// catalog never listed — `isMcpExposed` is the same answer `
|
|
260
|
+
// catalog never listed — `isMcpExposed` is the same answer `@ultimat3/mcp` gives. The NAME was
|
|
258
261
|
// the second half of the same defect: `toToolName` published `publish_post` while
|
|
259
262
|
// `@ultimat3/mcp` served `publishPost`, so a spec-reading agent called a tool that does not
|
|
260
263
|
// exist. Verbatim, and never derived — this is a published contract, not a label.
|