@ultimat3/action 1.1.0 → 2.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 +387 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +78 -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 +17 -10
- 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/README.md
CHANGED
|
@@ -42,7 +42,7 @@ imports one package for the primitive and its schemas, never two.
|
|
|
42
42
|
import { action, t } from '@ultimat3/action';
|
|
43
43
|
|
|
44
44
|
export const publishPost = action({
|
|
45
|
-
input: t.object({ postId: t.uuid, notify: t.boolean.default(true) }),
|
|
45
|
+
input: t.object({ postId: t.uuid, orgId: t.uuid, notify: t.boolean.default(true) }),
|
|
46
46
|
output: PostView,
|
|
47
47
|
policy: can('post:publish', ({ input, actor }) => ownsPost(actor, input.postId)),
|
|
48
48
|
cache: { invalidates: [tag.post, tag.feed] },
|
|
@@ -50,7 +50,7 @@ export const publishPost = action({
|
|
|
50
50
|
idempotent: true,
|
|
51
51
|
async handle({ input, ctx }) {
|
|
52
52
|
const post = await ctx.posts.publish(input.postId);
|
|
53
|
-
if (input.notify) await notifySubscribers.enqueue({ postId: post.id });
|
|
53
|
+
if (input.notify) await notifySubscribers.enqueue({ postId: post.id, orgId: input.orgId });
|
|
54
54
|
return post;
|
|
55
55
|
},
|
|
56
56
|
});
|
|
@@ -75,21 +75,33 @@ export const api = defineApi({
|
|
|
75
75
|
export type Api = typeof api;
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
Six keys, all optional:
|
|
79
|
+
|
|
78
80
|
| Key | Goes to | Why |
|
|
79
81
|
|---|---|---|
|
|
80
82
|
| `actions` | the action registry | the primitive |
|
|
81
83
|
| `mutators` | the action registry | a mutator IS an action, on the same authz path |
|
|
82
84
|
| `llm` | the action registry | `llm()` returns an action, not a ninth primitive |
|
|
83
85
|
| `queries` | `@ultimat3/query`'s registry, via core's registrar table | `query` is on this tier, so importing it here would be a build error |
|
|
86
|
+
| `jobs` | `@ultimat3/jobs`' registry, the same way | the export name becomes the durable queue key — a job row names the handle, not a counter |
|
|
87
|
+
| `tasks` | `@ultimat3/jobs`' registry, the same way | handing a task over is what names its cron after its export |
|
|
88
|
+
|
|
89
|
+
Jobs register **before** tasks: a task's descriptor lists the jobs it enqueues by name, so the
|
|
90
|
+
other order would read the queue keys one boot step before they were assigned. `Api` carries all
|
|
91
|
+
four maps back — `api.actions`, `api.queries`, `api.jobs`, `api.tasks` — keyed by the name
|
|
92
|
+
registration stamped.
|
|
84
93
|
|
|
85
94
|
Names come from **export names** — that is what makes the path, the tool name and the
|
|
86
95
|
OpenAPI `operationId` derivable everywhere without a second declaration. Registration
|
|
87
96
|
stamps the name onto the action the module exported, so the binding you imported is the
|
|
88
97
|
one that projects; a projection attempted before boot is `X_ACTION_UNREGISTERED`. Two
|
|
89
|
-
features exporting one name collide with `X_ACTION_DUPLICATE` rather than merging
|
|
98
|
+
features exporting one name collide with `X_ACTION_DUPLICATE` rather than merging, and two
|
|
99
|
+
names deriving one route collide with `X_ACTION_PATH_DUPLICATE` — `pluralize` leaves a trailing
|
|
100
|
+
`s` alone, so `archiveOrder` and `archiveOrders` are two exports and one `POST /api/orders/archive`.
|
|
90
101
|
|
|
91
|
-
`registerActions`
|
|
92
|
-
|
|
102
|
+
`registerActions` is what `defineApi` composes for the three action-shaped keys; the other three
|
|
103
|
+
go through core's `primitiveRegistrar(kind)`, because this package may not import `@ultimat3/query`
|
|
104
|
+
or `@ultimat3/jobs` sideways. An app calling either directly is a second path.
|
|
93
105
|
|
|
94
106
|
## Call it — `rpc`
|
|
95
107
|
|
|
@@ -107,7 +119,7 @@ a page's module graph free of any edge to a feature's implementation.
|
|
|
107
119
|
## Path derivation
|
|
108
120
|
|
|
109
121
|
First camelCase word is the verb; the rest is the resource, last word pluralized,
|
|
110
|
-
kebab-cased. The **MCP tool name is not derived** — it is the export name verbatim, because
|
|
122
|
+
kebab-cased. The **MCP tool name is not derived at all** — it is the export name verbatim, because
|
|
111
123
|
that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
|
|
112
124
|
|
|
113
125
|
| Action | Route | MCP tool |
|
|
@@ -117,6 +129,18 @@ that is what `defineAppMcp`'s `scopes:` and a `tools/call` have to spell.
|
|
|
117
129
|
| `likePost` | `POST /api/posts/like` | `likePost` |
|
|
118
130
|
| `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
|
|
119
131
|
|
|
132
|
+
**One name, four surfaces** — `.tool().name`, `openapi.json`'s `x-ultimate.mcpTool`,
|
|
133
|
+
`describeAction().mcp.tool` (what `x actions describe --json`, `x actions list --json`, the
|
|
134
|
+
`actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp`
|
|
135
|
+
serves. It was two until 2026-08: a `toToolName()` here snake_cased the first three to
|
|
136
|
+
`publish_post` while the server answered only `publishPost`, so an agent that read the published
|
|
137
|
+
contract called a tool that does not exist. `toToolName` is **deleted**, not deprecated — a second
|
|
138
|
+
derivation is a second name. `mcp-tool.test.ts`'s "one name per action, on every surface" is what
|
|
139
|
+
keeps it that way.
|
|
140
|
+
|
|
141
|
+
`x.manifest.json` is **not** one of the four: `ActionFact.mcp` is `{ expose, description? }`, so
|
|
142
|
+
the manifest never carried a tool name and was never wrong about one.
|
|
143
|
+
|
|
120
144
|
## One invocation core
|
|
121
145
|
|
|
122
146
|
`invoke()` is the only execution path: **parse input → evaluate policy → handle →
|
|
@@ -135,6 +159,11 @@ no `.def`. A second authz path cannot be written without deleting that store.
|
|
|
135
159
|
| handle | whatever the handler throws |
|
|
136
160
|
| parse output | `X_OUTPUT_INVALID` — and fields the schema never declared are dropped |
|
|
137
161
|
|
|
162
|
+
`cache: { invalidates }` fans out **after** the handler commits, so it never fails it: a
|
|
163
|
+
fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
|
|
164
|
+
`action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
|
|
165
|
+
call busts nothing; the first call already did.
|
|
166
|
+
|
|
138
167
|
Registering an action without `policy:` throws `X_ACTION_POLICY_MISSING`; there is
|
|
139
168
|
no bypass flag. A look-alike that never came out of `action()` is `X_ACTION_FOREIGN`.
|
|
140
169
|
|
|
@@ -185,25 +214,257 @@ LocalTables { posts: PostRow } }`.
|
|
|
185
214
|
name-sorted, and reads no clock, env or random source — same registry ⇒ same bytes ⇒
|
|
186
215
|
`x verify` can diff the spec and fail on `X_CONTRACT_DRIFT`.
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
217
|
+
## `rateLimit:` is the enforced limit
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
rateLimit: { limit: 5, windowMs: 600_000 }, // 5 held, one back every two minutes
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
One declaration, three places it lands: the bucket the limiter runs on (named after the action,
|
|
224
|
+
registered by `@ultimat3/http`'s `withRouteBuckets` when the route is mounted), the
|
|
225
|
+
`ratelimit-limit` header the caller reads, and `x-ultimate.rateLimit` in the OpenAPI operation.
|
|
226
|
+
`toBucket` is the only conversion — `capacity: limit`, `refillPerSecond: limit / (windowMs / 1000)`
|
|
227
|
+
— so the published numbers and the enforced ones cannot differ. It lives in `@ultimat3/http`,
|
|
228
|
+
beside `Bucket` and the limiter maths, and is re-exported here: `@ultimat3/query` needs the same
|
|
229
|
+
conversion and is the same tier, so a copy in either package would be a second answer for the
|
|
230
|
+
other. A pair the limiter cannot run on is `X_RATE_LIMIT_INVALID`, at projection. An action that declares nothing
|
|
231
|
+
stays on the `default` bucket. An app that also configures `http.rateLimit.buckets.<actionName>`
|
|
232
|
+
with **different** numbers is `X_RATE_LIMIT_BUCKET_CONFLICT` at boot: neither source wins, because
|
|
233
|
+
the loser would go on being read as enforced.
|
|
234
|
+
|
|
235
|
+
## `idempotent:` — and where its records live
|
|
236
|
+
|
|
237
|
+
`idempotent: true` + an `Idempotency-Key` header replays the first **outcome**
|
|
238
|
+
(`x-ultimate-replayed: 1`); a duplicate still in flight, or a reused key with a new payload, is
|
|
239
|
+
`X_IDEMPOTENCY_CONFLICT`.
|
|
240
|
+
|
|
241
|
+
**A record belongs to one caller.** The key is namespaced by action *and* by actor
|
|
242
|
+
(`idempotencyKeyFor`), so two callers sending the same header value hold two records — the same
|
|
243
|
+
value under one action used to be one shared record, which replayed one caller's response to
|
|
244
|
+
another. A **blank** `Idempotency-Key:` is `X_IDEMPOTENCY_KEY_INVALID`, never read as "no key":
|
|
245
|
+
`Headers.get()` answers `''` and not `null`, so a blank header was itself a shared key, and the
|
|
246
|
+
quiet reading — run without idempotency — loses the retry protection exactly when a client's key
|
|
247
|
+
interpolation broke. Omit the header to run un-keyed; the published `maxLength: 255` is enforced
|
|
248
|
+
by the same refusal. An anonymous caller has no identity to narrow to, so anonymous callers of a
|
|
249
|
+
public idempotent action still share a key space: a UUID key is what keeps them apart.
|
|
250
|
+
|
|
251
|
+
**A failed first attempt is replayed too, not re-run.** `guard()` and the input parse both happen
|
|
252
|
+
*before* the idempotency gate, so everything it can see throw is post-authorization and possibly
|
|
253
|
+
post-commit: a handler that took the money and then failed its own `output:` schema is the case.
|
|
254
|
+
The reservation is settled as a FAILURE and the retry re-throws it under the first attempt's own
|
|
255
|
+
code. Releasing it there is what made idempotency the cause of a double charge.
|
|
256
|
+
|
|
257
|
+
**Where the records live is declared, and refused at registration.** The default store is process
|
|
258
|
+
memory — bounded, swept on a 24h window, and `scope: 'process'`. An app on more than one replica
|
|
259
|
+
must say so and bring a store that can keep it, or the retry that lands on another replica finds
|
|
260
|
+
no record and runs the handler again:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
// boot, before registerActions()
|
|
264
|
+
import {
|
|
265
|
+
configureIdempotency,
|
|
266
|
+
postgresIdempotencyStore,
|
|
267
|
+
setIdempotencyStore,
|
|
268
|
+
} from '@ultimat3/action';
|
|
269
|
+
|
|
270
|
+
setIdempotencyStore(postgresIdempotencyStore({ executor: Bun.sql }));
|
|
271
|
+
configureIdempotency({ scope: 'shared' });
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`configureIdempotency({ scope: 'shared' })` over a per-process store — or over a store that
|
|
275
|
+
declares no scope at all — is `X_IDEMPOTENCY_NOT_SHARED` at `registerAction`, before the socket
|
|
276
|
+
opens. The table is `SQL_IDEMPOTENCY_TABLE`, applied the way `SQL_JOBS_TABLE` is: `x db up` in
|
|
277
|
+
development, the release-phase `ROLE=migrate` in production. `postgresIdempotencyStore(...)
|
|
278
|
+
.purgeExpired()` is the sweep — Postgres forgets nothing on its own, so run it from a `task`.
|
|
279
|
+
|
|
280
|
+
**A plain mutating `route` can use the same gate.** `withIdempotency`, `IDEMPOTENCY_HEADER`,
|
|
281
|
+
`idempotencyKeyFor` and `getIdempotencyStore` are all public, so a route that is not an action
|
|
282
|
+
reserves and replays through the one implementation rather than growing a second:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const key = req.header(IDEMPOTENCY_HEADER);
|
|
286
|
+
// No header is the caller declining idempotency; a BLANK one is not, and
|
|
287
|
+
// `idempotencyKeyFor` refuses it below rather than filing a record everyone shares.
|
|
288
|
+
if (key === null) return json(await refund(input));
|
|
289
|
+
const outcome = await withIdempotency(
|
|
290
|
+
getIdempotencyStore(),
|
|
291
|
+
// Namespaced by action AND actor: otherwise two routes share one caller's key, and two
|
|
292
|
+
// callers share one record.
|
|
293
|
+
idempotencyKeyFor('refundCharge', key, req.ctx.actor),
|
|
294
|
+
input,
|
|
295
|
+
() => refund(input),
|
|
296
|
+
);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
A `query` has none and never will: a read has nothing to be idempotent about.
|
|
300
|
+
|
|
301
|
+
## `deprecated:` — a compat window, not a version
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
deprecated: { since: '2026-08-01T00:00:00Z', sunset: '2026-12-31T23:59:59Z', replacedBy: 'searchOrders' },
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Four things at once: `Deprecation: @1754006400` (RFC 9745) and `Sunset: Wed, 31 Dec 2026 …`
|
|
308
|
+
(RFC 8594) on **every** response including the failures, `link: </api/orders/search>;
|
|
309
|
+
rel="successor-version"`, `deprecated: true` plus `x-ultimate.deprecation` in the OpenAPI
|
|
310
|
+
operation, and a `deprecated_calls_total{primitive,name}` counter — which is the only way to
|
|
311
|
+
answer "is anyone still calling it?" before deleting it. A date that cannot be rendered is
|
|
312
|
+
`X_ACTION_DEPRECATION_INVALID` at projection, not on the first request.
|
|
313
|
+
|
|
314
|
+
**Versioning itself is deliberately absent, and will stay absent.** Running `v1` and `v2` of one
|
|
315
|
+
action side by side is two deployments behind one ingress — axiom 7's answer, costing this package
|
|
316
|
+
no router feature, no path prefix and no second registry. What ships is the window: a date, a
|
|
317
|
+
successor, and a number.
|
|
318
|
+
|
|
319
|
+
## Audit — the seam, not the row
|
|
320
|
+
|
|
321
|
+
`audit: true` on any `action` or `mutator` sends **every attempt** — allowed, denied and failed
|
|
322
|
+
— to the installed `AuditSink`. Opt-in per declaration, never a global switch: a login and a
|
|
323
|
+
price change are not the same event, and the framework is not the thing that knows which of
|
|
324
|
+
them your business has to keep.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { setAuditSink } from '@ultimat3/action';
|
|
328
|
+
|
|
329
|
+
setAuditSink({
|
|
330
|
+
async write(record) { await record.ctx.db.auditRows.insert(myRow(record)); },
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
What the framework supplies is what it genuinely knows:
|
|
335
|
+
|
|
336
|
+
| Field | |
|
|
337
|
+
|---|---|
|
|
338
|
+
| `at` | when the attempt began, from `ctx.now()` — an instant, never a rendering |
|
|
339
|
+
| `action` / `mutator` | the registered name, and which primitive it was |
|
|
340
|
+
| `surface` | `server` \| `http` \| `mcp` \| `job` — the same price change over MCP is not the same event |
|
|
341
|
+
| `ctx` | the whole context: actor, `requestId`, `traceId`, locale, and the services a sink needs to write a row |
|
|
342
|
+
| `input` | the **parsed** input, or `undefined` when the parse is what failed — never the raw payload |
|
|
343
|
+
| `idempotencyKey` / `replayed` | the namespaced key, and whether this was a call rather than a write |
|
|
344
|
+
| `outcome` | `allowed` \| `denied` \| `failed` |
|
|
345
|
+
| `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
|
|
346
|
+
|
|
347
|
+
What it does **not** supply: an audit entity, a schema, a retention policy, a storage backend, a
|
|
348
|
+
hash chain, a subject index, or an opinion on what "who" means under impersonation. Four apps
|
|
349
|
+
model those four ways; shipping one would make three of them wrong.
|
|
350
|
+
|
|
351
|
+
A denial is recorded because `invoke` wraps the whole path — `guard` throws **before** `handle`,
|
|
352
|
+
so nothing you could write around your own handler would ever see one. That is the reason this
|
|
353
|
+
lives in the framework and the row does not.
|
|
354
|
+
|
|
355
|
+
**Failure is loud, both ways.** `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`,
|
|
356
|
+
raised before the input parse — the one audit failure with no committed write behind it. A sink
|
|
357
|
+
that refuses a **successful** record is `X_AUDIT_SINK_FAILED`: the deliberate opposite of the
|
|
358
|
+
cache tier's `bestEffort`, because a dropped cache entry expires by TTL and the stack heals
|
|
359
|
+
itself while nothing ever re-derives an audit row that was never written. It is post-commit all
|
|
360
|
+
the same, and the error says so.
|
|
361
|
+
|
|
362
|
+
**Its `fix:` branches, because only one of the two is ever true.** Retrying is safe exactly when
|
|
363
|
+
*this invocation* went through the idempotency store — then the settled record replays and the
|
|
364
|
+
audit row is re-attempted without re-running the handler. It did not when the action is not
|
|
365
|
+
`idempotent`, **and it did not when the action is `idempotent` but the caller sent no
|
|
366
|
+
`Idempotency-Key`**: `invoke` reads `def.idempotent === true ? (options.idempotencyKey ?? null)
|
|
367
|
+
: null`, so both collapse to the same `null`. In that case the error says *do not retry* and
|
|
368
|
+
names the edit — telling a caller to re-run a committed mutator is worse than saying nothing.
|
|
369
|
+
`meta.replayable` carries the same fact to `--json`.
|
|
370
|
+
|
|
371
|
+
A sink that refuses a **denied or failed** record is logged as
|
|
372
|
+
`audit.sink.failed` and the original error still reaches the caller: answering
|
|
373
|
+
`X_AUDIT_SINK_FAILED` there would hide the `X_FORBIDDEN` from whoever has to act on it.
|
|
374
|
+
|
|
375
|
+
Your house rule goes in a wrapper, not in a config option — the same shape as `tenantEntity()`:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
// apps/web/shared/base/audited-mutator.ts — the app's convention, written once
|
|
379
|
+
import { mutator, type MutatorDef } from '@ultimat3/action';
|
|
380
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
381
|
+
|
|
382
|
+
/** Every write in this app is recorded and retryable — declared once, not at forty call sites. */
|
|
383
|
+
export const auditedMutator = <I extends StandardSchemaV1, O extends StandardSchemaV1>(
|
|
384
|
+
def: MutatorDef<I, O>,
|
|
385
|
+
) => mutator({ ...def, audit: true, idempotent: true });
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Nothing downstream can tell the difference: `isMutator()` is structural and `registerActions`
|
|
389
|
+
names the object in place, so every projection, the manifest and admin CRUD work on it exactly
|
|
390
|
+
as on a hand-written one.
|
|
391
|
+
|
|
392
|
+
The row it produces is the app's, and so is every question the framework refused to answer —
|
|
393
|
+
which fields, whose tenant, chained or not, kept how long:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
setAuditSink({
|
|
397
|
+
async write(record) {
|
|
398
|
+
const { ctx } = record; // the services a sink needs to write a row
|
|
399
|
+
const prev = await chainHead(ctx); // hash-chained: the app's choice
|
|
400
|
+
await ctx.db.auditRows.insert({
|
|
401
|
+
orgId: orgOf(ctx.actor), // tenancy: derived from the actor
|
|
402
|
+
subjectId: subjectOf(record.action, record.input),// queryable by subject: the app's index
|
|
403
|
+
actorId: impersonatorOf(ctx.actor) ?? ctx.actor.id,
|
|
404
|
+
at: record.at, outcome: record.outcome, code: record.failure?.code ?? null,
|
|
405
|
+
prevHash: prev, hash: await sha256(prev, record),
|
|
406
|
+
});
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Contract tests
|
|
412
|
+
|
|
413
|
+
`publishPost.contract()` returns three assertions. Run them; they throw `X_CONTRACT_DRIFT`.
|
|
414
|
+
|
|
415
|
+
| Assertion | Holds when |
|
|
416
|
+
|---|---|
|
|
417
|
+
| input schema rejects garbage | the invocation fails `X_INPUT_INVALID` — that code, not any failure |
|
|
418
|
+
| policy denies an anonymous actor | the invocation fails with an `ActionDeniedError` |
|
|
419
|
+
| OpenAPI document contains its operation | the derived path is in `buildOpenApi()` |
|
|
420
|
+
|
|
421
|
+
The denial assertion sends an input synthesized from `input:`'s own schema — required keys
|
|
422
|
+
only, formats included — because a payload the schema rejects never reaches a policy. It
|
|
423
|
+
asserts the denial, not `X_FORBIDDEN`: a denial carries the policy decision's own code, and
|
|
424
|
+
`can()` answers a null actor with `X_UNAUTHENTICATED`.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
publishPost.contract({
|
|
428
|
+
garbage: 42, // what the input schema must reject
|
|
429
|
+
input: { postId, orgId }, // when the synthesized one cannot fit
|
|
430
|
+
ctx: myCtx, // default: an anonymous context
|
|
431
|
+
})
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Pass `input:` when the schema carries a constraint the IR cannot invert (a bare `pattern`) or
|
|
435
|
+
when `row:` needs an id that resolves. Anything thrown *before* the policy decides is drift,
|
|
436
|
+
never a pass — the assertion says which code got in the way and names `input:` as the fix.
|
|
191
437
|
|
|
192
438
|
## Errors
|
|
193
439
|
|
|
194
440
|
| Code | When | Fix |
|
|
195
441
|
|---|---|---|
|
|
196
442
|
| `X_ACTION_DUPLICATE` | two actions registered under one name | rename one export |
|
|
443
|
+
| `X_ACTION_PATH_DUPLICATE` | two actions derive one HTTP path (`archiveOrder` / `archiveOrders`) | rename one export |
|
|
197
444
|
| `X_ACTION_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
|
|
445
|
+
| `X_RATE_LIMIT_INVALID` | `rateLimit:` with a non-positive or non-finite half — `windowMs: 0` refills infinitely. Owned by `@ultimat3/http`, which owns the conversion | make both positive, or delete the block |
|
|
446
|
+
| `X_ACTION_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
|
|
198
447
|
| `X_INPUT_INVALID` | input failed the Standard Schema | `x actions describe <name> --json` |
|
|
199
448
|
| `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
|
|
449
|
+
| `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
|
|
450
|
+
| `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor: Bun.sql })` at boot |
|
|
451
|
+
| `X_IDEMPOTENCY_REPLAYED_FAILURE` | a retried key replays a first attempt that failed and carried no framework code of its own | read the first attempt, then send a fresh key |
|
|
200
452
|
| `X_CONTRACT_DRIFT` | client/server build skew, missing spec entry | reload / `x verify --contract` |
|
|
201
|
-
| `X_RPC_FAILED` | non-`problem+json` failure
|
|
453
|
+
| `X_RPC_FAILED` | non-`problem+json` failure, or a body naming no `X_` code | check the gateway |
|
|
202
454
|
| `X_ACTION_UNREGISTERED` | projected before `registerActions()` ran | register at boot |
|
|
455
|
+
| `X_AUDIT_SINK_MISSING` | `audit: true` and no sink installed — raised before the input parse | `setAuditSink(yourSink)` at boot |
|
|
456
|
+
| `X_AUDIT_SINK_FAILED` | the sink refused the record for an attempt that **succeeded** | fix the sink — then retry the same `Idempotency-Key` if this call carried one, else reconcile by hand |
|
|
203
457
|
|
|
204
458
|
Denials re-throw the policy layer's own codes (`X_FORBIDDEN`, `X_UNAUTHENTICATED`) —
|
|
205
459
|
this package never invents an authz code.
|
|
206
460
|
|
|
461
|
+
The client does the same with the server's: a `problem+json` failure comes back as a
|
|
462
|
+
`RemoteActionError` keeping the code the server sent, marked `meta.origin: 'remote'` because
|
|
463
|
+
the browser bundle may never have registered it, and linked only to a page that exists — the
|
|
464
|
+
server's own `docs`/`type` when it sent an `http(s)` one, this build's registered link when it
|
|
465
|
+
knows the code, otherwise the error index. A per-code URL is never synthesized for a code
|
|
466
|
+
nothing here declares.
|
|
467
|
+
|
|
207
468
|
## Boundaries
|
|
208
469
|
|
|
209
470
|
Tier 3. Imports `@ultimat3/core`, `schema`, `cache`, `policy`, `http`. Never imports
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,10 +31,10 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/cache": "
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/http": "
|
|
36
|
-
"@ultimat3/policy": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/cache": "2.0.0",
|
|
35
|
+
"@ultimat3/core": "2.0.0",
|
|
36
|
+
"@ultimat3/http": "2.0.0",
|
|
37
|
+
"@ultimat3/policy": "2.0.0",
|
|
38
|
+
"@ultimat3/schema": "2.0.0"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/action.ts
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { CacheTag } from '@ultimat3/cache';
|
|
8
|
+
import { tagKeys } from '@ultimat3/cache';
|
|
8
9
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
10
|
+
import { isMcpExposed } from '@ultimat3/core';
|
|
9
11
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
12
|
import type { ClientMethod, ClientOptions } from './client';
|
|
11
13
|
import type { ContractTest, ContractTestOptions } from './contract-test';
|
|
14
|
+
import type { Deprecation } from './deprecation';
|
|
12
15
|
import { facadeFor } from './facade';
|
|
13
16
|
import type { OpenApiOperation } from './http';
|
|
14
17
|
import type { IdempotencyStore } from './idempotency';
|
|
@@ -17,9 +20,13 @@ import type { ActionJobHandle } from './job-handle';
|
|
|
17
20
|
import type { JsonSchemaObject } from './json-schema';
|
|
18
21
|
import { jsonSchemaOf } from './json-schema';
|
|
19
22
|
import type { McpToolDescriptor } from './mcp-tool';
|
|
20
|
-
import { derivePath
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
+
import { derivePath } from './naming';
|
|
24
|
+
import {
|
|
25
|
+
type ActionPolicy,
|
|
26
|
+
policyCapability,
|
|
27
|
+
policyPermissions,
|
|
28
|
+
type Surface,
|
|
29
|
+
} from './policy-gate';
|
|
23
30
|
|
|
24
31
|
export interface ActionCache {
|
|
25
32
|
/** Tags dropped from every cache tier after the handler settles. */
|
|
@@ -84,8 +91,32 @@ export interface ActionDef<
|
|
|
84
91
|
readonly cache?: ActionCache;
|
|
85
92
|
readonly mcp?: ActionMcp;
|
|
86
93
|
readonly rateLimit?: ActionRateLimit;
|
|
94
|
+
/**
|
|
95
|
+
* On its way out. Declared here and projected everywhere at once: `Deprecation` and `Sunset`
|
|
96
|
+
* response headers (RFC 9745 / RFC 8594), a `rel="successor-version"` link when `replacedBy`
|
|
97
|
+
* names one, `deprecated: true` in the OpenAPI operation, and a
|
|
98
|
+
* `deprecated_calls_total{name}` counter — which is the only way to answer "is anyone
|
|
99
|
+
* still calling it?" before deleting it.
|
|
100
|
+
*
|
|
101
|
+
* **Versioning is deliberately NOT here.** Running `v1` and `v2` of one action side by side is
|
|
102
|
+
* two deployments behind one ingress, which is axiom 7's answer and costs this package no
|
|
103
|
+
* router feature; see the README. What ships is the compat WINDOW: a date, a successor, and a
|
|
104
|
+
* number.
|
|
105
|
+
*/
|
|
106
|
+
readonly deprecated?: Deprecation;
|
|
87
107
|
/** Marks the action safe to retry with an `Idempotency-Key`. */
|
|
88
108
|
readonly idempotent?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Record every attempt at this action through the installed `AuditSink` — allowed, denied and
|
|
111
|
+
* failed alike. Opt-in per declaration and never a global switch: a login and a price change
|
|
112
|
+
* are not the same event, and the framework is not the thing that knows which of them an app
|
|
113
|
+
* has to keep. `true` with no sink installed is `X_AUDIT_SINK_MISSING`, refused before the
|
|
114
|
+
* input parse.
|
|
115
|
+
*
|
|
116
|
+
* What the sink DOES with the record — which fields survive, how long, hash-chained or not,
|
|
117
|
+
* indexed by subject or not — is the app's, and this package ships none of it.
|
|
118
|
+
*/
|
|
119
|
+
readonly audit?: boolean;
|
|
89
120
|
/**
|
|
90
121
|
* Loads the row a row-level `policy` decides about, once per invocation, after the
|
|
91
122
|
* input parse and before the guard. This is the async half authz is not allowed to
|
|
@@ -136,13 +167,28 @@ export interface ActionDescriptor {
|
|
|
136
167
|
readonly resource: string;
|
|
137
168
|
readonly method: 'POST';
|
|
138
169
|
readonly path: string;
|
|
170
|
+
/** The policy's DISPLAY label. A composite renders as `and(a:b, c:d)` — never a permission. */
|
|
139
171
|
readonly capability: string;
|
|
172
|
+
/**
|
|
173
|
+
* Every permission the policy tree references, flattened. This is the field a compliance
|
|
174
|
+
* report matches a grant against: `capability` is a label, so `x policy list` reported every
|
|
175
|
+
* composite-guarded action's permissions as unenforced — real grants shown as dead.
|
|
176
|
+
*/
|
|
177
|
+
readonly permissions: readonly string[];
|
|
140
178
|
readonly input: JsonSchemaObject;
|
|
141
179
|
readonly output: JsonSchemaObject;
|
|
142
180
|
readonly invalidates: readonly string[];
|
|
143
181
|
readonly idempotent: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Whether every attempt reaches the audit sink. Published here for the same reason
|
|
184
|
+
* `idempotent` is: "which of these writes leave a trail" is a question an agent asks of
|
|
185
|
+
* `x actions list --json`, and a fact nothing publishes is a fact nobody can check.
|
|
186
|
+
*/
|
|
187
|
+
readonly audited: boolean;
|
|
144
188
|
readonly mcp: McpDescriptorMeta;
|
|
145
189
|
readonly rateLimit: ActionRateLimit | null;
|
|
190
|
+
/** Published so `x actions list --json` can answer "what is retiring, and when". */
|
|
191
|
+
readonly deprecated: Deprecation | null;
|
|
146
192
|
}
|
|
147
193
|
|
|
148
194
|
/**
|
|
@@ -157,7 +203,9 @@ export interface AnyActionDef {
|
|
|
157
203
|
readonly cache?: ActionCache;
|
|
158
204
|
readonly mcp?: ActionMcp;
|
|
159
205
|
readonly rateLimit?: ActionRateLimit;
|
|
206
|
+
readonly deprecated?: Deprecation;
|
|
160
207
|
readonly idempotent?: boolean;
|
|
208
|
+
readonly audit?: boolean;
|
|
161
209
|
row?(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
162
210
|
handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
|
|
163
211
|
}
|
|
@@ -177,6 +225,18 @@ export interface AnyAction {
|
|
|
177
225
|
as(actor: Actor | null, input: unknown, options?: InvokeOptions): Promise<unknown>;
|
|
178
226
|
tool(): McpToolDescriptor;
|
|
179
227
|
openapi(): OpenApiOperation;
|
|
228
|
+
/**
|
|
229
|
+
* The durable-work shape, erased. `listActions()` and `getAction(name)` hand back this view
|
|
230
|
+
* and nothing else, so leaving `job()` off it meant the registry could project an action to
|
|
231
|
+
* every surface except the queue — `getAction('publishPost')?.job()` was a type error against
|
|
232
|
+
* an object that has had the method since `facadeFor` bound it.
|
|
233
|
+
*
|
|
234
|
+
* It erases where `client()` cannot: `ActionJobHandle`'s members are method-syntax, so their
|
|
235
|
+
* parameters stay bivariant, and its output erases to `unknown`. `ClientMethod` is a function
|
|
236
|
+
* type — contravariant input — and `(input: unknown) => …` is a supertype of nothing.
|
|
237
|
+
* `type-pins.ts` holds both halves of that as build errors.
|
|
238
|
+
*/
|
|
239
|
+
job(): ActionJobHandle;
|
|
180
240
|
contract(options?: ContractTestOptions): readonly ContractTest[];
|
|
181
241
|
}
|
|
182
242
|
|
|
@@ -195,8 +255,10 @@ export interface Action<
|
|
|
195
255
|
options?: InvokeOptions,
|
|
196
256
|
): Promise<InferOutput<TOutput>>;
|
|
197
257
|
/**
|
|
198
|
-
* Typed against this action's schemas, which is the whole point of
|
|
199
|
-
*
|
|
258
|
+
* Typed against this action's schemas, which is the whole point of it — and the reason
|
|
259
|
+
* `client()` lives here alone: its input sits in a contravariant position, so no erased
|
|
260
|
+
* spelling of it is assignable from a concrete one. `job()` is the narrowing of the erased
|
|
261
|
+
* view's, not a second declaration of it.
|
|
200
262
|
*/
|
|
201
263
|
client(options: ClientOptions): ClientMethod<TInput, TOutput>;
|
|
202
264
|
job(): ActionJobHandle<TInput, TOutput>;
|
|
@@ -281,15 +343,27 @@ export function describeAction(target: AnyAction): ActionDescriptor {
|
|
|
281
343
|
method: 'POST',
|
|
282
344
|
path: path.path,
|
|
283
345
|
capability: policyCapability(def.policy),
|
|
346
|
+
// The flattened list, beside the label and never instead of it: one is read, one is matched.
|
|
347
|
+
permissions: policyPermissions(def.policy),
|
|
284
348
|
input: jsonSchemaOf(def.input),
|
|
285
349
|
output: jsonSchemaOf(def.output),
|
|
286
350
|
invalidates: tagKeys(def.cache?.invalidates ?? []),
|
|
287
351
|
idempotent: def.idempotent === true,
|
|
352
|
+
audited: def.audit === true,
|
|
288
353
|
mcp: {
|
|
289
|
-
|
|
290
|
-
tool
|
|
354
|
+
// `isMcpExposed`, not `?? true`: this fact is what the manifest publishes and what the
|
|
355
|
+
// contract diff classifies, so it has to be the answer the tool projection actually gives.
|
|
356
|
+
// Fail-open here published every action as a tool no surface would ever serve, which made
|
|
357
|
+
// a first honest `expose: false` read as a withdrawn capability and demand a major bump.
|
|
358
|
+
expose: isMcpExposed(mcp),
|
|
359
|
+
// The export name verbatim, which is the only name `@ultimat3/mcp` will answer a
|
|
360
|
+
// `tools/call` for. A derived one made every descriptor reader — `x actions describe
|
|
361
|
+
// --json`, the `actions.describe` dev tool, the `/_x` panel — name a tool no surface
|
|
362
|
+
// serves. `x.manifest.json` is not among them: it copies `expose` and `description` only.
|
|
363
|
+
tool: name,
|
|
291
364
|
description: mcp?.description ?? null,
|
|
292
365
|
},
|
|
293
366
|
rateLimit: def.rateLimit ?? null,
|
|
367
|
+
deprecated: def.deprecated ?? null,
|
|
294
368
|
};
|
|
295
369
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only file in this package that calls an `AuditSink`, and the one place the "a sink may not
|
|
3
|
+
* silently swallow" rule is spelled. Two failure policies, because an attempt that succeeded and
|
|
4
|
+
* an attempt that already failed do not want the same answer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isUltimateError, logger } from '@ultimat3/core';
|
|
8
|
+
import type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from './audit';
|
|
9
|
+
import { getAuditSink } from './audit';
|
|
10
|
+
import { ActionDeniedError, AuditSinkFailedError, AuditSinkMissingError } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolved before the input parse, never after: an audited action that nothing can record must
|
|
14
|
+
* refuse while it has still made no change. This is the only audit failure with no committed
|
|
15
|
+
* write behind it, which is exactly why it is checked first.
|
|
16
|
+
*/
|
|
17
|
+
export function auditSinkFor(action: string): AuditSink {
|
|
18
|
+
const sink = getAuditSink();
|
|
19
|
+
if (sink === null) throw new AuditSinkMissingError(action);
|
|
20
|
+
return sink;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An authz refusal is `denied`; everything else that threw is `failed`, an unparsed input included. */
|
|
24
|
+
export function auditOutcomeFor(error: unknown): AuditOutcome {
|
|
25
|
+
return error instanceof ActionDeniedError ? 'denied' : 'failed';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function auditFailureFor(error: unknown): AuditFailure {
|
|
29
|
+
return { code: isUltimateError(error) ? error.code : null, error };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The record for an attempt that SUCCEEDED. A sink that refuses fails the invocation — the
|
|
34
|
+
* deliberate opposite of `bustAfterCommit`, which absorbs its own failure because a stale cache
|
|
35
|
+
* entry expires by TTL and the stack heals itself. Nothing heals a missing audit row: the only
|
|
36
|
+
* process that held the facts has returned. So "if it isn't logged, it didn't happen" is enforced
|
|
37
|
+
* on the caller rather than on a log line nobody reads.
|
|
38
|
+
*
|
|
39
|
+
* It is post-commit all the same, and the error says so instead of pretending the write was
|
|
40
|
+
* rolled back. That is the honest half of the choice: what the caller gains is being TOLD.
|
|
41
|
+
*
|
|
42
|
+
* Whether a retry is SAFE is the record's own `idempotencyKey`, not the declaration's
|
|
43
|
+
* `idempotent`. The key is non-null exactly when this invocation went through the idempotency
|
|
44
|
+
* store, which is the one condition under which a retry replays instead of re-running a handler
|
|
45
|
+
* that has already committed — and it is null both for a non-idempotent action and for an
|
|
46
|
+
* idempotent one whose caller sent no header.
|
|
47
|
+
*/
|
|
48
|
+
export async function auditSettled(sink: AuditSink, record: AuditRecord): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
await sink.write(record);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new AuditSinkFailedError(record.action, error, record.idempotencyKey !== null);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The record for an attempt that THREW. The sink's failure is reported and the original error is
|
|
58
|
+
* the one the caller gets — replacing a denial with `X_AUDIT_SINK_FAILED` would hide the
|
|
59
|
+
* `X_FORBIDDEN` from whoever has to act on it, and would answer a probing client differently
|
|
60
|
+
* depending on whether the audit backend happened to be up, which is an oracle.
|
|
61
|
+
*
|
|
62
|
+
* Never a silent swallow: the log line is the same shape every other subsystem writes, so
|
|
63
|
+
* `audit.sink.failed` is one alert rule over every action in the app.
|
|
64
|
+
*/
|
|
65
|
+
export async function auditThrew(sink: AuditSink, record: AuditRecord): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
await sink.write(record);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Core's logger, not `ctx.logger`: an HTTP `Ctx` is a cast request context that carries none,
|
|
70
|
+
// the same reason `cache-gate.ts` gives. Never the record — rendering an input the sink just
|
|
71
|
+
// choked on is the second throw this branch exists to prevent.
|
|
72
|
+
logger.error('audit.sink.failed', {
|
|
73
|
+
action: record.action,
|
|
74
|
+
outcome: record.outcome,
|
|
75
|
+
error,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|