@ultimat3/action 22.9.1 → 22.11.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 +2 -1
- package/README.md +11 -2
- package/package.json +7 -7
- package/src/action.ts +27 -0
- package/src/contract-test.ts +9 -9
- package/src/errors.ts +18 -2
- package/src/index.ts +2 -0
- package/src/invoke.ts +16 -8
- package/src/policy-gate.ts +20 -0
- package/src/validate.ts +10 -0
package/CLAUDE.md
CHANGED
|
@@ -52,7 +52,8 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
|
|
|
52
52
|
|
|
53
53
|
## Invariants — execution and authz
|
|
54
54
|
|
|
55
|
-
- Every surface goes through `invoke`:
|
|
55
|
+
- Every surface goes through `invoke`: the policy's actor half (`guardBeforeInput`, 403 before the
|
|
56
|
+
parse), parse input, evaluate policy, handle, parse output. A second
|
|
56
57
|
execution path is the one unforgivable change here.
|
|
57
58
|
- **An explicit `ctx` is INSTALLED, never merely passed** — the ambient context (which
|
|
58
59
|
`@ultimat3/entity`'s tenant guard reads) must be the identity `guard()` decided about.
|
package/README.md
CHANGED
|
@@ -256,8 +256,8 @@ the manifest never carried a tool name and was never wrong about one.
|
|
|
256
256
|
|
|
257
257
|
## One invocation core
|
|
258
258
|
|
|
259
|
-
`invoke()` is the only execution path: **
|
|
260
|
-
parse output**. HTTP, MCP, jobs and direct server calls differ **only** in the
|
|
259
|
+
`invoke()` is the only execution path: **policy's actor half → parse input → evaluate
|
|
260
|
+
policy → handle → parse output**. HTTP, MCP, jobs and direct server calls differ **only** in the
|
|
261
261
|
`surface` they hand to `enforce()` from `@ultimat3/policy`, which selects how a
|
|
262
262
|
denial renders (problem+json / tool error / failed job) — never whether authz runs.
|
|
263
263
|
|
|
@@ -267,11 +267,20 @@ no `.def`. A second authz path cannot be written without deleting that store.
|
|
|
267
267
|
|
|
268
268
|
| Stage | Failure |
|
|
269
269
|
|---|---|
|
|
270
|
+
| policy, actor half (`guardBeforeInput`) | `X_UNAUTHENTICATED` / `X_FORBIDDEN` for a caller the policy refuses whatever the input — never a 400 describing the schema of an operation they may not call |
|
|
270
271
|
| parse input | `X_INPUT_INVALID` |
|
|
271
272
|
| evaluate policy | the policy's own code — `X_UNAUTHENTICATED` (401), `X_FORBIDDEN` (403) |
|
|
272
273
|
| handle | whatever the handler throws |
|
|
273
274
|
| parse output | `X_OUTPUT_INVALID` — and fields the schema never declared are dropped |
|
|
274
275
|
|
|
276
|
+
**Who before what (`As of 2026-09`).** The actor half is the part of the policy that reads no
|
|
277
|
+
input: `can(p)` with no predicate, `allow()`, `deny()`, their combinators, and `can(p, pred)` for
|
|
278
|
+
an actor without `p`. It is decided before the parse — `decideBeforeInput` in `@ultimat3/policy`,
|
|
279
|
+
exact rather than a heuristic — so `can('refund:issue')` answers a customer's `{}` with 403, and a
|
|
280
|
+
staff caller's `{}` with the 400 naming each missing field. A predicate over `input` or `row` still
|
|
281
|
+
runs after the parse, on the parsed value. Same on HTTP, MCP (including the `invalid-args`
|
|
282
|
+
path) and `.as()`/jobs. An audited action's denied attempt still records the parsed input.
|
|
283
|
+
|
|
275
284
|
`cache: { invalidates }` fans out **after** the handler commits, so it never fails it: a
|
|
276
285
|
fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
|
|
277
286
|
`action.invalidate.failed` log line and the entries expire by TTL. A replayed idempotent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.11.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",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/cache": "22.
|
|
38
|
-
"@ultimat3/core": "22.
|
|
39
|
-
"@ultimat3/entity": "22.
|
|
40
|
-
"@ultimat3/http": "22.
|
|
41
|
-
"@ultimat3/policy": "22.
|
|
42
|
-
"@ultimat3/schema": "22.
|
|
37
|
+
"@ultimat3/cache": "22.11.0",
|
|
38
|
+
"@ultimat3/core": "22.11.0",
|
|
39
|
+
"@ultimat3/entity": "22.11.0",
|
|
40
|
+
"@ultimat3/http": "22.11.0",
|
|
41
|
+
"@ultimat3/policy": "22.11.0",
|
|
42
|
+
"@ultimat3/schema": "22.11.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/action.ts
CHANGED
|
@@ -63,6 +63,33 @@ export interface ActionMcp {
|
|
|
63
63
|
* actor's admin permissions) hands `@ultimat3/mcp` a predicate instead.
|
|
64
64
|
*/
|
|
65
65
|
readonly visibleTo?: readonly string[];
|
|
66
|
+
/**
|
|
67
|
+
* The tool's display name in an MCP client's UI (`title`, MCP 2025-06-18). Contract text, like
|
|
68
|
+
* `description`. Omitted: none is published and the client shows the tool name.
|
|
69
|
+
*/
|
|
70
|
+
readonly title?: string;
|
|
71
|
+
/**
|
|
72
|
+
* MCP tool annotations, overriding what `@ultimat3/mcp` derives key by key. An action derives
|
|
73
|
+
* `readOnlyHint: false`, `destructiveHint: true` and `idempotentHint` from `idempotent`. Hints for
|
|
74
|
+
* a client's confirmation UI — the policy decides every call whatever they say.
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* mcp: { expose: true, annotations: { destructiveHint: false, openWorldHint: true } },
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
readonly annotations?: McpAnnotationHints;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* MCP's four tool hints, as the spec spells them. Declared here (tier 3) and in `@ultimat3/query`
|
|
85
|
+
* because `@ultimat3/mcp` (tier 4) is the reader and cannot be imported; its `McpToolAnnotations`
|
|
86
|
+
* is the same shape.
|
|
87
|
+
*/
|
|
88
|
+
export interface McpAnnotationHints {
|
|
89
|
+
readonly readOnlyHint?: boolean;
|
|
90
|
+
readonly destructiveHint?: boolean;
|
|
91
|
+
readonly idempotentHint?: boolean;
|
|
92
|
+
readonly openWorldHint?: boolean;
|
|
66
93
|
}
|
|
67
94
|
|
|
68
95
|
export interface ActionRateLimit {
|
package/src/contract-test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { actionName, invoke } from './invoke';
|
|
|
12
12
|
import { derivePath } from './naming';
|
|
13
13
|
import { buildOpenApi } from './openapi';
|
|
14
14
|
import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
|
|
15
|
+
import { validateInput } from './validate';
|
|
15
16
|
|
|
16
17
|
export interface ContractTest {
|
|
17
18
|
readonly name: string;
|
|
@@ -47,9 +48,12 @@ export function contractTestsFor(
|
|
|
47
48
|
return [
|
|
48
49
|
{
|
|
49
50
|
name: `${name}: input schema rejects garbage`,
|
|
51
|
+
// The action's own parser — the one `invoke` runs — and never `invoke` itself: the policy's
|
|
52
|
+
// actor half is decided BEFORE the parse, so an anonymous caller of a guarded action is
|
|
53
|
+
// refused 401 without the schema ever seeing the garbage. The claim is about `input:`.
|
|
50
54
|
run: async () => {
|
|
51
55
|
await expectThrow(
|
|
52
|
-
() =>
|
|
56
|
+
() => validateInput(target.input, garbage, name),
|
|
53
57
|
'X_INPUT_INVALID',
|
|
54
58
|
`${name} accepted the value this assertion sent as garbage`,
|
|
55
59
|
`tighten \`input:\` in the ${name} definition`,
|
|
@@ -146,9 +150,10 @@ async function expectDenied(
|
|
|
146
150
|
// drift error would hide the line that threw behind a fix that does not apply.
|
|
147
151
|
if (!isUltimateError(error)) throw error;
|
|
148
152
|
if (error instanceof ActionDeniedError) return;
|
|
149
|
-
// `invoke` runs parse input → row → policy → handle → parse
|
|
150
|
-
// here identically. Only `X_INPUT_INVALID` is attributable: it
|
|
151
|
-
// raises before `guard()` is reached
|
|
153
|
+
// `invoke` runs the policy's actor half → parse input → row → policy → handle → parse
|
|
154
|
+
// output, and every stage lands here identically. Only `X_INPUT_INVALID` is attributable: it
|
|
155
|
+
// is what `validateInput` raises before `guard()` is reached (for a policy whose actor half
|
|
156
|
+
// could not decide), and `input:` is the knob that answers it. Any other
|
|
152
157
|
// code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
|
|
153
158
|
// `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather
|
|
154
159
|
// than being retold as an input problem with a fix that changes nothing.
|
|
@@ -184,11 +189,6 @@ async function expectThrow(
|
|
|
184
189
|
} catch (error) {
|
|
185
190
|
if (!isUltimateError(error)) throw error;
|
|
186
191
|
if (error.code === code) return;
|
|
187
|
-
// `X_AUDIT_SINK_MISSING` is the one refusal `invoke` raises BEFORE the input parse, so
|
|
188
|
-
// "the schema accepted garbage" is a false statement about it and `input:` is not what
|
|
189
|
-
// answers it. It keeps its own code and its own runnable fix — the same rule `expectDenied`
|
|
190
|
-
// follows for every code it cannot attribute to `input:`.
|
|
191
|
-
if (error.code === 'X_AUDIT_SINK_MISSING') throw error;
|
|
192
192
|
report();
|
|
193
193
|
throw new ContractDriftError(`${cause} (got ${error.code}, expected ${code})`, fix);
|
|
194
194
|
}
|
package/src/errors.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
8
|
assertNever,
|
|
9
|
+
deniedCallerFix,
|
|
9
10
|
ERROR_DOCS_URL,
|
|
10
11
|
hasErrorCode,
|
|
11
12
|
registerErrorCodes,
|
|
@@ -136,6 +137,7 @@ export class ActionDeniedError extends UltimateError {
|
|
|
136
137
|
code,
|
|
137
138
|
cause: `${action} denied: ${denialReason(denial)}`,
|
|
138
139
|
fix: `x policy explain ${action} --json # shows which clause decided and why`,
|
|
140
|
+
callerFix: deniedCallerFix(),
|
|
139
141
|
});
|
|
140
142
|
this.denial = denial;
|
|
141
143
|
}
|
|
@@ -203,11 +205,25 @@ export class InputInvalidError extends UltimateError {
|
|
|
203
205
|
* `formatIssues` here would pull that package's whole barrel into every browser bundle holding
|
|
204
206
|
* the typed client.
|
|
205
207
|
*/
|
|
206
|
-
constructor(
|
|
208
|
+
constructor(
|
|
209
|
+
name: string,
|
|
210
|
+
detail: string,
|
|
211
|
+
issues?: readonly ValidationIssue[],
|
|
212
|
+
/**
|
|
213
|
+
* What `name` names, in the cause: `'action'` (the default), or `'tool'` when `@ultimat3/mcp`
|
|
214
|
+
* refuses arguments against a tool's published schema — a query's or a meta tool's, not only an
|
|
215
|
+
* action's.
|
|
216
|
+
*/
|
|
217
|
+
subject: 'action' | 'tool' = 'action',
|
|
218
|
+
) {
|
|
207
219
|
super({
|
|
208
220
|
code: 'X_INPUT_INVALID',
|
|
209
|
-
cause: `input for
|
|
221
|
+
cause: `input for ${subject} "${name}" failed validation: ${detail}`,
|
|
210
222
|
fix: `x actions describe ${name} --json # prints the expected input schema`,
|
|
223
|
+
// A remote caller holds the published schema — the MCP `inputSchema`, the OpenAPI request
|
|
224
|
+
// body — and cannot run `x actions describe`.
|
|
225
|
+
callerFix:
|
|
226
|
+
'correct the fields named in cause to match the published input schema (the tool inputSchema, or the OpenAPI request body) and send again — the same input is refused the same way',
|
|
211
227
|
...(issues === undefined ? {} : { meta: { issues } }),
|
|
212
228
|
});
|
|
213
229
|
this.issues = issues;
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export type {
|
|
|
54
54
|
ActionRowArgs,
|
|
55
55
|
AnyAction,
|
|
56
56
|
InvokeOptions,
|
|
57
|
+
McpAnnotationHints,
|
|
57
58
|
McpDescriptorMeta,
|
|
58
59
|
} from './action';
|
|
59
60
|
export { action, describeAction, isAction } from './action';
|
|
@@ -247,6 +248,7 @@ export {
|
|
|
247
248
|
actorOf,
|
|
248
249
|
admitsAnonymous,
|
|
249
250
|
guard,
|
|
251
|
+
guardBeforeInput,
|
|
250
252
|
policyCapability,
|
|
251
253
|
policyPermissions,
|
|
252
254
|
} from './policy-gate';
|
package/src/invoke.ts
CHANGED
|
@@ -34,8 +34,8 @@ import { bustAfterCommit } from './cache-gate';
|
|
|
34
34
|
import { ActionForeignError, ActionUnregisteredError } from './errors';
|
|
35
35
|
import { getIdempotencyStore, withIdempotency } from './idempotency';
|
|
36
36
|
import { idempotencyKeyFor } from './idempotency-key';
|
|
37
|
-
import { actorOf, guard } from './policy-gate';
|
|
38
|
-
import { validateInput, validateOutput } from './validate';
|
|
37
|
+
import { actorOf, guard, guardBeforeInput } from './policy-gate';
|
|
38
|
+
import { parsedOrNothing, validateInput, validateOutput } from './validate';
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Private on purpose. `@ultimat3/action` exports no way to read this back, which
|
|
@@ -207,7 +207,7 @@ async function execute(
|
|
|
207
207
|
});
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
/** The invocation itself, unwrapped: parse, load the row, guard, run, parse, bust. */
|
|
210
|
+
/** The invocation itself, unwrapped: gate, parse, load the row, guard, run, parse, bust. */
|
|
211
211
|
async function perform(
|
|
212
212
|
def: AnyActionDef,
|
|
213
213
|
name: string,
|
|
@@ -216,6 +216,18 @@ async function perform(
|
|
|
216
216
|
options: InvokeOptions,
|
|
217
217
|
trace: InvokeTrace,
|
|
218
218
|
): Promise<unknown> {
|
|
219
|
+
const surface = options.surface ?? 'server';
|
|
220
|
+
// Who is asking is decided before what they sent is read: a caller the policy refuses whatever
|
|
221
|
+
// 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 `guard` below.
|
|
223
|
+
try {
|
|
224
|
+
guardBeforeInput(def.policy, { actor: actorOf(ctx), ctx, action: name }, surface);
|
|
225
|
+
} catch (denial) {
|
|
226
|
+
// The audit record still names what was attempted — the denial is already decided, so parsing
|
|
227
|
+
// now tells the CALLER nothing, and an unparseable payload is a record with no input.
|
|
228
|
+
if (def.audit === true) trace.input = await parsedOrNothing(def.input, raw);
|
|
229
|
+
throw denial;
|
|
230
|
+
}
|
|
219
231
|
const input = await validateInput(def.input, raw, name);
|
|
220
232
|
trace.input = input;
|
|
221
233
|
// The one place a row-level rule gets its row. Once per invocation, never per row:
|
|
@@ -224,11 +236,7 @@ async function perform(
|
|
|
224
236
|
// action with no loader hands the rule `null` — unchanged, and never a silent allow,
|
|
225
237
|
// because a rule that reads `row` has to decide what `null` means.
|
|
226
238
|
const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
|
|
227
|
-
guard(
|
|
228
|
-
def.policy,
|
|
229
|
-
{ actor: actorOf(ctx), input, row, ctx, action: name },
|
|
230
|
-
options.surface ?? 'server',
|
|
231
|
-
);
|
|
239
|
+
guard(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
|
|
232
240
|
|
|
233
241
|
// Output parsing sits inside `run` so a replayed idempotent response is the parsed value too —
|
|
234
242
|
// one shape on the wire, first call and every retry. No span of its own: `execute` above holds
|
package/src/policy-gate.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
10
|
import {
|
|
11
11
|
enforce,
|
|
12
|
+
enforceBeforeInput,
|
|
12
13
|
policyPermissions as flattenedPermissions,
|
|
13
14
|
admitsAnonymous as policyAdmitsAnonymous,
|
|
14
15
|
} from '@ultimat3/policy';
|
|
@@ -56,6 +57,25 @@ export function guard(policy: ActionPolicy, subject: PolicySubject, surface: Sur
|
|
|
56
57
|
if (denial !== undefined) throw new ActionDeniedError(subject.action, denial);
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The actor-only half of `guard`, run BEFORE the input is parsed: a caller the policy refuses
|
|
62
|
+
* whatever they send gets the 403 (or 401) here, and never the 400 whose issue list describes
|
|
63
|
+
* the input schema of an operation they may not call. Undecided — a predicate that reads the input
|
|
64
|
+
* or the row — passes, and `guard` decides after the parse exactly as before. Same denial class,
|
|
65
|
+
* same code, same surface rendering as `guard`'s.
|
|
66
|
+
*/
|
|
67
|
+
export function guardBeforeInput(
|
|
68
|
+
policy: ActionPolicy,
|
|
69
|
+
subject: Omit<PolicySubject, 'input' | 'row'>,
|
|
70
|
+
surface: Surface,
|
|
71
|
+
): void {
|
|
72
|
+
const denial = enforceBeforeInput(policySurface(surface), policy, {
|
|
73
|
+
actor: subject.actor,
|
|
74
|
+
ctx: subject.ctx,
|
|
75
|
+
});
|
|
76
|
+
if (denial !== undefined) throw new ActionDeniedError(subject.action, denial);
|
|
77
|
+
}
|
|
78
|
+
|
|
59
79
|
/** A direct server call is the job surface: no request, no response to shape. */
|
|
60
80
|
function policySurface(surface: Surface): PolicySurface {
|
|
61
81
|
switch (surface) {
|
package/src/validate.ts
CHANGED
|
@@ -31,6 +31,16 @@ export async function validateInput<S extends StandardSchemaV1>(
|
|
|
31
31
|
return result.value;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The parse without the refusal: the value, or `undefined` when it does not parse. Only for a
|
|
36
|
+
* record of an attempt already refused on other grounds (`invoke`'s audit of a before-input
|
|
37
|
+
* denial) — never on a path that goes on to run anything.
|
|
38
|
+
*/
|
|
39
|
+
export async function parsedOrNothing(schema: StandardSchemaV1, raw: unknown): Promise<unknown> {
|
|
40
|
+
const result = await validateAsync(schema, raw);
|
|
41
|
+
return result.issues === undefined ? result.value : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
/**
|
|
35
45
|
* The handler's return value is data too. Parsing it is what makes the OpenAPI
|
|
36
46
|
* response schema, the typed client and the MCP `outputSchema` true rather than
|