@ultimat3/action 22.10.0 → 22.12.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 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`: parse input, evaluate policy, handle, parse output. A second
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
@@ -211,7 +211,9 @@ export const api = defineApi({
211
211
  scopes: MCP_SCOPES, // scope -> [primitive names]; the map defineAppMcp takes
212
212
  resolveToken, // the MCP resolver: token -> { actor, scopes } | null
213
213
  rateLimit: { limit: 120, windowMs: 60_000 },
214
- openapi: 'openapi.v1.json', // x manifest writes only the cut here; x verify checks it
214
+ openapi: 'openapi.v1.json', // x manifest writes only the cut here; x verify checks it —
215
+ // each operation `security: [{ bearer: ['<its scope>'] }]`,
216
+ // the bearer scheme lists every scope (x-ultimate.scopes)
215
217
  }],
216
218
  },
217
219
  // Declaring it (any key) makes openapi.json COMPLETE: info, servers, securitySchemes
@@ -256,8 +258,8 @@ the manifest never carried a tool name and was never wrong about one.
256
258
 
257
259
  ## One invocation core
258
260
 
259
- `invoke()` is the only execution path: **parse input → evaluate policy → handle →
260
- parse output**. HTTP, MCP, jobs and direct server calls differ **only** in the
261
+ `invoke()` is the only execution path: **policy's actor half → parse input → evaluate
262
+ policy → handle → parse output**. HTTP, MCP, jobs and direct server calls differ **only** in the
261
263
  `surface` they hand to `enforce()` from `@ultimat3/policy`, which selects how a
262
264
  denial renders (problem+json / tool error / failed job) — never whether authz runs.
263
265
 
@@ -267,11 +269,20 @@ no `.def`. A second authz path cannot be written without deleting that store.
267
269
 
268
270
  | Stage | Failure |
269
271
  |---|---|
272
+ | 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
273
  | parse input | `X_INPUT_INVALID` |
271
274
  | evaluate policy | the policy's own code — `X_UNAUTHENTICATED` (401), `X_FORBIDDEN` (403) |
272
275
  | handle | whatever the handler throws |
273
276
  | parse output | `X_OUTPUT_INVALID` — and fields the schema never declared are dropped |
274
277
 
278
+ **Who before what (`As of 2026-09`).** The actor half is the part of the policy that reads no
279
+ input: `can(p)` with no predicate, `allow()`, `deny()`, their combinators, and `can(p, pred)` for
280
+ an actor without `p`. It is decided before the parse — `decideBeforeInput` in `@ultimat3/policy`,
281
+ exact rather than a heuristic — so `can('refund:issue')` answers a customer's `{}` with 403, and a
282
+ staff caller's `{}` with the 400 naming each missing field. A predicate over `input` or `row` still
283
+ runs after the parse, on the parsed value. Same on HTTP, MCP (including the `invalid-args`
284
+ path) and `.as()`/jobs. An audited action's denied attempt still records the parsed input.
285
+
275
286
  `cache: { invalidates }` fans out **after** the handler commits, so it never fails it: a
276
287
  fan-out that refuses — an undeclared tag, `X_CACHE_TAG_UNKNOWN` — is one
277
288
  `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.10.0",
3
+ "version": "22.12.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.10.0",
38
- "@ultimat3/core": "22.10.0",
39
- "@ultimat3/entity": "22.10.0",
40
- "@ultimat3/http": "22.10.0",
41
- "@ultimat3/policy": "22.10.0",
42
- "@ultimat3/schema": "22.10.0"
37
+ "@ultimat3/cache": "22.12.0",
38
+ "@ultimat3/core": "22.12.0",
39
+ "@ultimat3/entity": "22.12.0",
40
+ "@ultimat3/http": "22.12.0",
41
+ "@ultimat3/policy": "22.12.0",
42
+ "@ultimat3/schema": "22.12.0"
43
43
  }
44
44
  }
