@ultimat3/action 24.0.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +65 -39
- package/README.md +124 -41
- package/package.json +10 -10
- package/src/action.ts +25 -69
- package/src/api-declaration.ts +1 -1
- package/src/audit-gate.ts +3 -4
- package/src/audit-memory.ts +3 -3
- package/src/audit-postgres.ts +15 -6
- package/src/audit.ts +7 -106
- package/src/client.ts +18 -6
- package/src/contract-test.ts +18 -6
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +21 -3
- package/src/errors.ts +40 -62
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +29 -10
- package/src/idempotency-postgres.ts +27 -12
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +69 -9
- package/src/index.ts +33 -87
- package/src/invoke.ts +54 -19
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +2 -1
- package/src/mutator.ts +1 -2
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/policy-gate.ts +8 -51
- package/src/rate-limit-gate.ts +114 -0
- package/src/record-wire.ts +2 -2
- package/src/registry.ts +2 -2
- package/src/sample-input.ts +35 -6
- package/src/stable.ts +0 -7
- package/src/transition.ts +23 -3
- package/src/tx-scope.ts +1 -1
- package/src/type-pins.ts +103 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -95
- package/src/wire-headers.ts +0 -20
package/src/action.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `action` primitive: one server-authoritative mutation, declared once.
|
|
3
|
-
* Every projection in this package (route, OpenAPI, client,
|
|
4
|
-
*
|
|
3
|
+
* Every projection in this package (route, OpenAPI, client, job handle, contract
|
|
4
|
+
* tests) reads this declaration — none of them re-declare it. The MCP tool is
|
|
5
|
+
* `@ultimat3/mcp`'s projection (`toolFrom`), one tier up, of the same declaration.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import type { CacheTag } from '@ultimat3/cache';
|
|
8
9
|
import { tagKeys } from '@ultimat3/cache';
|
|
9
|
-
import type { Actor, Ctx } from '@ultimat3/core';
|
|
10
|
+
import type { Actor, Ctx, Deprecation, McpExposureDeclaration } from '@ultimat3/core';
|
|
10
11
|
import { isMcpExposed } from '@ultimat3/core';
|
|
12
|
+
import type { RateLimitDecision } from '@ultimat3/http';
|
|
13
|
+
import { policyCapability, policyPermissions } from '@ultimat3/policy';
|
|
11
14
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
12
15
|
import type { ClientMethod, ClientOptions } from './client';
|
|
13
16
|
import type { ContractTest, ContractTestOptions } from './contract-test';
|
|
14
|
-
import type { Deprecation } from './deprecation';
|
|
15
17
|
import { facadeFor } from './facade';
|
|
16
18
|
import type { OpenApiOperation } from './http';
|
|
17
19
|
import type { ActionHttp } from './http-path';
|
|
@@ -21,76 +23,18 @@ import { actionName, defOf, hasDef, invoke, stashDef } from './invoke';
|
|
|
21
23
|
import type { ActionJobHandle } from './job-handle';
|
|
22
24
|
import type { JsonSchemaObject } from './json-schema';
|
|
23
25
|
import { jsonSchemaOf } from './json-schema';
|
|
24
|
-
import type {
|
|
25
|
-
import {
|
|
26
|
-
type ActionPolicy,
|
|
27
|
-
policyCapability,
|
|
28
|
-
policyPermissions,
|
|
29
|
-
type Surface,
|
|
30
|
-
} from './policy-gate';
|
|
26
|
+
import type { ActionPolicy, Surface } from './policy-gate';
|
|
31
27
|
|
|
32
28
|
export interface ActionCache {
|
|
33
29
|
/** Tags dropped from every cache tier after the handler settles. */
|
|
34
30
|
readonly invalidates: readonly CacheTag[];
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
export interface ActionMcp {
|
|
38
|
-
/** Opt-in: only a literal `true` makes the action a tool. Silence exposes nothing. */
|
|
39
|
-
readonly expose: boolean;
|
|
40
|
-
/**
|
|
41
|
-
* Contract text, NOT UI text — deliberately outside `t()`. It becomes the OpenAPI
|
|
42
|
-
* operation `summary` (`toOpenApiOperation`), and `buildOpenApi`'s bytes are what
|
|
43
|
-
* `x verify` diffs for contract drift. Resolving it through the ambient, request-scoped
|
|
44
|
-
* translator would make `openapi.json` depend on whichever locale happened to be active
|
|
45
|
-
* when it was generated, which is exactly the determinism that file's header forbids.
|
|
46
|
-
* Localised agent-facing text needs a separate, locale-resolved projection; there is no
|
|
47
|
-
* second field for it here until that exists, because two ways to describe one tool is
|
|
48
|
-
* the drift axiom 1 rejects.
|
|
49
|
-
*/
|
|
50
|
-
readonly description?: string;
|
|
51
|
-
/**
|
|
52
|
-
* Roles that may SEE the projected tool. A CATALOG audience, never an authz rule — the
|
|
53
|
-
* `policy` above still decides every call, and this list decides nothing about one. Omitted
|
|
54
|
-
* means every caller may enumerate it.
|
|
55
|
-
*
|
|
56
|
-
* Fail-closed where it lands (`@ultimat3/mcp`): a caller whose role is not named — including
|
|
57
|
-
* one carrying no role at all — gets the answer an ABSENT tool gets, never `Forbidden`.
|
|
58
|
-
* Forbidden would confirm the tool exists, which turns the catalog into something an agent
|
|
59
|
-
* can enumerate by probing names.
|
|
60
|
-
*
|
|
61
|
-
* A plain role list, never a predicate: a declared fact has to stay static and serialisable.
|
|
62
|
-
* A surface deriving visibility from something richer (`@ultimat3/admin` derives it from the
|
|
63
|
-
* actor's admin permissions) hands `@ultimat3/mcp` a predicate instead.
|
|
64
|
-
*/
|
|
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
33
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* is the same shape.
|
|
34
|
+
* An action's `mcp` block: core's ONE `McpExposureDeclaration`, minus the list whitelist an action
|
|
35
|
+
* has no list to compose over. Derived, never restated — `type-pins.ts` holds it to core's.
|
|
87
36
|
*/
|
|
88
|
-
export
|
|
89
|
-
readonly readOnlyHint?: boolean;
|
|
90
|
-
readonly destructiveHint?: boolean;
|
|
91
|
-
readonly idempotentHint?: boolean;
|
|
92
|
-
readonly openWorldHint?: boolean;
|
|
93
|
-
}
|
|
37
|
+
export type ActionMcp = Omit<McpExposureDeclaration, 'listParams'>;
|
|
94
38
|
|
|
95
39
|
export interface ActionRateLimit {
|
|
96
40
|
readonly limit: number;
|
|
@@ -180,6 +124,19 @@ export interface InvokeOptions {
|
|
|
180
124
|
readonly idempotencyKey?: string | null;
|
|
181
125
|
readonly store?: IdempotencyStore;
|
|
182
126
|
readonly onReplay?: () => void;
|
|
127
|
+
/**
|
|
128
|
+
* The caller's connection address, which is the only rate-limit subject an anonymous caller has
|
|
129
|
+
* (`actor > org > address`). The HTTP projection passes the request's; a surface with none
|
|
130
|
+
* leaves it out, and every anonymous caller there shares one bucket — refused together rather
|
|
131
|
+
* than unlimited.
|
|
132
|
+
*/
|
|
133
|
+
readonly clientAddress?: string | null;
|
|
134
|
+
/**
|
|
135
|
+
* Hands over the `rateLimit:` decision before a refusal is thrown — how the HTTP projection
|
|
136
|
+
* answers with `RateLimit-*` and `Retry-After`. Called only for a declared limit on a surface
|
|
137
|
+
* that spends one.
|
|
138
|
+
*/
|
|
139
|
+
readonly onRateLimit?: (decision: RateLimitDecision) => void;
|
|
183
140
|
}
|
|
184
141
|
|
|
185
142
|
export interface McpDescriptorMeta {
|
|
@@ -258,7 +215,6 @@ export interface AnyAction {
|
|
|
258
215
|
named(name: string): AnyAction;
|
|
259
216
|
/** Run as this actor. Same `invoke` core, only the context's actor changes. */
|
|
260
217
|
as(actor: Actor | null, input: unknown, options?: InvokeOptions): Promise<unknown>;
|
|
261
|
-
tool(): McpToolDescriptor;
|
|
262
218
|
openapi(): OpenApiOperation;
|
|
263
219
|
/**
|
|
264
220
|
* The durable-work shape, erased. `listActions()` and `getAction(name)` hand back this view
|
|
@@ -302,7 +258,7 @@ export interface Action<
|
|
|
302
258
|
/** The fluent half of an action: lifted declaration plus one method per projection. */
|
|
303
259
|
export type ActionFacade<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> = Pick<
|
|
304
260
|
Action<TInput, TOutput>,
|
|
305
|
-
'input' | 'output' | 'policy' | 'mcp' | 'as' | '
|
|
261
|
+
'input' | 'output' | 'policy' | 'mcp' | 'as' | 'openapi' | 'client' | 'job' | 'contract'
|
|
306
262
|
>;
|
|
307
263
|
|
|
308
264
|
export function action<
|
|
@@ -327,7 +283,7 @@ export function isAction(value: unknown): value is AnyAction {
|
|
|
327
283
|
/**
|
|
328
284
|
* Stamp the export name onto the action the app declared, rather than handing back a
|
|
329
285
|
* differently-named copy of it. `import { publishPost } from './actions'` is then the
|
|
330
|
-
* action that projects — `publishPost.
|
|
286
|
+
* action that projects — `publishPost.openapi()` after boot, with nothing to remember.
|
|
331
287
|
* Naming twice is the one case that still needs a twin: one object, one name, forever.
|
|
332
288
|
*/
|
|
333
289
|
export function nameAction<A extends AnyAction>(target: A, name: string): A {
|
package/src/api-declaration.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function apiDeclaration(): ApiDeclaration {
|
|
|
117
117
|
return declared;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/** Test seam, called by `
|
|
120
|
+
/** Test seam, called by `resetActions`. */
|
|
121
121
|
export function resetApiDeclaration(): void {
|
|
122
122
|
declared = {};
|
|
123
123
|
}
|
package/src/audit-gate.ts
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* an attempt that already failed do not want the same answer.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { isUltimateError, logger } from '@ultimat3/core';
|
|
7
|
+
import { getAuditSink, isUltimateError, logger } from '@ultimat3/core';
|
|
8
8
|
import type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from './audit';
|
|
9
|
-
import { getAuditSink } from './audit';
|
|
10
9
|
import { ActionDeniedError, AuditSinkFailedError, AuditSinkMissingError } from './errors';
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -63,7 +62,7 @@ export async function auditSettled(sink: AuditSink, record: AuditRecord): Promis
|
|
|
63
62
|
try {
|
|
64
63
|
await sink.write(record);
|
|
65
64
|
} catch (error) {
|
|
66
|
-
throw new AuditSinkFailedError(record.
|
|
65
|
+
throw new AuditSinkFailedError(record.name, error, record.idempotencyKey !== null);
|
|
67
66
|
}
|
|
68
67
|
}
|
|
69
68
|
|
|
@@ -84,7 +83,7 @@ export async function auditThrew(sink: AuditSink, record: AuditRecord): Promise<
|
|
|
84
83
|
// the same reason `cache-gate.ts` gives. Never the record — rendering an input the sink just
|
|
85
84
|
// choked on is the second throw this branch exists to prevent.
|
|
86
85
|
logger.error('audit.sink.failed', {
|
|
87
|
-
action: record.
|
|
86
|
+
action: record.name,
|
|
88
87
|
outcome: record.outcome,
|
|
89
88
|
error,
|
|
90
89
|
});
|
package/src/audit-memory.ts
CHANGED
|
@@ -11,8 +11,8 @@ import type { AuditRecord, AuditSink } from './audit';
|
|
|
11
11
|
* input — so its cost is the request's, not a row's: at 50 audited writes a second an unbounded
|
|
12
12
|
* array is 4.3M immortal records a day and the pod dies holding the trail it was retaining. This
|
|
13
13
|
* sink was the one memory implementation in the framework with no cap, beside five that have one
|
|
14
|
-
* (`memoryRateLimitStore`, `
|
|
15
|
-
* `
|
|
14
|
+
* (`memoryRateLimitStore`, `memoryIdempotencyStore`, `concurrencyLimiter`, `memoryTotpReplayGuard`,
|
|
15
|
+
* `memoryEventBus`).
|
|
16
16
|
*/
|
|
17
17
|
export const DEFAULT_MAX_AUDIT_RECORDS = 1_000;
|
|
18
18
|
|
|
@@ -46,7 +46,7 @@ export interface MemoryAuditSink extends AuditSink {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* The OLDEST goes, which is the same direction `
|
|
49
|
+
* The OLDEST goes, which is the same direction `memoryEventBus` evicts in and the opposite
|
|
50
50
|
* of refusing new writes: a sink that stopped recording at the cap would answer "nothing has
|
|
51
51
|
* happened since" for a process that has been serving all day, and the most recent attempts are
|
|
52
52
|
* the ones anyone reading `x dev` is looking at.
|
package/src/audit-postgres.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Actor, PgExecutor } from '@ultimat3/core';
|
|
9
|
-
import {
|
|
9
|
+
import { uuidV7 } from '@ultimat3/core';
|
|
10
10
|
import type { AuditRecord, AuditSink } from './audit';
|
|
11
11
|
import { auditableInput } from './audit-input';
|
|
12
12
|
|
|
@@ -34,6 +34,10 @@ import { auditableInput } from './audit-input';
|
|
|
34
34
|
* a different answer per app — seven years for one, thirty days for the next — so shipping a
|
|
35
35
|
* `delete` would be shipping one of those answers. The table grows until the app prunes or
|
|
36
36
|
* partitions it, and that is stated rather than solved.
|
|
37
|
+
*
|
|
38
|
+
* **`primitive` arrived by `alter`, with a default of `'action'`.** The sink is shared with
|
|
39
|
+
* `query({ audit: true })` since plan 101 sweep 10b, so a row has to say whether a read or a write
|
|
40
|
+
* acted; every row written before that was an action's, which is exactly what the default says.
|
|
37
41
|
*/
|
|
38
42
|
export const SQL_AUDIT_TABLE = `
|
|
39
43
|
create table if not exists x_audit (
|
|
@@ -61,6 +65,8 @@ create table if not exists x_audit (
|
|
|
61
65
|
recorded_at timestamptz not null default now()
|
|
62
66
|
);
|
|
63
67
|
|
|
68
|
+
alter table x_audit add column if not exists primitive text not null default 'action';
|
|
69
|
+
|
|
64
70
|
create index if not exists x_audit_at_idx on x_audit (at desc);
|
|
65
71
|
|
|
66
72
|
create index if not exists x_audit_actor_at_idx on x_audit (actor_id, at desc);
|
|
@@ -79,11 +85,11 @@ export const SQL_AUDIT_INSERT = `
|
|
|
79
85
|
insert into x_audit (
|
|
80
86
|
id, at, action, mutator, surface, outcome, replayed, idempotency_key, failure_code,
|
|
81
87
|
actor_id, actor_kind, org_id, on_behalf_of_id, on_behalf_of_kind,
|
|
82
|
-
request_id, trace_id, locale, tz, build_id, role, input
|
|
88
|
+
request_id, trace_id, locale, tz, build_id, role, input, primitive
|
|
83
89
|
) values (
|
|
84
90
|
$1::uuid, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9,
|
|
85
91
|
$10, $11, $12, $13, $14,
|
|
86
|
-
$15, $16, $17, $18, $19, $20, $21::jsonb
|
|
92
|
+
$15, $16, $17, $18, $19, $20, $21::jsonb, $22
|
|
87
93
|
)
|
|
88
94
|
`;
|
|
89
95
|
|
|
@@ -111,7 +117,7 @@ export interface PostgresAuditSink extends AuditSink {
|
|
|
111
117
|
* `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
|
|
112
118
|
*
|
|
113
119
|
* What is written is an ALLOW-LIST of the facts the framework itself owns, never the `Ctx`. That
|
|
114
|
-
* is not tidiness: `
|
|
120
|
+
* is not tidiness: `ctxOf` spreads every installed service onto the context object, and on
|
|
115
121
|
* an HTTP surface the value is a `RequestContext` carrying the request's own `Authorization` and
|
|
116
122
|
* `Cookie` headers — so a projection that walked it would write an app's database clients and its
|
|
117
123
|
* caller's credentials into an audit table. An app that wants more columns writes its own
|
|
@@ -126,9 +132,11 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
|
|
|
126
132
|
const onBehalfOf = onBehalfOfOf(actor);
|
|
127
133
|
const input = auditableInput(record.input);
|
|
128
134
|
await exec.query(SQL_AUDIT_INSERT, [
|
|
129
|
-
|
|
135
|
+
uuidV7(),
|
|
130
136
|
at === null ? null : at.toISOString(),
|
|
131
|
-
|
|
137
|
+
// `name`, into the column the seam named before reads were audited: renaming it is a data
|
|
138
|
+
// migration for every installed table, and the value it holds is the same.
|
|
139
|
+
record.name,
|
|
132
140
|
record.mutator,
|
|
133
141
|
record.surface,
|
|
134
142
|
record.outcome,
|
|
@@ -149,6 +157,7 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
|
|
|
149
157
|
// `undefined` in means a parse that never produced an input, which is a NULL column and
|
|
150
158
|
// not the four characters `JSON.stringify(undefined)` does not produce either.
|
|
151
159
|
input === undefined ? null : JSON.stringify(input),
|
|
160
|
+
record.primitive,
|
|
152
161
|
]);
|
|
153
162
|
},
|
|
154
163
|
};
|
package/src/audit.ts
CHANGED
|
@@ -1,109 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The audit seam: THAT an action or mutator can be recorded at all.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The audit seam, as this package exposes it: THAT an action or mutator can be recorded at all.
|
|
3
|
+
* The contract — the record, the sink, the one installed-sink slot — moved to `@ultimat3/core`'s
|
|
4
|
+
* `audit.ts` so `@ultimat3/query` (a sibling this package cannot import) writes the same record
|
|
5
|
+
* into the same sink. The TYPES are re-exported for this package's own modules; the slot's VALUES
|
|
6
|
+
* (`getAuditSink`, `setAuditSink`, `resetAuditSink`) are imported from `@ultimat3/core` and never
|
|
7
|
+
* re-exported — a second import path for one value is `X_HELPER_COPY` (`bun run flight-copies`).
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
import type { Surface } from './policy-gate';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The three things that can happen to an attempt. Deliberately the same three words
|
|
14
|
-
* `@ultimat3/admin`'s `AuditEntry` uses — that package is tier 5 and this one is tier 3, so the
|
|
15
|
-
* vocabulary is shared by name and not by import. `denied` is an authz refusal, `failed` is
|
|
16
|
-
* everything else that threw, including an input that never parsed.
|
|
17
|
-
*/
|
|
18
|
-
export type AuditOutcome = 'allowed' | 'denied' | 'failed';
|
|
19
|
-
|
|
20
|
-
/** Why a non-`allowed` attempt ended. The framework classifies; it never renders. */
|
|
21
|
-
export interface AuditFailure {
|
|
22
|
-
/** The `X_*` code when an `UltimateError` ended it; `null` for anything else that threw. */
|
|
23
|
-
readonly code: string | null;
|
|
24
|
-
/** The thrown value, verbatim — its stack is the thing worth reading. */
|
|
25
|
-
readonly error: unknown;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* One attempt, as the framework observed it. Every field here is something `invoke` already
|
|
30
|
-
* holds; nothing on it is a guess about the business.
|
|
31
|
-
*
|
|
32
|
-
* `result` is deliberately absent. A handler's return value is reachable from the handler
|
|
33
|
-
* itself, on the one outcome that has one — so shipping it would be the framework deciding the
|
|
34
|
-
* row carries an after-image, which is the first field of an audit ENTITY. `input` is present
|
|
35
|
-
* for the opposite reason: on a `denied` record the handler never ran, so nothing in app code
|
|
36
|
-
* can recover what was attempted, and that is the record an auditor actually wants.
|
|
37
|
-
*/
|
|
38
|
-
export interface AuditRecord {
|
|
39
|
-
/**
|
|
40
|
-
* When the attempt began, from `ctx.now()` — never `new Date()`. An instant, not a rendering:
|
|
41
|
-
* serialising it (ISO, epoch, a Postgres `timestamptz`) is the app's decision, and it is the
|
|
42
|
-
* app that knows the zone anything is displayed in.
|
|
43
|
-
*/
|
|
44
|
-
readonly at: Date;
|
|
45
|
-
/** The registered export name. A mutator carries the name of its action half. */
|
|
46
|
-
readonly action: string;
|
|
47
|
-
/** True when `mutator()` built it. Which primitive acted is a framework fact. */
|
|
48
|
-
readonly mutator: boolean;
|
|
49
|
-
/** Which projection ran it: a price change over `http` and one over `mcp` are not the same event. */
|
|
50
|
-
readonly surface: Surface;
|
|
51
|
-
/**
|
|
52
|
-
* The context the attempt ran in — actor, `requestId`, `traceId`, locale, and the service bag
|
|
53
|
-
* a sink needs to write a row at all. Carried whole rather than projected into `actorId` +
|
|
54
|
-
* `requestId` fields, because choosing WHICH context facts an audit row keeps is precisely the
|
|
55
|
-
* convention four apps modelled four ways.
|
|
56
|
-
*
|
|
57
|
-
* **A sink that PERSISTS must project it, and `audit-postgres.ts` is where that is done.**
|
|
58
|
-
* `createContext` spreads every installed service onto this object and an HTTP surface's value
|
|
59
|
-
* is a `RequestContext` carrying the caller's `Authorization` and `Cookie`, so writing it down
|
|
60
|
-
* whole puts an app's database clients and its caller's credentials in a table. Whole here,
|
|
61
|
-
* allow-listed there — the seam hands over everything and each sink decides what it keeps.
|
|
62
|
-
*/
|
|
63
|
-
readonly ctx: Ctx;
|
|
64
|
-
/**
|
|
65
|
-
* The PARSED input, or `undefined` when the parse is what failed. Never the raw payload:
|
|
66
|
-
* an unvalidated body is attacker-shaped, and handing one to a sink that writes it to a table
|
|
67
|
-
* is how an audit trail becomes an injection surface.
|
|
68
|
-
*/
|
|
69
|
-
readonly input: unknown;
|
|
70
|
-
/** The namespaced key an `idempotent` action was retried under, or `null`. */
|
|
71
|
-
readonly idempotencyKey: string | null;
|
|
72
|
-
/** True when the response was replayed from an earlier settled record — a call, not a write. */
|
|
73
|
-
readonly replayed: boolean;
|
|
74
|
-
readonly outcome: AuditOutcome;
|
|
75
|
-
/** Present exactly when `outcome !== 'allowed'`. */
|
|
76
|
-
readonly failure: AuditFailure | null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Where a record goes. The app's implementation: a table, an append-only hash chain, an OTel
|
|
81
|
-
* log, a queue. `@ultimat3/admin`'s `AuditSink` is the same noun one tier up, over its own
|
|
82
|
-
* fixed entry type; this one carries no `AdminActor` and no `permission`, because an action
|
|
83
|
-
* outside `/admin` has neither.
|
|
84
|
-
*
|
|
85
|
-
* A sink that throws is never swallowed — see `audit-gate.ts` for which failure wins.
|
|
86
|
-
*/
|
|
87
|
-
export interface AuditSink {
|
|
88
|
-
write(record: AuditRecord): Promise<void> | void;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* No default. A logger-backed default would satisfy `audit: true` with a line nobody stores,
|
|
93
|
-
* which is the silent pass this seam exists to remove: an audited action with no sink installed
|
|
94
|
-
* is `X_AUDIT_SINK_MISSING`, refused before the handler runs.
|
|
95
|
-
*/
|
|
96
|
-
let installed: AuditSink | null = null;
|
|
97
|
-
|
|
98
|
-
export function setAuditSink(sink: AuditSink): void {
|
|
99
|
-
installed = sink;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function getAuditSink(): AuditSink | null {
|
|
103
|
-
return installed;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Test seam: back to "nothing installed", which restoring a literal cannot express. */
|
|
107
|
-
export function resetAuditSink(): void {
|
|
108
|
-
installed = null;
|
|
109
|
-
}
|
|
10
|
+
export type { AuditFailure, AuditOutcome, AuditRecord, AuditSink } from '@ultimat3/core';
|
package/src/client.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* compile error in a Solid component — not a 404 at runtime.
|
|
5
5
|
*
|
|
6
6
|
* `ClientFlight` is a TYPE here and never a value: the fence, the retry loop and the deadline are
|
|
7
|
-
* `@ultimat3/core`'s `client-flight.ts`, so a caller that never calls `
|
|
7
|
+
* `@ultimat3/core`'s `client-flight.ts`, so a caller that never calls `clientFlight` does
|
|
8
8
|
* not pay a byte for any of them — an `import type` is erased and the value import would not be.
|
|
9
9
|
* Dedup is deliberately unreachable from this file — a mutation may never join another mutation,
|
|
10
10
|
* and the way that is guaranteed is that `keyFor` is never called here.
|
|
@@ -24,15 +24,16 @@ import type {
|
|
|
24
24
|
} from '@ultimat3/core';
|
|
25
25
|
import {
|
|
26
26
|
actionPath,
|
|
27
|
+
BUILD_ID_HEADER,
|
|
27
28
|
clientTransport,
|
|
28
29
|
FRAMEWORK_CODE,
|
|
29
30
|
isJsonObject,
|
|
30
31
|
problemOf,
|
|
32
|
+
remoteTitleOf,
|
|
31
33
|
} from '@ultimat3/core';
|
|
32
34
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
33
35
|
import type { Action } from './action';
|
|
34
36
|
import { ContractDriftError, RemoteActionError } from './errors';
|
|
35
|
-
import { BUILD_ID_HEADER } from './wire-headers';
|
|
36
37
|
import { issuesFromWire } from './wire-issues';
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -79,7 +80,7 @@ export interface ClientOptions {
|
|
|
79
80
|
readonly buildId?: string;
|
|
80
81
|
readonly headers?: Readonly<Record<string, string>>;
|
|
81
82
|
/**
|
|
82
|
-
* Opt-in flight control — `
|
|
83
|
+
* Opt-in flight control — `clientFlight({ … })`. Absent, a call is one dispatch and
|
|
83
84
|
* nothing else, which is what every caller written before this option existed already gets.
|
|
84
85
|
*/
|
|
85
86
|
readonly flight?: ClientFlight;
|
|
@@ -132,8 +133,11 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
|
|
|
132
133
|
const url = `${options.baseUrl.replace(/\/+$/, '')}${path}`;
|
|
133
134
|
const onResponse = (response: Response): void =>
|
|
134
135
|
assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
|
|
135
|
-
const decodeError = (
|
|
136
|
-
|
|
136
|
+
const decodeError = (
|
|
137
|
+
status: number,
|
|
138
|
+
text: string,
|
|
139
|
+
retryAfterSeconds?: number,
|
|
140
|
+
): UltimateError | undefined => toUltimateError(text, status, name, retryAfterSeconds);
|
|
137
141
|
// Erased at the wire seam; the response type is this action's by construction.
|
|
138
142
|
return (input, callOptions = {}) =>
|
|
139
143
|
clientTransport({
|
|
@@ -197,7 +201,12 @@ function assertSameBuild(
|
|
|
197
201
|
* code a query gets for the same failure. `X_RPC_FAILED` was this branch until 21.0.0; the code
|
|
198
202
|
* stays registered (shipped codes never change) and nothing in the framework throws it now.
|
|
199
203
|
*/
|
|
200
|
-
function toUltimateError(
|
|
204
|
+
function toUltimateError(
|
|
205
|
+
text: string,
|
|
206
|
+
status: number,
|
|
207
|
+
name: string,
|
|
208
|
+
retryAfterSeconds: number | undefined,
|
|
209
|
+
): UltimateError | undefined {
|
|
201
210
|
// `problemOf` is total — a gateway's HTML, an empty body and a truncated stream all answer `{}`,
|
|
202
211
|
// which carries no `code` and therefore falls through to the transport's decode.
|
|
203
212
|
const body = problemOf(text);
|
|
@@ -207,6 +216,9 @@ function toUltimateError(text: string, status: number, name: string): UltimateEr
|
|
|
207
216
|
action: name,
|
|
208
217
|
status,
|
|
209
218
|
code,
|
|
219
|
+
// Core's reader, so an island titles an http refusal it never loaded the table for.
|
|
220
|
+
title: remoteTitleOf(body['title']),
|
|
221
|
+
retryAfterSeconds,
|
|
210
222
|
// Parsed, never taken: `body` is whatever answered the request. A list this build cannot read
|
|
211
223
|
// is dropped rather than repaired, and `cause` below still carries every rejection in it.
|
|
212
224
|
issues: issuesFromWire(body['issues']),
|
package/src/contract-test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { ctxOf, isJsonObject, isUltimateError, logger, renderFixShellArg } from '@ultimat3/core';
|
|
9
9
|
import type { AnyAction } from './action';
|
|
10
10
|
import { ActionDeniedError, ContractDriftError } from './errors';
|
|
11
11
|
import { toOpenApiOperation } from './http';
|
|
@@ -14,7 +14,6 @@ import { actionName, invoke } from './invoke';
|
|
|
14
14
|
import { buildOpenApi } from './openapi';
|
|
15
15
|
import { listActions } from './registry';
|
|
16
16
|
import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
|
|
17
|
-
import { isJsonObject } from './stable';
|
|
18
17
|
import { validateInput } from './validate';
|
|
19
18
|
|
|
20
19
|
export interface ContractTest {
|
|
@@ -37,7 +36,7 @@ export interface ContractTestOptions {
|
|
|
37
36
|
|
|
38
37
|
/** A context whose actor is core's anonymous actor — what a signed-out caller has. */
|
|
39
38
|
export function anonymousCtx(): Ctx {
|
|
40
|
-
return
|
|
39
|
+
return ctxOf({});
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
export function contractTestsFor(
|
|
@@ -95,13 +94,26 @@ function assertDocumented(target: AnyAction, name: string): void {
|
|
|
95
94
|
const published = buildOpenApi({ actions }).paths[path];
|
|
96
95
|
const owner = operationIdAt(published);
|
|
97
96
|
if (owner === toOpenApiOperation(target).operationId) return;
|
|
98
|
-
throw
|
|
97
|
+
throw documentedDrift(path, owner, name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The refusal when the published document does not name this action at its route. `name` is
|
|
102
|
+
* whatever `.named()` was handed — nothing validates its alphabet — so it is screened where it
|
|
103
|
+
* enters the `x actions describe` command (security audit of plan 101 sweep 1c).
|
|
104
|
+
*/
|
|
105
|
+
export function documentedDrift(
|
|
106
|
+
path: string,
|
|
107
|
+
owner: string | undefined,
|
|
108
|
+
name: string,
|
|
109
|
+
): ContractDriftError {
|
|
110
|
+
return new ContractDriftError(
|
|
99
111
|
owner === undefined
|
|
100
112
|
? `OpenAPI document has no entry for ${path}`
|
|
101
113
|
: `OpenAPI document serves ${path} as ${owner}, so ${name} is not in the published contract`,
|
|
102
114
|
owner === undefined
|
|
103
115
|
? 'x verify --json # the contract suite is a step of it'
|
|
104
|
-
: `x actions describe ${name} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
|
|
116
|
+
: `x actions describe ${renderFixShellArg(name, '<action>')} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
|
|
105
117
|
);
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -176,7 +188,7 @@ async function expectDenied(
|
|
|
176
188
|
if (error instanceof ActionDeniedError) return;
|
|
177
189
|
// `invoke` runs the policy's actor half → parse input → row → policy → handle → parse
|
|
178
190
|
// output, and every stage lands here identically. Only `X_INPUT_INVALID` is attributable: it
|
|
179
|
-
// is what `validateInput` raises before `
|
|
191
|
+
// is what `validateInput` raises before `guardAction()` is reached (for a policy whose actor half
|
|
180
192
|
// could not decide), and `input:` is the knob that answers it. Any other
|
|
181
193
|
// code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
|
|
182
194
|
// `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ultimat3/action's error TITLES, registered at import — the one module `sideEffects` names, so
|
|
2
|
+
// `errors.ts` (the classes) is unlisted and a chunk carries it only where it constructs a class:
|
|
3
|
+
// Bun >= 1.4.1 honours the array and keeps a listed module wherever the barrel reaches it.
|
|
4
|
+
// `index.ts` imports this bare, because `problemError` rebuilds a server's refusal by code and reads
|
|
5
|
+
// its title from the registry; `errors.ts` and its two siblings import it bare as well.
|
|
6
|
+
|
|
7
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Titles for the framework-wide code table — every one of them owned by this package.
|
|
11
|
+
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
|
|
12
|
+
* enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
|
|
13
|
+
* Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
|
|
14
|
+
*/
|
|
15
|
+
export const ACTION_ERROR_TITLES: Readonly<Record<string, string>> = {
|
|
16
|
+
X_ACTION_DUPLICATE: 'two actions are registered under one name',
|
|
17
|
+
X_AUDIT_SINK_FAILED: 'an audited action ran and the audit sink refused its record',
|
|
18
|
+
X_AUDIT_SINK_MISSING: 'an action declares audit: true and no audit sink is installed',
|
|
19
|
+
X_ACTION_DEPRECATION_INVALID: 'an action declares a deprecation whose dates cannot be rendered',
|
|
20
|
+
X_ACTION_PATH_DUPLICATE: 'two actions derive one HTTP path',
|
|
21
|
+
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
|
|
22
|
+
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
|
|
23
|
+
X_ACTION_UNREGISTERED: 'an action was projected before it was registered',
|
|
24
|
+
X_CONTRACT_DRIFT: 'client and server disagree about the contract',
|
|
25
|
+
X_IDEMPOTENCY_CONFLICT: 'idempotency key reused with a different payload or still in flight',
|
|
26
|
+
X_IDEMPOTENCY_KEY_INVALID: 'an Idempotency-Key was sent that cannot identify one request',
|
|
27
|
+
X_IDEMPOTENCY_NOT_SHARED:
|
|
28
|
+
'idempotency is declared fleet-wide and the installed store is per-process',
|
|
29
|
+
X_IDEMPOTENCY_REPLAYED_FAILURE:
|
|
30
|
+
'a retried Idempotency-Key replays a first attempt that failed after it may have committed',
|
|
31
|
+
X_IDEMPOTENCY_STATUS_UNKNOWN: 'an idempotency record holds a status this build cannot read',
|
|
32
|
+
X_INPUT_INVALID: 'input failed schema validation',
|
|
33
|
+
X_MUTATOR_CLOCK_MISSING:
|
|
34
|
+
"a mutator declares conflict: 'last-write-wins' and its entity has no number clock column",
|
|
35
|
+
X_OUTPUT_INVALID: 'a handler returned a value its output schema rejects',
|
|
36
|
+
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
|
|
37
|
+
X_ACTION_HTTP_PATH_INVALID: "an action's pinned http.path is not a static lowercase path",
|
|
38
|
+
X_ACTION_PATH_STYLE_INVALID: "defineApi's http.pathStyle is not a known style",
|
|
39
|
+
X_OPENAPI_CONFIG_INVALID: "defineApi's openapi block cannot produce a valid document",
|
|
40
|
+
X_ACTION_PATH_DERIVED_EARLY: 'a path was derived before pathStyle changed',
|
|
41
|
+
X_MUTATOR_NOT_IDEMPOTENT: 'a mutator is declared without idempotent: true',
|
|
42
|
+
X_IDEMPOTENCY_RESERVATION_LOST:
|
|
43
|
+
"an idempotent action's reservation was taken over before its transaction could settle it",
|
|
44
|
+
X_IDEMPOTENT_REPLAY_REDACTED: 'an idempotent replay would return a field redacted at rest',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// One unconditional call: a presence guard would turn "another package claims one of these codes"
|
|
48
|
+
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
|
|
49
|
+
registerErrorCodes(
|
|
50
|
+
Object.fromEntries(Object.entries(ACTION_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
51
|
+
);
|
package/src/errors-http.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The refusals of the HTTP declaration an app makes about its actions: a pinned `http.path`, the
|
|
3
3
|
* app's `pathStyle`, and the `openapi` block. Split from `errors.ts` at its ceiling; the codes and
|
|
4
|
-
* titles
|
|
4
|
+
* titles are registered in `error-titles.ts`, one registry, imported bare so constructing one of
|
|
5
|
+
* these registers its title first.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
9
|
+
import './error-titles';
|
|
8
10
|
|
|
9
11
|
export class ActionHttpPathInvalidError extends UltimateError {
|
|
10
12
|
constructor(input: { name: string; path: unknown; reason: string }) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The idempotency failures, split out of `errors.ts` at its line ceiling. One subclass per
|
|
3
|
-
* stable code, exactly as there — the codes and their titles stay in `
|
|
3
|
+
* stable code, exactly as there — the codes and their titles stay in `error-titles.ts`'s one
|
|
4
4
|
* `registerErrorCodes` call, because a second registration is how two modules end up deciding a
|
|
5
|
-
* title by load order.
|
|
5
|
+
* title by load order. Imported bare, so constructing one of these registers its title first.
|
|
6
6
|
*/
|
|
7
7
|
import { renderCauseValue, UltimateError } from '@ultimat3/core';
|
|
8
|
+
import './error-titles';
|
|
8
9
|
// Type-only: `idempotency.ts` imports the classes below, and a runtime edge here would close the
|
|
9
10
|
// cycle. `verbatimModuleSyntax` is what makes that guarantee mechanical.
|
|
10
11
|
import type { IdempotencyFailure } from './idempotency';
|
|
@@ -84,7 +85,7 @@ export class IdempotencyNotSharedError extends UltimateError {
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `
|
|
88
|
+
* The replay of a first attempt that FAILED. It is a replay and not a re-run on purpose: `guardAction()`
|
|
88
89
|
* and the input parse both run before the idempotency gate, so everything the gate can see throw
|
|
89
90
|
* is post-authorization and possibly post-commit — a handler that took the money and then failed
|
|
90
91
|
* its own `output:` schema is the case this exists for. Releasing the reservation there let the
|
|
@@ -172,3 +173,20 @@ export class IdempotencyReservationLostError extends UltimateError {
|
|
|
172
173
|
});
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A replay of a settled answer that had a field redacted at rest. Refused, never served: the
|
|
179
|
+
* stored copy holds `[redacted]` where the first caller got a credential, and handing that back
|
|
180
|
+
* as "the answer" is a silent wrong value. The first attempt RAN — a fresh key runs it again, so
|
|
181
|
+
* the fix names reading its effect as the way to recover the result.
|
|
182
|
+
*/
|
|
183
|
+
export class IdempotentReplayRedactedError extends UltimateError {
|
|
184
|
+
constructor(key: string) {
|
|
185
|
+
super({
|
|
186
|
+
code: 'X_IDEMPOTENT_REPLAY_REDACTED',
|
|
187
|
+
cause: `the answer stored for idempotency key "${key}" had a secret-named field redacted when it settled, so it cannot be replayed faithfully`,
|
|
188
|
+
fix: "x actions describe '<action>' --json # the call already ran and showed its secret once — read its effect with a query; a new Idempotency-Key runs it again (a second credential)",
|
|
189
|
+
meta: { key, replayed: true },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|