@ultimat3/action 24.0.0 → 25.1.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 +65 -39
- package/README.md +124 -41
- package/package.json +10 -10
- 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 +3 -3
- package/src/audit-postgres.ts +15 -6
- package/src/audit.ts +7 -106
- package/src/client.ts +18 -6
- package/src/contract-test.ts +18 -6
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +21 -3
- package/src/errors.ts +40 -62
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +29 -10
- package/src/idempotency-postgres.ts +27 -12
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +69 -9
- package/src/index.ts +33 -87
- package/src/invoke.ts +54 -19
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +2 -1
- package/src/mutator.ts +1 -2
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- 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 +2 -2
- package/src/sample-input.ts +35 -6
- package/src/stable.ts +0 -7
- package/src/transition.ts +23 -3
- package/src/tx-scope.ts +1 -1
- package/src/type-pins.ts +103 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -95
- package/src/wire-headers.ts +0 -20
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One job: the copy of an idempotent answer that may be kept AT REST — every value under a key
|
|
3
|
+
* core's `isRedactedKey` names, every `Secret` box, and everything the walk cannot vouch for
|
|
4
|
+
* replaced by `[redacted]`. The rule is core's table, the one `audit-input.ts` and the logger ask.
|
|
5
|
+
*/
|
|
6
|
+
import { isRedactedKey, isSecret, REDACTED } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How deep the walk inspects. Past it the subtree is REDACTED, never kept: what was not looked at
|
|
10
|
+
* cannot be shown to hold no credential, and the record of it outlives the request by a day. An
|
|
11
|
+
* output schema deep enough to reach this is a recursive one, and its replay is refused rather
|
|
12
|
+
* than served with a credential in it.
|
|
13
|
+
*/
|
|
14
|
+
export const IDEMPOTENCY_REDACT_MAX_DEPTH = 32;
|
|
15
|
+
|
|
16
|
+
export interface RestingAnswer {
|
|
17
|
+
/** What the store may write down. The ORIGINAL reference when nothing was redacted. */
|
|
18
|
+
readonly value: unknown;
|
|
19
|
+
/** True when anything was replaced — the record's flag, so a replay never scans for a string. */
|
|
20
|
+
readonly redacted: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface WalkState {
|
|
24
|
+
redacted: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The answer as it may rest. Plain data — objects whose prototype is `Object.prototype` or
|
|
29
|
+
* `null`, arrays, `Date`, primitives — with no redacted key comes back as the SAME reference, so
|
|
30
|
+
* the common case stores and replays exactly what it did before.
|
|
31
|
+
*
|
|
32
|
+
* **Fail closed on everything else.** The Postgres store writes `JSON.stringify(value)`, which
|
|
33
|
+
* runs app code this walk never saw: a `toJSON` (on a class, a plain object, an array, even a
|
|
34
|
+
* function) may return `{ apiKey }`, and a getter read a second time may return something else.
|
|
35
|
+
* So a value with a callable `toJSON`, a `Map`, a `Set`, a class instance or a `Date` subclass is
|
|
36
|
+
* `[redacted]` and the record flagged — its replay is refused, which is the honest answer for an
|
|
37
|
+
* answer this walk could not judge. An own getter is read ONCE and the subtree copied, so the
|
|
38
|
+
* serializer reads the value that was judged, not a fresh one. `toJSON` is never called. A CYCLE
|
|
39
|
+
* is cut the same way — its back-reference becomes `[redacted]` — so both stores keep one
|
|
40
|
+
* serializable copy; a value repeated as siblings is not a cycle and is kept.
|
|
41
|
+
*/
|
|
42
|
+
export function restingAnswer(value: unknown): RestingAnswer {
|
|
43
|
+
const state: WalkState = { redacted: false };
|
|
44
|
+
const out = walk(value, 0, new Set(), state);
|
|
45
|
+
return { value: out, redacted: state.redacted };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redact(state: WalkState): string {
|
|
49
|
+
state.redacted = true;
|
|
50
|
+
return REDACTED;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `JSON.stringify` asks for `toJSON` on any object — a function included. */
|
|
54
|
+
function hasToJson(value: object): boolean {
|
|
55
|
+
return typeof (value as { toJSON?: unknown }).toJSON === 'function';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A `Date` whose `toJSON` is the built-in one: its own prototype, no own override. */
|
|
59
|
+
function isPlainDate(value: object): boolean {
|
|
60
|
+
return Object.getPrototypeOf(value) === Date.prototype && !Object.hasOwn(value, 'toJSON');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPlainObject(value: object): boolean {
|
|
64
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
65
|
+
return proto === Object.prototype || proto === null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function walk(value: unknown, depth: number, ancestors: Set<object>, state: WalkState): unknown {
|
|
69
|
+
if (typeof value === 'function') return hasToJson(value) ? redact(state) : value;
|
|
70
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
71
|
+
if (isSecret(value)) return redact(state);
|
|
72
|
+
if (isPlainDate(value)) return value;
|
|
73
|
+
// A back-reference fails closed: kept, `JSON.stringify` throws in the Postgres store after the
|
|
74
|
+
// handler committed, while the memory store would accept it — two answers for one record.
|
|
75
|
+
if (ancestors.has(value)) return redact(state);
|
|
76
|
+
const array = Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
|
|
77
|
+
if (hasToJson(value) || !(array || isPlainObject(value))) return redact(state);
|
|
78
|
+
if (depth >= IDEMPOTENCY_REDACT_MAX_DEPTH) return redact(state);
|
|
79
|
+
ancestors.add(value);
|
|
80
|
+
try {
|
|
81
|
+
return array
|
|
82
|
+
? walkArray(value as readonly unknown[], depth, ancestors, state)
|
|
83
|
+
: walkObject(value, depth, ancestors, state);
|
|
84
|
+
} finally {
|
|
85
|
+
ancestors.delete(value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One read of an own property: a data property's value, or its getter called exactly once.
|
|
91
|
+
* `copy` says the subtree must be copied for the read to be the one the serializer sees.
|
|
92
|
+
*/
|
|
93
|
+
function readOnce(target: object, key: string | number): { value: unknown; copy: boolean } {
|
|
94
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
|
95
|
+
if (descriptor === undefined) return { value: undefined, copy: false };
|
|
96
|
+
if (descriptor.get !== undefined) return { value: descriptor.get.call(target), copy: true };
|
|
97
|
+
return { value: descriptor.value, copy: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function walkArray(
|
|
101
|
+
value: readonly unknown[],
|
|
102
|
+
depth: number,
|
|
103
|
+
ancestors: Set<object>,
|
|
104
|
+
state: WalkState,
|
|
105
|
+
): unknown {
|
|
106
|
+
let copy: unknown[] | undefined;
|
|
107
|
+
// Indexed, not `forEach`: a hole is visited (as `undefined`) and kept in its place.
|
|
108
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
109
|
+
const read = readOnce(value, index);
|
|
110
|
+
const next = walk(read.value, depth + 1, ancestors, state);
|
|
111
|
+
if (read.copy || next !== read.value) copy ??= value.slice(0, index);
|
|
112
|
+
copy?.push(next);
|
|
113
|
+
}
|
|
114
|
+
return copy ?? value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function walkObject(
|
|
118
|
+
value: object,
|
|
119
|
+
depth: number,
|
|
120
|
+
ancestors: Set<object>,
|
|
121
|
+
state: WalkState,
|
|
122
|
+
): unknown {
|
|
123
|
+
let changed = false;
|
|
124
|
+
const out: Record<string, unknown> = {};
|
|
125
|
+
// OWN enumerable string keys: what `JSON.stringify` writes and what the Postgres store keeps.
|
|
126
|
+
for (const key of Object.keys(value)) {
|
|
127
|
+
const read = readOnce(value, key);
|
|
128
|
+
const item = read.value;
|
|
129
|
+
// The key decides before the value is walked, so a credential under a redacted name is never
|
|
130
|
+
// read further. `null` and `undefined` under one are KEPT: they hold no credential, and
|
|
131
|
+
// redacting them would refuse the replay of a `{ resetToken: null }` that never carried one.
|
|
132
|
+
const next =
|
|
133
|
+
item !== undefined && item !== null && isRedactedKey(key)
|
|
134
|
+
? redact(state)
|
|
135
|
+
: walk(item, depth + 1, ancestors, state);
|
|
136
|
+
if (read.copy || next !== item) changed = true;
|
|
137
|
+
out[key] = next;
|
|
138
|
+
}
|
|
139
|
+
return changed ? out : value;
|
|
140
|
+
}
|
package/src/idempotency.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
IdempotencyConflictError,
|
|
10
10
|
IdempotencyNotSharedError,
|
|
11
11
|
IdempotencyReplayedFailureError,
|
|
12
|
+
IdempotentReplayRedactedError,
|
|
12
13
|
} from './errors';
|
|
13
|
-
import {
|
|
14
|
+
import { memoryIdempotencyStore } from './idempotency-memory';
|
|
15
|
+
import { restingAnswer } from './idempotency-redact';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* Where a store's records live. Declared by the driver, never inferred — the same rule
|
|
@@ -68,6 +70,12 @@ export interface IdempotencyRecord {
|
|
|
68
70
|
readonly value: unknown;
|
|
69
71
|
/** Present exactly when `status === 'failed'` — what the replay re-throws. */
|
|
70
72
|
readonly failure?: IdempotencyFailure | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* True when `value` had a field redacted at rest (`idempotency-redact.ts`). Its replay is
|
|
75
|
+
* `X_IDEMPOTENT_REPLAY_REDACTED`, decided by this flag — never by scanning the value for
|
|
76
|
+
* `[redacted]`, a string a legitimate answer may hold. Absent or false: replayed as stored.
|
|
77
|
+
*/
|
|
78
|
+
readonly redacted?: boolean | undefined;
|
|
71
79
|
/** Epoch milliseconds. The store's own dedupe window is measured from here. */
|
|
72
80
|
readonly createdAt: number;
|
|
73
81
|
}
|
|
@@ -91,6 +99,14 @@ export interface IdempotencyStore {
|
|
|
91
99
|
* is one immortal entry per write, forever.
|
|
92
100
|
*/
|
|
93
101
|
readonly windowMs?: number | undefined;
|
|
102
|
+
/**
|
|
103
|
+
* The store's declaration that it keeps `settle`'s `redacted` flag and hands it back as
|
|
104
|
+
* `IdempotencyRecord.redacted` (#591). REQUIRED, and a literal: a store written before answers
|
|
105
|
+
* were redacted at rest drops the flag and would replay `[redacted]` as the answer, and a
|
|
106
|
+
* required parameter alone cannot refuse it — TypeScript accepts an implementation with fewer
|
|
107
|
+
* parameters. Without this member, such a store is a build error (`type-pins.ts`).
|
|
108
|
+
*/
|
|
109
|
+
readonly keepsRedaction: true;
|
|
94
110
|
/** Atomically create-or-fetch the record for `key`. The atomicity is the point. */
|
|
95
111
|
reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
|
|
96
112
|
/**
|
|
@@ -104,8 +120,13 @@ export interface IdempotencyStore {
|
|
|
104
120
|
* exactly, overwrote a live reservation, and the replacement's own settle was then fenced out —
|
|
105
121
|
* so the retry replayed a value produced for a different request. A settlement that matches no
|
|
106
122
|
* record is logged, never thrown: it lands after the handler has committed.
|
|
123
|
+
*
|
|
124
|
+
* `value` is already the RESTING copy — `withIdempotency` redacts before it calls, so no store
|
|
125
|
+
* ever receives a credential. `redacted` says it did, and a store MUST keep it and hand it back
|
|
126
|
+
* as `IdempotencyRecord.redacted`: one that drops it replays `[redacted]` as the answer. Required
|
|
127
|
+
* of every caller, so a store that wraps another cannot drop it on the way through.
|
|
107
128
|
*/
|
|
108
|
-
settle(key: string, value: unknown, reservationId: string): Promise<void>;
|
|
129
|
+
settle(key: string, value: unknown, reservationId: string, redacted: boolean): Promise<void>;
|
|
109
130
|
/**
|
|
110
131
|
* Settle a FAILURE, so the retry replays it instead of re-running a handler that may already
|
|
111
132
|
* have committed. Fenced on the same reservation id as `settle`, for the same case — a
|
|
@@ -116,8 +137,13 @@ export interface IdempotencyStore {
|
|
|
116
137
|
* and re-running it is the double charge.
|
|
117
138
|
*/
|
|
118
139
|
fail?(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void>;
|
|
119
|
-
/**
|
|
120
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Drop the reservation `reservationId` owns, so a retry can run. Only ever correct BEFORE the
|
|
142
|
+
* handler starts. Fenced like `settle` and `fail`: a straggler whose reservation was reclaimed
|
|
143
|
+
* past the deadline deleted the REPLACEMENT's record by key — settled, the next retry re-ran a
|
|
144
|
+
* handler that had committed. A release that matches nothing is a no-op.
|
|
145
|
+
*/
|
|
146
|
+
release(key: string, reservationId: string): Promise<void>;
|
|
121
147
|
get(key: string): Promise<IdempotencyRecord | undefined>;
|
|
122
148
|
}
|
|
123
149
|
|
|
@@ -136,7 +162,7 @@ export interface IdempotencyConfig {
|
|
|
136
162
|
export const DEFAULT_IDEMPOTENCY_CONFIG: IdempotencyConfig = Object.freeze({ scope: 'process' });
|
|
137
163
|
|
|
138
164
|
let config: IdempotencyConfig = DEFAULT_IDEMPOTENCY_CONFIG;
|
|
139
|
-
let defaultStore: IdempotencyStore =
|
|
165
|
+
let defaultStore: IdempotencyStore = memoryIdempotencyStore();
|
|
140
166
|
|
|
141
167
|
/** Declared at boot, before `registerActions()`. Nothing infers a replica count. */
|
|
142
168
|
export function configureIdempotency(next: IdempotencyConfig): void {
|
|
@@ -158,7 +184,7 @@ export function getIdempotencyStore(): IdempotencyStore {
|
|
|
158
184
|
/** Test-only. A process configures its idempotency once at boot and never reconfigures it. */
|
|
159
185
|
export function resetIdempotency(): void {
|
|
160
186
|
config = DEFAULT_IDEMPOTENCY_CONFIG;
|
|
161
|
-
defaultStore =
|
|
187
|
+
defaultStore = memoryIdempotencyStore();
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
/**
|
|
@@ -190,19 +216,30 @@ export interface IdempotentOutcome<T> {
|
|
|
190
216
|
* X_IDEMPOTENCY_CONFLICT for a payload mismatch, X_IDEMPOTENCY_CONFLICT for a duplicate still in
|
|
191
217
|
* flight.
|
|
192
218
|
*
|
|
193
|
-
* **Everything `run()` throws is treated as possibly-committed.** `
|
|
219
|
+
* **Everything `run()` throws is treated as possibly-committed.** `guardAction()` and `validateInput`
|
|
194
220
|
* both run before this gate is reached (`invoke.ts`), so by the time `run` is called the only
|
|
195
221
|
* things left are the handler and `validateOutput` — and the second of those throws *after* the
|
|
196
222
|
* first has committed. Releasing the reservation there is what turned a rounding change in an
|
|
197
223
|
* output schema into a second charge: `X_OUTPUT_INVALID` dropped the record, and the client's
|
|
198
224
|
* automatic retry re-ran a handler that had already taken the money. So the failure is SETTLED
|
|
199
|
-
* and replayed, and `release` is reserved for a pre-handler failure —
|
|
225
|
+
* and replayed, and `release` is reserved for a pre-handler failure — `beforeRun` is the only one.
|
|
200
226
|
*/
|
|
227
|
+
export interface WithIdempotencyOptions {
|
|
228
|
+
/**
|
|
229
|
+
* Called only for a reservation this call CREATED, before `run` — `invoke`'s rate-limit spend
|
|
230
|
+
* for a key its pre-parse peek took for a replay (a stale record reclaimed in between). A throw
|
|
231
|
+
* here is a PRE-handler failure, so the reservation is released, never settled: a refusal is not
|
|
232
|
+
* the key's outcome, and a retry once the bucket refills must still be able to run.
|
|
233
|
+
*/
|
|
234
|
+
readonly beforeRun?: () => Promise<void>;
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
export async function withIdempotency<T>(
|
|
202
238
|
store: IdempotencyStore,
|
|
203
239
|
key: string,
|
|
204
240
|
input: unknown,
|
|
205
241
|
run: () => Promise<T>,
|
|
242
|
+
options: WithIdempotencyOptions = {},
|
|
206
243
|
): Promise<IdempotentOutcome<T>> {
|
|
207
244
|
const requestHash = keyedFingerprint(input, IDEMPOTENCY_FINGERPRINT_PURPOSE);
|
|
208
245
|
const { record, created } = await store.reserve(key, requestHash);
|
|
@@ -210,9 +247,28 @@ export async function withIdempotency<T>(
|
|
|
210
247
|
if (!created) {
|
|
211
248
|
if (record.status === 'in-flight') throw new IdempotencyConflictError(key, 'in-flight');
|
|
212
249
|
if (record.status === 'failed') throw new IdempotencyReplayedFailureError(key, record.failure);
|
|
250
|
+
// The stored value lost a credential at rest: serving it would hand `[redacted]` back as the
|
|
251
|
+
// answer, so the replay is refused — and, like any replay, spends and runs nothing.
|
|
252
|
+
if (record.redacted === true) throw new IdempotentReplayRedactedError(key);
|
|
213
253
|
// The stored value is the previous return of this very handler.
|
|
214
254
|
return { value: record.value as T, replayed: true };
|
|
215
255
|
}
|
|
256
|
+
if (options.beforeRun !== undefined) {
|
|
257
|
+
try {
|
|
258
|
+
await options.beforeRun();
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// THIS reservation's id: one reclaimed while `beforeRun` waited is no longer ours to drop.
|
|
261
|
+
// A release the store refuses never replaces the refusal: the caller is owed the 429, not a
|
|
262
|
+
// driver error. The reservation then stays in flight until the window lapses (an autocommit
|
|
263
|
+
// one is never reclaimed early), so a retry inside it is a 409 — logged, so it is findable.
|
|
264
|
+
try {
|
|
265
|
+
await store.release(key, record.id);
|
|
266
|
+
} catch (storeError) {
|
|
267
|
+
logger.error('action.idempotency.release-refused', { key, error: storeError });
|
|
268
|
+
}
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
216
272
|
let value: T;
|
|
217
273
|
try {
|
|
218
274
|
value = await run();
|
|
@@ -230,7 +286,11 @@ export async function withIdempotency<T>(
|
|
|
230
286
|
// Inside a transaction the shared store settles on that transaction's connection, so "settled"
|
|
231
287
|
// and "the write is durable" are one COMMIT — and a settle that lost its reservation THROWS
|
|
232
288
|
// (`X_IDEMPOTENCY_RESERVATION_LOST`), which is the rollback of an attempt a retry replaced.
|
|
233
|
-
|
|
289
|
+
//
|
|
290
|
+
// The CALLER gets `value` whole; the store gets the resting copy, so an answer carrying a
|
|
291
|
+
// credential is never written down in plaintext for the window (#591).
|
|
292
|
+
const resting = restingAnswer(value);
|
|
293
|
+
await store.settle(key, resting.value, record.id, resting.redacted);
|
|
234
294
|
return { value, replayed: false };
|
|
235
295
|
}
|
|
236
296
|
|
package/src/index.ts
CHANGED
|
@@ -7,44 +7,18 @@
|
|
|
7
7
|
* framework died; there is exactly one here, structurally.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// Imported bare, and the one module `sideEffects` lists: a browser that rebuilds a SERVER's refusal
|
|
11
|
+
// (`problemError`, by code) constructs nothing from `errors.ts`, and still has to render the title
|
|
12
|
+
// this package registered. `SIDE_EFFECTS_ANCHORS` carries the argument and `bun run side-effects`
|
|
13
|
+
// enforces it.
|
|
14
|
+
import './error-titles';
|
|
15
|
+
|
|
10
16
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* one flight map, one gate, one backoff curve for the whole framework — and so is the pipeline
|
|
15
|
-
* itself: it shipped as a byte-identical copy here and in `@ultimat3/query`, and
|
|
16
|
-
* two tier-3 packages may not import each other, so the one copy lives at tier 0.
|
|
17
|
-
*
|
|
18
|
-
* Re-exported rather than re-declared, so every name is importable from this package exactly as
|
|
19
|
-
* before. `isSuperseded` is core's too: reading a fenced answer is the point of installing a
|
|
20
|
-
* flight, and it should not cost a second import.
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* The SHARED store, and the only one an app running more than one replica may install. The
|
|
24
|
-
* statements are exported beside it because the table is applied the way `SQL_JOBS_TABLE` is —
|
|
25
|
-
* by `x db up` in development and by the release-phase `ROLE=migrate` in production.
|
|
26
|
-
*/
|
|
27
|
-
export type {
|
|
28
|
-
ClientFlight,
|
|
29
|
-
ClientFlightOptions,
|
|
30
|
-
ClientRetry,
|
|
31
|
-
FlightKeyOptions,
|
|
32
|
-
FlightPlan,
|
|
33
|
-
PgExecutor,
|
|
34
|
-
WireAnswer,
|
|
35
|
-
} from '@ultimat3/core';
|
|
36
|
-
export {
|
|
37
|
-
createClientFlight,
|
|
38
|
-
DEFAULT_CLIENT_RETRY,
|
|
39
|
-
isSuperseded,
|
|
40
|
-
isTransientFailure,
|
|
41
|
-
} from '@ultimat3/core';
|
|
42
|
-
/**
|
|
43
|
-
* `toBucket` is `@ultimat3/http`'s — http owns `Bucket` and the limiter maths, and `action` and
|
|
44
|
-
* `query` are the same tier, so a copy in either is a second answer for the other. Re-exported
|
|
45
|
-
* here, not re-implemented, so an action file still reaches it through one import.
|
|
17
|
+
* Types only: the barrel's own declarations name them. Flight control's VALUES are imported from
|
|
18
|
+
* `@ultimat3/core`, which holds the one pipeline for action and query alike; a value re-export
|
|
19
|
+
* here is `X_HELPER_COPY`.
|
|
46
20
|
*/
|
|
47
|
-
export {
|
|
21
|
+
export type { ClientFlight, ClientRetry, PgExecutor } from '@ultimat3/core';
|
|
48
22
|
/** Re-exported so an `action` file needs one import, not two. Same object as schema's. */
|
|
49
23
|
export type { Infer } from '@ultimat3/schema';
|
|
50
24
|
export { t } from '@ultimat3/schema';
|
|
@@ -60,7 +34,6 @@ export type {
|
|
|
60
34
|
ActionRowArgs,
|
|
61
35
|
AnyAction,
|
|
62
36
|
InvokeOptions,
|
|
63
|
-
McpAnnotationHints,
|
|
64
37
|
McpDescriptorMeta,
|
|
65
38
|
} from './action';
|
|
66
39
|
export { action, describeAction, isAction } from './action';
|
|
@@ -79,25 +52,21 @@ export { apiDeclaration } from './api-declaration';
|
|
|
79
52
|
/**
|
|
80
53
|
* The audit seam. `AuditSink` is the whole extension point: the framework supplies the record
|
|
81
54
|
* and never the row. `audit-gate.ts` stays unexported — the sink has one caller, and that
|
|
82
|
-
* absence is what keeps it one.
|
|
55
|
+
* absence is what keeps it one. The record and the sink are `@ultimat3/core`'s, re-exported as
|
|
56
|
+
* TYPES only because this barrel's sinks name them; the slot (`setAuditSink`) is core's alone.
|
|
83
57
|
*/
|
|
84
|
-
export type {
|
|
85
|
-
AuditFailure,
|
|
86
|
-
AuditOutcome,
|
|
87
|
-
AuditRecord,
|
|
88
|
-
AuditSink,
|
|
89
|
-
} from './audit';
|
|
90
|
-
export { getAuditSink, resetAuditSink, setAuditSink } from './audit';
|
|
58
|
+
export type { AuditRecord, AuditSink } from './audit';
|
|
91
59
|
export { AUDIT_INPUT_MAX_DEPTH, auditableInput, UNREPRESENTABLE } from './audit-input';
|
|
92
60
|
export type { MemoryAuditSink, MemoryAuditSinkOptions } from './audit-memory';
|
|
93
61
|
export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
|
|
94
62
|
/**
|
|
95
|
-
* The DURABLE sink, and the only one an app that must keep its trail may install. The
|
|
96
|
-
*
|
|
97
|
-
* boot, never by an app migration.
|
|
63
|
+
* The DURABLE sink, and the only one an app that must keep its trail may install. The table's DDL
|
|
64
|
+
* is exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
|
|
65
|
+
* boot, never by an app migration. The insert is not: it is the sink's own statement, and an
|
|
66
|
+
* export with no reader outside this package is `X_SQL_EXPORT_UNREAD` (`bun run sql-export-readers`).
|
|
98
67
|
*/
|
|
99
68
|
export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
|
|
100
|
-
export { postgresAuditSink,
|
|
69
|
+
export { postgresAuditSink, SQL_AUDIT_TABLE } from './audit-postgres';
|
|
101
70
|
export type {
|
|
102
71
|
ActionLike,
|
|
103
72
|
ActionMap,
|
|
@@ -112,14 +81,6 @@ export type { ContractTest, ContractTestOptions } from './contract-test';
|
|
|
112
81
|
export { anonymousCtx, contractTestsFor, policyTestStubFor } from './contract-test';
|
|
113
82
|
export type { Api, ApiDef, ApiModule, ApiModules } from './define-api';
|
|
114
83
|
export { defineApi } from './define-api';
|
|
115
|
-
/**
|
|
116
|
-
* The compat window a retirement gets. Versioning itself is NOT here and never will be: two
|
|
117
|
-
* versions of one action side by side is two deployments behind one ingress (axiom 7), not a
|
|
118
|
-
* router feature. `renderDeprecation` is exported so a plain `route` can announce the same pair
|
|
119
|
-
* of headers the action projection does.
|
|
120
|
-
*/
|
|
121
|
-
export type { Deprecation, DeprecationField, DeprecationRender } from './deprecation';
|
|
122
|
-
export { recordDeprecatedCall, renderDeprecation } from './deprecation';
|
|
123
84
|
export type { IdempotencyConflictReason, IdempotencyKeyProblem, RemoteFailure } from './errors';
|
|
124
85
|
export {
|
|
125
86
|
ActionDeniedError,
|
|
@@ -138,6 +99,7 @@ export {
|
|
|
138
99
|
IdempotencyReplayedFailureError,
|
|
139
100
|
IdempotencyReservationLostError,
|
|
140
101
|
IdempotencyStatusUnknownError,
|
|
102
|
+
IdempotentReplayRedactedError,
|
|
141
103
|
InputInvalidError,
|
|
142
104
|
MutatorNotIdempotentError,
|
|
143
105
|
OutputInvalidError,
|
|
@@ -151,8 +113,6 @@ export {
|
|
|
151
113
|
} from './errors-http';
|
|
152
114
|
export type { OpenApiOperation } from './http';
|
|
153
115
|
export {
|
|
154
|
-
BUILD_ID_HEADER,
|
|
155
|
-
IDEMPOTENCY_HEADER,
|
|
156
116
|
operationTagOf,
|
|
157
117
|
REPLAYED_HEADER,
|
|
158
118
|
toOpenApiOperation,
|
|
@@ -191,34 +151,27 @@ export {
|
|
|
191
151
|
withIdempotency,
|
|
192
152
|
} from './idempotency';
|
|
193
153
|
export { idempotencyKeyFor, MAX_IDEMPOTENCY_KEY_LENGTH } from './idempotency-key';
|
|
194
|
-
export type {
|
|
154
|
+
export type {
|
|
155
|
+
MemoryIdempotencyStore,
|
|
156
|
+
MemoryIdempotencyStoreOptions,
|
|
157
|
+
} from './idempotency-memory';
|
|
195
158
|
export {
|
|
196
159
|
DEFAULT_IDEMPOTENCY_WINDOW_MS,
|
|
197
160
|
DEFAULT_MAX_IDEMPOTENCY_KEYS,
|
|
198
|
-
|
|
161
|
+
memoryIdempotencyStore,
|
|
199
162
|
} from './idempotency-memory';
|
|
200
163
|
export type {
|
|
201
164
|
PostgresIdempotencyStore,
|
|
202
165
|
PostgresIdempotencyStoreOptions,
|
|
203
166
|
} from './idempotency-postgres';
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
SQL_IDEMPOTENCY_FAIL,
|
|
207
|
-
SQL_IDEMPOTENCY_GET,
|
|
208
|
-
SQL_IDEMPOTENCY_PURGE,
|
|
209
|
-
SQL_IDEMPOTENCY_RELEASE,
|
|
210
|
-
SQL_IDEMPOTENCY_RESERVE,
|
|
211
|
-
SQL_IDEMPOTENCY_SETTLE,
|
|
212
|
-
SQL_IDEMPOTENCY_TABLE,
|
|
213
|
-
} from './idempotency-postgres';
|
|
167
|
+
/** The table's DDL alone: the boot applies it. The store's own statements stay its own. */
|
|
168
|
+
export { postgresIdempotencyStore, SQL_IDEMPOTENCY_TABLE } from './idempotency-postgres';
|
|
214
169
|
/** The one execution path. `defOf` stays unexported — that is the enforcement. */
|
|
215
170
|
export { actionName, invoke } from './invoke';
|
|
216
171
|
export type { ActionJobHandle } from './job-handle';
|
|
217
172
|
export { toJobHandle } from './job-handle';
|
|
218
173
|
export type { JsonSchemaObject } from './json-schema';
|
|
219
174
|
export { jsonSchemaOf } from './json-schema';
|
|
220
|
-
export type { McpInvokeOptions, McpToolDescriptor } from './mcp-tool';
|
|
221
|
-
export { isExposed, toMcpTool, toMcpTools } from './mcp-tool';
|
|
222
175
|
export type {
|
|
223
176
|
LocalTable,
|
|
224
177
|
LocalTableName,
|
|
@@ -230,7 +183,7 @@ export type {
|
|
|
230
183
|
} from './mutator';
|
|
231
184
|
export { custom, isMutator, mutator } from './mutator';
|
|
232
185
|
export type { ActionPath } from './naming';
|
|
233
|
-
export { derivePath, inputSchemaName, outputSchemaName
|
|
186
|
+
export { derivePath, inputSchemaName, outputSchemaName } from './naming';
|
|
234
187
|
export type { BuildOpenApiOptions, OpenApiDocument, OpenApiInfo } from './openapi';
|
|
235
188
|
export { buildOpenApi, serializeOpenApi } from './openapi';
|
|
236
189
|
export {
|
|
@@ -246,19 +199,12 @@ export {
|
|
|
246
199
|
export { explainActionPathMiss } from './path-style-miss';
|
|
247
200
|
export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
|
|
248
201
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
202
|
+
* The one authz gate, named after what it guards. The display label (`policyCapability`), what a
|
|
203
|
+
* report MATCHES on (`policyPermissions`) and what `toRoute` derives `meta.auth` from
|
|
204
|
+
* (`admitsAnonymous`) are `@ultimat3/policy`'s, and the anonymous → `null` mapping (`actorOf`) is
|
|
205
|
+
* `@ultimat3/core`'s — imported from there, so `query` reads the same walk through one import path.
|
|
253
206
|
*/
|
|
254
|
-
export {
|
|
255
|
-
actorOf,
|
|
256
|
-
admitsAnonymous,
|
|
257
|
-
guard,
|
|
258
|
-
guardBeforeInput,
|
|
259
|
-
policyCapability,
|
|
260
|
-
policyPermissions,
|
|
261
|
-
} from './policy-gate';
|
|
207
|
+
export { guardAction, guardActionBeforeInput } from './policy-gate';
|
|
262
208
|
export {
|
|
263
209
|
actionHttpPath,
|
|
264
210
|
configureActionPathStyle,
|
|
@@ -267,7 +213,7 @@ export {
|
|
|
267
213
|
listActions,
|
|
268
214
|
registerAction,
|
|
269
215
|
registerActions,
|
|
270
|
-
|
|
216
|
+
resetActions,
|
|
271
217
|
} from './registry';
|
|
272
218
|
export { requestDeadlineMs } from './request-deadline';
|
|
273
219
|
/**
|
package/src/invoke.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one invocation core:
|
|
3
|
-
* output. The declaration lives in this module's private store, so `handle` is
|
|
2
|
+
* The one invocation core: ask who is calling, spend the rate limit, parse input, evaluate
|
|
3
|
+
* policy, run the handler, parse output. The declaration lives in this module's private store, so `handle` is
|
|
4
4
|
* unreachable from anywhere else — HTTP, MCP, jobs and `.as()` hand `invoke` a
|
|
5
5
|
* payload, and none of them can become a second execution path.
|
|
6
6
|
*
|
|
7
7
|
* `audit: true` wraps that path, it never forks it: `execute` is the same body either way, and
|
|
8
8
|
* the audited branch only observes it. Wrapping rather than hooking is what lets a DENIED attempt
|
|
9
|
-
* be recorded at all — `
|
|
9
|
+
* be recorded at all — `guardAction` throws before `handle`, so nothing an app writes around its own
|
|
10
10
|
* handler could ever see one.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { Ctx } from '@ultimat3/core';
|
|
14
14
|
import {
|
|
15
|
+
actorOf,
|
|
15
16
|
anonymousActor,
|
|
16
|
-
|
|
17
|
+
ctxOf,
|
|
17
18
|
isUltimateError,
|
|
18
19
|
runWithContext,
|
|
19
20
|
tryUseContext,
|
|
@@ -34,7 +35,8 @@ import { bustAfterCommit } from './cache-gate';
|
|
|
34
35
|
import { ActionForeignError, ActionUnregisteredError } from './errors';
|
|
35
36
|
import { getIdempotencyStore, withIdempotency } from './idempotency';
|
|
36
37
|
import { idempotencyKeyFor } from './idempotency-key';
|
|
37
|
-
import {
|
|
38
|
+
import { guardAction, guardActionBeforeInput } from './policy-gate';
|
|
39
|
+
import { spendActionLimit, withCallerAddress, withJobFrame } from './rate-limit-gate';
|
|
38
40
|
import { parsedOrNothing, validateInput, validateOutput } from './validate';
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -77,6 +79,15 @@ export function invoke(
|
|
|
77
79
|
raw: unknown,
|
|
78
80
|
options: InvokeOptions = {},
|
|
79
81
|
): Promise<unknown> {
|
|
82
|
+
// The address a surface handed over becomes the one every nested call inherits — an agent's
|
|
83
|
+
// tool calls are keyed by the visitor who asked, not shared by every anonymous visitor.
|
|
84
|
+
// A job run opens a fresh frame instead: no visitor's address follows work into the worker.
|
|
85
|
+
return options.surface === 'job'
|
|
86
|
+
? withJobFrame(() => invokeAs(target, raw, options))
|
|
87
|
+
: withCallerAddress(options.clientAddress, () => invokeAs(target, raw, options));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function invokeAs(target: AnyAction, raw: unknown, options: InvokeOptions): Promise<unknown> {
|
|
80
91
|
if (options.actor === undefined) {
|
|
81
92
|
// INSTALLED, never only handed over. An explicit `ctx` used to be passed to `core` and to
|
|
82
93
|
// nothing else, so everything downstream that reads the ambient context — most importantly
|
|
@@ -96,7 +107,7 @@ export function invoke(
|
|
|
96
107
|
const run = (): Promise<unknown> => core(target, raw, useContext(), options);
|
|
97
108
|
const base = options.ctx ?? tryUseContext();
|
|
98
109
|
return base === undefined
|
|
99
|
-
? runWithContext(
|
|
110
|
+
? runWithContext(ctxOf(patch), run)
|
|
100
111
|
: runWithContext(base, () => withChildContext(patch, run));
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -128,7 +139,8 @@ async function core(
|
|
|
128
139
|
const draft = {
|
|
129
140
|
// When the attempt began, from the context's clock — never `new Date()`.
|
|
130
141
|
at: ctx.now(),
|
|
131
|
-
|
|
142
|
+
name,
|
|
143
|
+
primitive: 'action',
|
|
132
144
|
// The brand `mutator()` stamps, read structurally — the same read `describeAction` makes,
|
|
133
145
|
// and for the same reason: importing `isMutator` would point this module at the one that
|
|
134
146
|
// imports it, for a check that needs the brand and not the predicate.
|
|
@@ -163,7 +175,7 @@ async function core(
|
|
|
163
175
|
/**
|
|
164
176
|
* The span covers the WHOLE invocation, not just `handle`. Wrapping the handler alone reported
|
|
165
177
|
* 40ms for an action whose p99 was 2s, because `def.row()` — the loader a row-level policy needs
|
|
166
|
-
* — ran outside it, along with the input parse and `
|
|
178
|
+
* — ran outside it, along with the input parse and `guardAction()`. The 1.96s was inside no span at
|
|
167
179
|
* all, so the only reading available was "framework overhead" and the only fix was hand
|
|
168
180
|
* instrumentation. One span, one extent, and the attributes that make it answerable.
|
|
169
181
|
*
|
|
@@ -219,15 +231,42 @@ async function perform(
|
|
|
219
231
|
const surface = options.surface ?? 'server';
|
|
220
232
|
// Who is asking is decided before what they sent is read: a caller the policy refuses whatever
|
|
221
233
|
// the input is gets 403/401 here, not a 400 whose issues describe this action's input schema.
|
|
222
|
-
// Only the actor half runs — a predicate over `input` or `row` waits for `
|
|
234
|
+
// Only the actor half runs — a predicate over `input` or `row` waits for `guardAction` below.
|
|
223
235
|
try {
|
|
224
|
-
|
|
236
|
+
guardActionBeforeInput(def.policy, { actor: actorOf(ctx), ctx, action: name }, surface);
|
|
225
237
|
} catch (denial) {
|
|
226
238
|
// The audit record still names what was attempted — the denial is already decided, so parsing
|
|
227
239
|
// now tells the CALLER nothing, and an unparseable payload is a record with no input.
|
|
228
240
|
if (def.audit === true) trace.input = await parsedOrNothing(def.input, raw);
|
|
229
241
|
throw denial;
|
|
230
242
|
}
|
|
243
|
+
// The ONE place a declared `rateLimit:` is spent, so every surface draws on the same bucket.
|
|
244
|
+
// HERE — after the actor half (a refused caller learns that, not a 429), BEFORE the parse, the
|
|
245
|
+
// row load and `guardAction`: spent later, a flood of invalid input (400) or row-denied calls (403)
|
|
246
|
+
// was never refused, and every 429 had already paid for a row load.
|
|
247
|
+
const spend = (): Promise<void> =>
|
|
248
|
+
spendActionLimit(name, def.rateLimit, ctx, {
|
|
249
|
+
surface,
|
|
250
|
+
clientAddress: options.clientAddress,
|
|
251
|
+
onRateLimit: options.onRateLimit,
|
|
252
|
+
});
|
|
253
|
+
const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
|
|
254
|
+
const store = key === null ? undefined : (options.store ?? getIdempotencyStore());
|
|
255
|
+
// The namespaced key, not the caller's: the same key under two actions — or from two callers
|
|
256
|
+
// — is two keys, and an audit row keyed on the raw header would collide across both. The
|
|
257
|
+
// ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
|
|
258
|
+
if (key !== null) trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
|
|
259
|
+
// A replay is its stored answer and spends nothing — a 429 in its place told a client its
|
|
260
|
+
// committed write had not happened. A key the store already holds is a replay (or a duplicate in
|
|
261
|
+
// flight, answered 409); only a key it does not hold spends here. The peek can be stale both
|
|
262
|
+
// ways, so `withIdempotency` meters a reservation it CREATED that this peek did not pay for.
|
|
263
|
+
const spentEarly =
|
|
264
|
+
trace.idempotencyKey === null ||
|
|
265
|
+
store === undefined ||
|
|
266
|
+
def.rateLimit === undefined ||
|
|
267
|
+
(await store.get(trace.idempotencyKey)) === undefined;
|
|
268
|
+
if (spentEarly) await spend();
|
|
269
|
+
|
|
231
270
|
const input = await validateInput(def.input, raw, name);
|
|
232
271
|
trace.input = input;
|
|
233
272
|
// The one place a row-level rule gets its row. Once per invocation, never per row:
|
|
@@ -236,7 +275,7 @@ async function perform(
|
|
|
236
275
|
// action with no loader hands the rule `null` — unchanged, and never a silent allow,
|
|
237
276
|
// because a rule that reads `row` has to decide what `null` means.
|
|
238
277
|
const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
|
|
239
|
-
|
|
278
|
+
guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
|
|
240
279
|
|
|
241
280
|
// Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
|
|
242
281
|
// one shape on the wire, first call and every retry. No span of its own: `execute` above holds
|
|
@@ -246,18 +285,14 @@ async function perform(
|
|
|
246
285
|
return validateOutput(def.output, produced, name);
|
|
247
286
|
};
|
|
248
287
|
|
|
249
|
-
const key = def.idempotent === true ? (options.idempotencyKey ?? null) : null;
|
|
250
288
|
let value: unknown;
|
|
251
289
|
let wrote = true;
|
|
252
|
-
if (
|
|
290
|
+
if (store === undefined || trace.idempotencyKey === null) {
|
|
253
291
|
value = await run();
|
|
254
292
|
} else {
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// ACTOR comes from `ctx`, which `invoke` installed, so every surface scopes identically.
|
|
259
|
-
trace.idempotencyKey = idempotencyKeyFor(name, key, ctx.actor);
|
|
260
|
-
const outcome = await withIdempotency(store, trace.idempotencyKey, input, run);
|
|
293
|
+
const outcome = await withIdempotency(store, trace.idempotencyKey, input, run, {
|
|
294
|
+
...(spentEarly ? {} : { beforeRun: spend }),
|
|
295
|
+
});
|
|
261
296
|
if (outcome.replayed) options.onReplay?.();
|
|
262
297
|
trace.replayed = outcome.replayed;
|
|
263
298
|
wrote = !outcome.replayed;
|