@@ -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
- () => invoke(target, garbage, { ctx, surface: 'http' }),
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 output, and every stage lands
150
- // here identically. Only `X_INPUT_INVALID` is attributable: it is what `validateInput`
151
- // raises before `guard()` is reached, and `input:` is the knob that answers it. Any other
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/index.ts CHANGED
@@ -248,6 +248,7 @@ export {
248
248
  actorOf,
249
249
  admitsAnonymous,
250
250
  guard,
251
+ guardBeforeInput,
251
252
  policyCapability,
252
253
  policyPermissions,
253
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
@@ -69,6 +69,11 @@ function routeIndex(routes: readonly Route[]): ReadonlyMap<string, Route> {
69
69
 
70
70
  interface Refusals {
71
71
  readonly scheme: string;
72
+ /**
73
+ * The security requirement's list: empty on the cookie document, the ONE scope a mounted
74
+ * operation needs on a bearer mount's (OpenAPI 3.1 allows role names for a non-OAuth scheme).
75
+ */
76
+ readonly scopes: readonly string[];
72
77
  /** The mount's own allowance applies to every operation, whatever the route declares. */
73
78
  readonly everyOpLimited: boolean;
74
79
  readonly unauthorizedHeaders?: Record<string, unknown>;
@@ -105,7 +110,7 @@ function completeOperation(operation: Operation, route: Route | undefined, refus
105
110
  return {
106
111
  ...operation,
107
112
  responses,
108
- ...(authenticated ? { security: [{ [refusals.scheme]: [] }] } : {}),
113
+ ...(authenticated ? { security: [{ [refusals.scheme]: [...refusals.scopes] }] } : {}),
109
114
  };
110
115
  }
111
116
 
@@ -162,6 +167,7 @@ export function completeOpenApi(
162
167
  const paths = mapOperations(document.paths, (path, method, operation) =>
163
168
  completeOperation(operation, index.get(`${method} ${path}`), {
164
169
  scheme: COOKIE_SCHEME,
170
+ scopes: [],
165
171
  everyOpLimited: false,
166
172
  }),
167
173
  );
@@ -187,7 +193,8 @@ export function completeOpenApi(
187
193
  /**
188
194
  * A bearer mount's own document: only the operations its `scopes` name, re-keyed to the mounted
189
195
  * paths, each `bearer`-secured with 401, 404-when-hidden and 429, and only the schemas those
190
- * operations reference. `x-ultimate.scope` names the scope a token needs for each.
196
+ * operations reference. Each operation's `security` is `[{ bearer: ['<scope>'] }]` — the scope a
197
+ * token needs, also in `x-ultimate.scope` — and the scheme documents the whole scope map.
191
198
  */
192
199
  export function mountOpenApi(
193
200
  document: OpenApiDocument,
@@ -205,6 +212,9 @@ export function mountOpenApi(
205
212
  // A `Map`: the key is a route path, and a plain object keyed by one would read `__proto__`.
206
213
  const mountedItems = new Map<string, Record<string, unknown>>();
207
214
  const tags = new Set<string>();
215
+ // Scope → the operations in THIS document it unlocks: what the scheme documents, so it can never
216
+ // name an operation the document does not carry.
217
+ const unlocks = new Map<string, string[]>();
208
218
  const byRoute = [...input.routes].sort((a, b) =>
209
219
  `${a.path} ${a.method}` < `${b.path} ${b.method}` ? -1 : 1,
210
220
  );
@@ -222,6 +232,7 @@ export function mountOpenApi(
222
232
  { ...route, meta: { ...route.meta, auth: 'required' } },
223
233
  {
224
234
  scheme: BEARER_SCHEME,
235
+ scopes: [scope],
225
236
  everyOpLimited: mount.rateLimit !== undefined,
226
237
  unauthorizedHeaders: {
227
238
  'WWW-Authenticate': { description: 'Bearer', schema: { type: 'string' } },
@@ -234,6 +245,7 @@ export function mountOpenApi(
234
245
  };
235
246
  const extensions = (completed['x-ultimate'] as Record<string, unknown> | undefined) ?? {};
236
247
  for (const tag of (completed['tags'] as readonly string[] | undefined) ?? []) tags.add(tag);
248
+ unlocks.set(scope, [...(unlocks.get(scope) ?? []), route.meta.name]);
237
249
  const mounted = mountedPath(mount.prefix, route.path);
238
250
  mountedItems.set(mounted, {
239
251
  ...mountedItems.get(mounted),
@@ -249,12 +261,32 @@ export function mountOpenApi(
249
261
  paths,
250
262
  components: {
251
263
  schemas,
252
- securitySchemes: { [BEARER_SCHEME]: BEARER_SECURITY_SCHEME },
264
+ securitySchemes: { [BEARER_SCHEME]: scopedBearerScheme(unlocks) },
253
265
  },
254
266
  tags: [...tags].sort().map((name) => ({ name })),
255
267
  };
256
268
  }
257
269
 
270
+ /**
271
+ * The mount's `bearer` scheme with its scopes stated. An `http` scheme has no `scopes` field (that
272
+ * is OAuth's `flows`), so they are in the description for a reader and in `x-ultimate.scopes` —
273
+ * scope → operation names, both sorted — for a generator; each operation's requirement names one.
274
+ */
275
+ function scopedBearerScheme(unlocks: ReadonlyMap<string, readonly string[]>) {
276
+ const scopes = [...unlocks.keys()].sort();
277
+ const table = scopes.map((scope) => [scope, [...(unlocks.get(scope) ?? [])].sort()] as const);
278
+ const lines = table.map(([scope, names]) => `- \`${scope}\`: ${names.join(', ')}`);
279
+ return {
280
+ ...BEARER_SECURITY_SCHEME,
281
+ description: [
282
+ BEARER_SECURITY_SCHEME.description,
283
+ 'Each operation lists the one scope a token needs in its security requirement; a token without it is answered 404.',
284
+ ...(lines.length === 0 ? [] : ['', 'Scopes:', ...lines]),
285
+ ].join('\n'),
286
+ 'x-ultimate': { scopes: Object.fromEntries(table) },
287
+ };
288
+ }
289
+
258
290
  /** Every component schema `root` reaches through `$ref`, transitively. */
259
291
  function referencedSchemas(
260
292
  root: unknown,
@@ -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