@ultimat3/action 30.9.0 → 31.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/src/invoke.ts CHANGED
@@ -16,10 +16,12 @@ import {
16
16
  anonymousActor,
17
17
  ctxOf,
18
18
  failureCaptureActive,
19
+ instrument,
19
20
  isUltimateError,
20
21
  REDACTED,
21
22
  recordFailureInput,
22
23
  runWithContext,
24
+ subscribed,
23
25
  tryUseContext,
24
26
  useContext,
25
27
  withChildContext,
@@ -29,6 +31,7 @@ import {
29
31
  import { statusFor } from '@ultimat3/http';
30
32
  import { maskSensitive } from '@ultimat3/schema';
31
33
  import type { AnyAction, AnyActionDef, InvokeOptions } from './action';
34
+ import { declarationOf, drainsItsHandler } from './action-stream';
32
35
  import type { AuditRecord } from './audit';
33
36
  import {
34
37
  auditFailureFor,
@@ -40,6 +43,8 @@ import {
40
43
  import { bustAfterCommit } from './cache-gate';
41
44
  import { judgedHandler } from './declared-errors';
42
45
  import { ActionForeignError, ActionUnregisteredError } from './errors';
46
+ import type { HandlerOverride } from './handler-override';
47
+ import { handlerOverrideOf, setHandlerOverride } from './handler-override';
43
48
  import { getIdempotencyStore, withIdempotency } from './idempotency';
44
49
  import { idempotencyKeyFor } from './idempotency-key';
45
50
  import { drawMeter } from './meter-gate';
@@ -83,6 +88,15 @@ export function handlerReached(target: AnyAction): number {
83
88
  return HANDLER_REACHED.get(defOf(target)) ?? 0;
84
89
  }
85
90
 
91
+ /**
92
+ * A test double where `target`'s `handle` is, on every surface — policy, rate limit, input parse,
93
+ * meter, idempotency and output parse still run. `undefined` restores the declared handler. On
94
+ * `@ultimat3/action/adapter` only: `@ultimat3/testing`'s `stub()` is its caller.
95
+ */
96
+ export function overrideHandler(target: AnyAction, handle: HandlerOverride | undefined): void {
97
+ setHandlerOverride(declarationOf(defOf(target)), handle);
98
+ }
99
+
86
100
  /** Projections need a stable name; an unregistered action has none yet. */
87
101
  export function actionName(target: AnyAction): string {
88
102
  if (target.name.length === 0) throw new ActionUnregisteredError();
@@ -233,8 +247,13 @@ async function traced(
233
247
  'ultimate.actor.kind': ctx.actor.kind,
234
248
  'ultimate.idempotent': def.idempotent === true,
235
249
  });
250
+ const startedAt = performance.now();
236
251
  try {
237
252
  const value = await perform(def, name, raw, ctx, options, trace);
253
+ // Every invocation, sampled or not — the span reaches an exporter only when sampled.
254
+ if (subscribed('action.invoke')) {
255
+ instrument('action.invoke', { action: name, durationMs: performance.now() - startedAt });
256
+ }
238
257
  span.setAttributes({
239
258
  'ultimate.outcome': 'allowed',
240
259
  'ultimate.idempotency.replayed': trace.replayed,
@@ -248,6 +267,13 @@ async function traced(
248
267
  // are different questions, and a trace that called both "error" cannot separate them.
249
268
  span.setAttribute('ultimate.outcome', auditOutcomeFor(error));
250
269
  if (isUltimateError(error)) span.setAttribute('ultimate.error.code', error.code);
270
+ if (subscribed('action.invoke')) {
271
+ instrument('action.invoke', {
272
+ action: name,
273
+ durationMs: performance.now() - startedAt,
274
+ error,
275
+ });
276
+ }
251
277
  throw error;
252
278
  }
253
279
  });
@@ -345,7 +371,9 @@ async function perform(
345
371
  HANDLER_REACHED.set(def, (HANDLER_REACHED.get(def) ?? 0) + 1);
346
372
  // Read here, not closed over: under a key this runs in `runToCompletion`'s scope, and a handler
347
373
  // passing the outer `ctx.signal` onward would fail its vendor call and settle a failure.
348
- const produced = await judgedHandler(def, name, () => def.handle({ input, ctx: useContext() }));
374
+ const override = drainsItsHandler(def) ? undefined : handlerOverrideOf(def);
375
+ const handle = override ?? def.handle.bind(def);
376
+ const produced = await judgedHandler(def, name, () => handle({ input, ctx: useContext() }));
349
377
  return validateOutput(def.output, produced, name);
350
378
  };
351
379
  // The meter is drawn LAST before the handler, on every surface `surface: 'server'` included: a
package/src/loader.ts ADDED
@@ -0,0 +1,5 @@
1
+ // `@ultimat3/action/loader` — where an export is NAMED as an action: `x`'s module scan and the
2
+ // framework's own fixtures. Never the `.` barrel: an app hands its modules to `defineApi`, the one
3
+ // registration path (axiom 1), and that reaches these through the same registry.
4
+
5
+ export { registerAction, registerActions } from './registry';
@@ -4,6 +4,7 @@
4
4
  * the pool it opened, so a tenant's day is one counter across every replica and every restart.
5
5
  */
6
6
 
7
+ import { adapterSlot } from '@ultimat3/core/adapter';
7
8
  import { memoryMeterStore } from './meter-memory';
8
9
 
9
10
  /** One counter: whose, which meter, which period declaration. */
@@ -45,17 +46,24 @@ export interface MeterStore {
45
46
  releaseWarning(key: MeterKey, periodStartMs: number): Promise<void>;
46
47
  }
47
48
 
48
- let installed: MeterStore = memoryMeterStore();
49
+ let base: MeterStore = memoryMeterStore();
50
+ /** The installed store: a stack over the process default, released in any order. */
51
+ const slot = adapterSlot<MeterStore>(() => base);
49
52
 
50
- export function setMeterStore(store: MeterStore): void {
51
- installed = store;
53
+ /**
54
+ * The boot's installer (its release removes THIS install only, in any order — `adapterSlot`), on `@ultimat3/action/adapter` and never the `.` barrel: an app swaps the
55
+ * store with `meterStore:` in `apps/<app>/runtime.ts`.
56
+ */
57
+ export function installMeterStore(store: MeterStore): () => void {
58
+ return slot.install(store);
52
59
  }
53
60
 
54
61
  export function getMeterStore(): MeterStore {
55
- return installed;
62
+ return slot.current();
56
63
  }
57
64
 
58
65
  /** Back to a fresh process default. The boot's teardown, and a test's. */
59
66
  export function resetMeterStore(): void {
60
- installed = memoryMeterStore();
67
+ slot.reset();
68
+ base = memoryMeterStore();
61
69
  }
package/src/openapi.ts CHANGED
@@ -145,6 +145,8 @@ const PROBLEM_SCHEMA: JsonSchemaObject = {
145
145
  },
146
146
  },
147
147
  },
148
+ // Present, and only ever `true`, when validation stopped at `MAX_ISSUES`: `issues` is a prefix.
149
+ issuesTruncated: { type: 'boolean', enum: [true] },
148
150
  // Only the keys the code declared through `registerProblemMeta`; its shape is per code.
149
151
  meta: { type: 'object' },
150
152
  },
@@ -1,87 +1,52 @@
1
1
  /**
2
- * The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP,
3
- * job, direct server call) reaches authz through `guardAction()` — there is no second
4
- * code path, which is what makes "one authz system" true rather than aspirational.
2
+ * The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP, job, direct server
3
+ * call) reaches authz here, through policy's `guardPrimitive` — the one gate `@ultimat3/query`
4
+ * runs too, so the two primitives cannot drift on a surface rule.
5
5
  */
6
6
 
7
7
  import type { Actor, Ctx } from '@ultimat3/core';
8
- import { assertNever } from '@ultimat3/core';
9
- import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
10
- import { enforce, enforceBeforeInput } from '@ultimat3/policy';
8
+ import type { GuardSubject, Policy, PrimitiveSurface } from '@ultimat3/policy';
9
+ import { guardPrimitive } from '@ultimat3/policy';
11
10
  import { ActionDeniedError } from './errors';
12
11
 
13
- /**
14
- * Policies are opaque here: we evaluate them, we never introspect their rules.
15
- * `TRow` is what a row-level rule decides about, defaulted so the bare
16
- * `ActionPolicy` keeps meaning "decides on input, any row or none".
17
- */
12
+ /** Opaque here: evaluated, never introspected. `TRow` is what a row-level rule decides about. */
18
13
  export type ActionPolicy<TRow = unknown> = Policy<unknown, TRow>;
19
14
 
20
15
  /** Which projection is running. Selects the deny renderer, never the decision. */
21
- export type Surface = 'server' | 'http' | 'mcp' | 'job';
16
+ export type Surface = PrimitiveSurface<'action'>;
22
17
 
23
18
  export interface PolicySubject {
24
19
  readonly actor: Actor | null;
25
20
  readonly input: unknown;
26
- /**
27
- * The already-loaded row a row-level rule decides about; `null` when the action
28
- * declared no loader. Optional here and required in `PolicyArgs` for the same
29
- * reason `EvaluateArgs.row` is: a surface deciding on input alone should not have
30
- * to write `row: null`, but the predicate it reaches must still see the field.
31
- * `invoke` always passes it, so the gap closes before any rule runs.
32
- */
21
+ /** The loaded row a row-level rule decides about; `invoke` always passes it, `null` for none. */
33
22
  readonly row?: unknown;
34
23
  readonly ctx: Ctx;
35
24
  readonly action: string;
36
25
  }
37
26
 
38
- /**
39
- * Evaluate once, render the denial per surface. `enforce` runs the same policy
40
- * object for every surface, so an actor denied over HTTP is denied over MCP with
41
- * the same reason and the same code.
42
- */
27
+ /** Evaluate once, render the denial per surface: one reason and one code on every surface. */
43
28
  export function guardAction(policy: ActionPolicy, subject: PolicySubject, surface: Surface): void {
44
- const denial = enforce(policySurface(surface), policy, {
45
- input: subject.input,
46
- actor: subject.actor,
47
- // `evaluate()` normalises a missing row to `null`, so an input-only rule and a
48
- // row rule reach the predicate through one shape rather than two.
49
- row: subject.row,
50
- ctx: subject.ctx,
51
- });
52
- if (denial !== undefined) throw new ActionDeniedError(subject.action, denial);
29
+ const { action: name, ...rest } = subject;
30
+ guard(policy, { stage: 'input', name, ...rest }, surface);
53
31
  }
54
32
 
55
33
  /**
56
- * The actor-only half of `guardAction`, run BEFORE the input is parsed: a caller the policy refuses
57
- * whatever they send gets the 403 (or 401) here, and never the 400 whose issue list describes
58
- * the input schema of an operation they may not call. Undecided — a predicate that reads the input
59
- * or the row — passes, and `guardAction` decides after the parse exactly as before. Same denial
60
- * class, same code, same surface rendering as `guardAction`'s.
34
+ * The actor-only half, BEFORE the input parse: a caller refused whatever they send gets the 403
35
+ * (or 401), never a 400 describing an input schema they may not call. Undecided passes.
61
36
  */
62
37
  export function guardActionBeforeInput(
63
38
  policy: ActionPolicy,
64
39
  subject: Omit<PolicySubject, 'input' | 'row'>,
65
40
  surface: Surface,
66
41
  ): void {
67
- const denial = enforceBeforeInput(policySurface(surface), policy, {
68
- actor: subject.actor,
69
- ctx: subject.ctx,
70
- });
71
- if (denial !== undefined) throw new ActionDeniedError(subject.action, denial);
42
+ guard(
43
+ policy,
44
+ { stage: 'actor', name: subject.action, actor: subject.actor, ctx: subject.ctx },
45
+ surface,
46
+ );
72
47
  }
73
48
 
74
- /** A direct server call is the job surface: no request, no response to shape. */
75
- function policySurface(surface: Surface): PolicySurface {
76
- switch (surface) {
77
- case 'http':
78
- return 'http';
79
- case 'mcp':
80
- return 'mcp';
81
- case 'job':
82
- case 'server':
83
- return 'job';
84
- default:
85
- return assertNever(surface);
86
- }
49
+ function guard(policy: ActionPolicy, subject: GuardSubject, surface: Surface): void {
50
+ const refused = guardPrimitive('action', policy, subject, surface);
51
+ if (refused !== undefined) throw new ActionDeniedError(refused);
87
52
  }
@@ -5,7 +5,14 @@
5
5
  */
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
- import { asyncContext, isAnonymous } from '@ultimat3/core';
8
+ import {
9
+ asyncContext,
10
+ fixedRateLimitOf,
11
+ rateLimitScopeOf,
12
+ rateLimitSpendsOn,
13
+ rateLimitSubjectOf,
14
+ resolvedRateLimitOf,
15
+ } from '@ultimat3/core';
9
16
  import type { RateLimitDecision } from '@ultimat3/http';
10
17
  // The bucket maths, the subject precedence and the refusal are all `@ultimat3/http`'s (tier 2):
11
18
  // a copy here would be a second answer to "what does this limit mean" and "whose bucket is it".
@@ -56,13 +63,6 @@ export function inJobFrame(): boolean {
56
63
  return callerFrame.get()?.job === true;
57
64
  }
58
65
 
59
- /**
60
- * Namespaced, never the bare action name: the HTTP pipeline still spends its `default` bucket
61
- * under `<route name>|<subject>`, and one key taken against two bucket shapes in one store would
62
- * corrupt both counters.
63
- */
64
- const ACTION_SCOPE = 'action';
65
-
66
66
  export interface RateLimitSpendOptions {
67
67
  readonly surface: Surface;
68
68
  /** The connection address — the only subject an anonymous caller has. */
@@ -86,20 +86,20 @@ export async function spendActionLimit(
86
86
  ctx: Ctx,
87
87
  options: RateLimitSpendOptions,
88
88
  ): Promise<void> {
89
- if (limit === undefined || options.surface === 'server') return;
89
+ if (limit === undefined || !rateLimitSpendsOn(options.surface)) return;
90
90
  const frame = callerFrame.get();
91
91
  // A job run, or any call nested inside one (an `agent()` run as a job calling its tools as
92
92
  // `'mcp'`): never a visitor's address, and the unattributed bucket rather than `ip:unknown`.
93
93
  const inJob = options.surface === 'job' || inJobFrame();
94
94
  const spends = rateLimitSpends(
95
95
  {
96
- actorId: isAnonymous(ctx.actor) ? null : ctx.actor.id,
97
- // Read off an anonymous actor too: a job run IS the worker's anonymous identity carrying the
98
- // job's org (`jobRunActor`), and that org is who the run is — charging every tenant's runs
99
- // to one `ip:unknown` key let one tenant's backlog refuse everyone's.
100
- orgId: ctx.actor.orgId ?? null,
101
- ip: options.clientAddress ?? (inJob ? null : (frame?.address ?? null)),
102
- routeName: `${ACTION_SCOPE}:${name}`,
96
+ // Core's one subject rule — the org is read off an anonymous actor too (a job run).
97
+ ...rateLimitSubjectOf(
98
+ ctx.actor,
99
+ options.clientAddress ?? (inJob ? null : (frame?.address ?? null)),
100
+ ),
101
+ // Namespaced, never the bare name: the HTTP pipeline spends `<route name>|<subject>`.
102
+ routeName: rateLimitScopeOf('action', name),
103
103
  ...(inJob ? { unattributed: UNATTRIBUTED_JOB } : {}),
104
104
  },
105
105
  // The tenant allowance is the HTTP pipeline's (it spends it for every route, a handler-limited
@@ -109,7 +109,7 @@ export async function spendActionLimit(
109
109
  // Resolved once per call, BEFORE any bucket is touched, and through `toBucket`'s own screen: a
110
110
  // resolver answering `NaN`, `0` or `Infinity` is `X_RATE_LIMIT_INVALID`, never allow-all.
111
111
  const bucket = toBucket(name, {
112
- limit: await resolvedRateLimit(limit, ctx),
112
+ limit: await resolvedRateLimitOf(limit, { ctx }),
113
113
  windowMs: limit.windowMs,
114
114
  });
115
115
  for (const spend of spends) {
@@ -124,16 +124,6 @@ export async function spendActionLimit(
124
124
  }
125
125
  }
126
126
 
127
- /**
128
- * The number a call is held to: the declared one, or what the resolver answers from `ctx` — the
129
- * tenant's plan, read by the app. A non-number becomes `NaN`, so `toBucket` refuses it by name.
130
- */
131
- async function resolvedRateLimit(limit: ActionRateLimit, ctx: Ctx): Promise<number> {
132
- const resolved: unknown =
133
- typeof limit.limit === 'function' ? await limit.limit({ ctx }) : limit.limit;
134
- return typeof resolved === 'number' ? resolved : Number.NaN;
135
- }
136
-
137
127
  /**
138
128
  * At mount, never on the first request: a fixed pair is checked whole; a per-call limit has only
139
129
  * its window to check until a caller resolves it, so the window is checked against one request.
@@ -149,6 +139,5 @@ export function assertActionRateLimit(name: string, limit: ActionRateLimit): voi
149
139
  export function fixedRateLimit(
150
140
  limit: ActionRateLimit | undefined,
151
141
  ): { readonly limit: number; readonly windowMs: number } | null {
152
- if (limit === undefined || typeof limit.limit === 'function') return null;
153
- return { limit: limit.limit, windowMs: limit.windowMs };
142
+ return fixedRateLimitOf(limit);
154
143
  }
package/src/validate.ts CHANGED
@@ -26,7 +26,15 @@ export async function parseActionInput<S extends StandardSchemaV1>(
26
26
  const result = await validateAsync(schema, raw);
27
27
  if (result.issues !== undefined) {
28
28
  const issues = toValidationIssues(result.issues);
29
- throw new InputInvalidError(actionName, formatIssues(issues).join('; '), issues);
29
+ // `issuesTruncated`: validation stopped at `MAX_ISSUES`, and the refusal must say the list is
30
+ // a prefix rather than let a client render 100 rejections as the whole answer.
31
+ throw new InputInvalidError(
32
+ actionName,
33
+ formatIssues(issues).join('; '),
34
+ issues,
35
+ 'action',
36
+ result.issuesTruncated === true,
37
+ );
30
38
  }
31
39
  return result.value;
32
40
  }
@@ -5,24 +5,22 @@
5
5
  * structure another layer will render.
6
6
  */
7
7
 
8
- import { stringField } from '@ultimat3/core';
8
+ import { stringField } from '@ultimat3/core/page';
9
9
  import type { ValidationIssue } from '@ultimat3/schema';
10
- import { issueRuleFacts } from '@ultimat3/schema';
11
-
12
- /**
13
- * A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
14
- * entries are rendered into a DOM by whoever displays them, so the bound is here rather than there.
15
- */
16
- export const MAX_WIRE_ISSUES = 100;
10
+ import { issueRuleFacts, MAX_ISSUES } from '@ultimat3/schema';
17
11
 
18
12
  /**
13
+ * Bounded by `@ultimat3/schema`'s `MAX_ISSUES`, imported and never restated or re-exported: a list
14
+ * longer is not a form's worth of rejections but a body meant to be expensive, and the server stops
15
+ * validating at exactly that length, so a capped list arrives whole.
16
+ *
19
17
  * All-or-nothing on purpose. A partly-parsed list would DROP the entries it could not read, and
20
18
  * nothing downstream would know: a caller that finds `meta.issues` uses it INSTEAD of the
21
19
  * flattened `cause`, so a dropped entry is a rejection the user never hears about. Refusing the
22
20
  * whole list leaves the `cause` — which still holds every issue — as the answer.
23
21
  */
24
22
  export function issuesFromWire(value: unknown): readonly ValidationIssue[] | undefined {
25
- if (!Array.isArray(value) || value.length === 0 || value.length > MAX_WIRE_ISSUES) {
23
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ISSUES) {
26
24
  return undefined;
27
25
  }
28
26
  const issues: ValidationIssue[] = [];