@ultimat3/action 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +91 -42
- package/README.md +167 -47
- package/package.json +10 -9
- 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 +5 -3
- package/src/audit-postgres.ts +16 -8
- package/src/audit.ts +7 -106
- package/src/cache-gate.ts +25 -6
- package/src/client.ts +18 -6
- package/src/contract-test.ts +50 -14
- package/src/error-titles.ts +51 -0
- package/src/errors-http.ts +3 -1
- package/src/errors-idempotency.ts +57 -5
- package/src/errors.ts +44 -61
- package/src/facade.ts +6 -7
- package/src/http-path.ts +1 -1
- package/src/http.ts +23 -20
- package/src/idempotency-memory.ts +92 -8
- package/src/idempotency-postgres.ts +91 -39
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +58 -7
- package/src/index.ts +37 -88
- package/src/invoke.ts +57 -21
- package/src/job-handle.ts +5 -3
- package/src/json-schema.ts +6 -10
- package/src/mutator.ts +14 -4
- package/src/naming.ts +2 -3
- package/src/openapi-complete.ts +16 -1
- package/src/openapi.ts +25 -2
- 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 +5 -4
- package/src/request-deadline.ts +25 -0
- package/src/stable.ts +0 -7
- package/src/transition.ts +35 -9
- package/src/tx-scope.ts +57 -0
- package/src/type-pins.ts +98 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -87
- 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
|
|
|
@@ -34,6 +34,8 @@ export interface MemoryAuditSinkOptions {
|
|
|
34
34
|
* here: a non-zero count on a real deployment is the sink saying it is the wrong one.
|
|
35
35
|
*/
|
|
36
36
|
export interface MemoryAuditSink extends AuditSink {
|
|
37
|
+
/** Synchronous here: the window is an array, so nothing is left for a caller to await. */
|
|
38
|
+
write(record: AuditRecord): void;
|
|
37
39
|
/** The retained window, oldest first. A copy — the log cannot be mutated through it. */
|
|
38
40
|
records(): readonly AuditRecord[];
|
|
39
41
|
/** Retained right now — the bound, observable. */
|
|
@@ -44,7 +46,7 @@ export interface MemoryAuditSink extends AuditSink {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
|
-
* The OLDEST goes, which is the same direction `
|
|
49
|
+
* The OLDEST goes, which is the same direction `memoryEventBus` evicts in and the opposite
|
|
48
50
|
* of refusing new writes: a sink that stopped recording at the cap would answer "nothing has
|
|
49
51
|
* happened since" for a process that has been serving all day, and the most recent attempts are
|
|
50
52
|
* the ones anyone reading `x dev` is looking at.
|
package/src/audit-postgres.ts
CHANGED
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
* production. Statements are spelled out so an agent can run the exact one it saw in a log.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { Actor } from '@ultimat3/core';
|
|
9
|
-
import {
|
|
8
|
+
import type { Actor, PgExecutor } from '@ultimat3/core';
|
|
9
|
+
import { uuidV7 } from '@ultimat3/core';
|
|
10
10
|
import type { AuditRecord, AuditSink } from './audit';
|
|
11
11
|
import { auditableInput } from './audit-input';
|
|
12
|
-
import type { PgExecutor } from './idempotency-postgres';
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
14
|
* Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, and
|
|
@@ -35,6 +34,10 @@ import type { PgExecutor } from './idempotency-postgres';
|
|
|
35
34
|
* a different answer per app — seven years for one, thirty days for the next — so shipping a
|
|
36
35
|
* `delete` would be shipping one of those answers. The table grows until the app prunes or
|
|
37
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.
|
|
38
41
|
*/
|
|
39
42
|
export const SQL_AUDIT_TABLE = `
|
|
40
43
|
create table if not exists x_audit (
|
|
@@ -62,6 +65,8 @@ create table if not exists x_audit (
|
|
|
62
65
|
recorded_at timestamptz not null default now()
|
|
63
66
|
);
|
|
64
67
|
|
|
68
|
+
alter table x_audit add column if not exists primitive text not null default 'action';
|
|
69
|
+
|
|
65
70
|
create index if not exists x_audit_at_idx on x_audit (at desc);
|
|
66
71
|
|
|
67
72
|
create index if not exists x_audit_actor_at_idx on x_audit (actor_id, at desc);
|
|
@@ -80,11 +85,11 @@ export const SQL_AUDIT_INSERT = `
|
|
|
80
85
|
insert into x_audit (
|
|
81
86
|
id, at, action, mutator, surface, outcome, replayed, idempotency_key, failure_code,
|
|
82
87
|
actor_id, actor_kind, org_id, on_behalf_of_id, on_behalf_of_kind,
|
|
83
|
-
request_id, trace_id, locale, tz, build_id, role, input
|
|
88
|
+
request_id, trace_id, locale, tz, build_id, role, input, primitive
|
|
84
89
|
) values (
|
|
85
90
|
$1::uuid, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9,
|
|
86
91
|
$10, $11, $12, $13, $14,
|
|
87
|
-
$15, $16, $17, $18, $19, $20, $21::jsonb
|
|
92
|
+
$15, $16, $17, $18, $19, $20, $21::jsonb, $22
|
|
88
93
|
)
|
|
89
94
|
`;
|
|
90
95
|
|
|
@@ -112,7 +117,7 @@ export interface PostgresAuditSink extends AuditSink {
|
|
|
112
117
|
* `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
|
|
113
118
|
*
|
|
114
119
|
* What is written is an ALLOW-LIST of the facts the framework itself owns, never the `Ctx`. That
|
|
115
|
-
* is not tidiness: `
|
|
120
|
+
* is not tidiness: `ctxOf` spreads every installed service onto the context object, and on
|
|
116
121
|
* an HTTP surface the value is a `RequestContext` carrying the request's own `Authorization` and
|
|
117
122
|
* `Cookie` headers — so a projection that walked it would write an app's database clients and its
|
|
118
123
|
* caller's credentials into an audit table. An app that wants more columns writes its own
|
|
@@ -127,9 +132,11 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
|
|
|
127
132
|
const onBehalfOf = onBehalfOfOf(actor);
|
|
128
133
|
const input = auditableInput(record.input);
|
|
129
134
|
await exec.query(SQL_AUDIT_INSERT, [
|
|
130
|
-
|
|
135
|
+
uuidV7(),
|
|
131
136
|
at === null ? null : at.toISOString(),
|
|
132
|
-
|
|
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,
|
|
133
140
|
record.mutator,
|
|
134
141
|
record.surface,
|
|
135
142
|
record.outcome,
|
|
@@ -150,6 +157,7 @@ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAu
|
|
|
150
157
|
// `undefined` in means a parse that never produced an input, which is a NULL column and
|
|
151
158
|
// not the four characters `JSON.stringify(undefined)` does not produce either.
|
|
152
159
|
input === undefined ? null : JSON.stringify(input),
|
|
160
|
+
record.primitive,
|
|
153
161
|
]);
|
|
154
162
|
},
|
|
155
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/cache-gate.ts
CHANGED
|
@@ -1,22 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The post-commit cache bust — the only file in this package that calls `invalidateTags`.
|
|
3
|
-
*
|
|
4
|
-
* one logged failure instead of a failed action
|
|
5
|
-
* keeps the write it already made.
|
|
3
|
+
* Post-COMMIT, not post-handler: inside a transaction it waits for the root commit. A fan-out that
|
|
4
|
+
* refuses degrades to one logged failure instead of a failed action — the stale entries expire by
|
|
5
|
+
* TTL, and the caller keeps the write it already made.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { CacheTag, InvalidationReport } from '@ultimat3/cache';
|
|
9
9
|
import { invalidateTags } from '@ultimat3/cache';
|
|
10
10
|
import { logger } from '@ultimat3/core';
|
|
11
|
+
import { type CommitScope, openCommitScope } from './tx-scope';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Fan `tags` out and never throw. `undefined` back means
|
|
14
|
-
*
|
|
15
|
-
* is not that case: `invalidateTags` absorbs those into `report.errors
|
|
14
|
+
* Fan `tags` out and never throw. `undefined` back means nothing was cleared BY THIS CALL: the
|
|
15
|
+
* fan-out refused — an undeclared tag (`X_CACHE_TAG_UNKNOWN`) is the one an app hits — or it was
|
|
16
|
+
* deferred. One dead tier is not that case: `invalidateTags` absorbs those into `report.errors`.
|
|
17
|
+
*
|
|
18
|
+
* Inside a transaction (`scope`, the ambient one unless a caller passes its own) the handler's rows
|
|
19
|
+
* are not durable when it returns. Busting there let a concurrent read refill the cache with the
|
|
20
|
+
* pre-commit rows, which then stayed for the TTL — and a rollback busted for a write nobody made.
|
|
21
|
+
* So the bust is handed to the ROOT commit, as `@ultimat3/entity`'s row observer hands its report:
|
|
22
|
+
* `onCommit` never fires for a transaction that rolled back or whose COMMIT was lost.
|
|
16
23
|
*/
|
|
17
24
|
export async function bustAfterCommit(
|
|
18
25
|
action: string,
|
|
19
26
|
tags: readonly CacheTag[],
|
|
27
|
+
scope: CommitScope | undefined = openCommitScope(),
|
|
28
|
+
): Promise<InvalidationReport | undefined> {
|
|
29
|
+
if (scope === undefined) return bust(action, tags);
|
|
30
|
+
// Not awaited, and it cannot be: a commit effect is synchronous and runs after the transaction
|
|
31
|
+
// is durable. `bust` never rejects, so there is no rejection for the missing await to lose.
|
|
32
|
+
scope.onCommit(() => void bust(action, tags));
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function bust(
|
|
37
|
+
action: string,
|
|
38
|
+
tags: readonly CacheTag[],
|
|
20
39
|
): Promise<InvalidationReport | undefined> {
|
|
21
40
|
try {
|
|
22
41
|
return await invalidateTags(tags);
|
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,12 +5,14 @@
|
|
|
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
|
+
import { toOpenApiOperation } from './http';
|
|
12
|
+
import { servedActionRoute } from './http-path';
|
|
11
13
|
import { actionName, invoke } from './invoke';
|
|
12
|
-
import { derivePath } from './naming';
|
|
13
14
|
import { buildOpenApi } from './openapi';
|
|
15
|
+
import { listActions } from './registry';
|
|
14
16
|
import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
|
|
15
17
|
import { validateInput } from './validate';
|
|
16
18
|
|
|
@@ -34,7 +36,7 @@ export interface ContractTestOptions {
|
|
|
34
36
|
|
|
35
37
|
/** A context whose actor is core's anonymous actor — what a signed-out caller has. */
|
|
36
38
|
export function anonymousCtx(): Ctx {
|
|
37
|
-
return
|
|
39
|
+
return ctxOf({});
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function contractTestsFor(
|
|
@@ -74,20 +76,54 @@ export function contractTestsFor(
|
|
|
74
76
|
},
|
|
75
77
|
{
|
|
76
78
|
name: `${name}: OpenAPI document contains its operation`,
|
|
77
|
-
run: async () =>
|
|
78
|
-
const document = buildOpenApi({ actions: [target] });
|
|
79
|
-
const path = derivePath(name).path;
|
|
80
|
-
if (document.paths[path] === undefined) {
|
|
81
|
-
throw new ContractDriftError(
|
|
82
|
-
`OpenAPI document has no entry for ${path}`,
|
|
83
|
-
'x verify --json # the contract suite is a step of it',
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
},
|
|
79
|
+
run: async () => assertDocumented(target, name),
|
|
87
80
|
},
|
|
88
81
|
];
|
|
89
82
|
}
|
|
90
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Against the document the APP publishes — the whole registry, plus this action when a test drives
|
|
86
|
+
* a `.named()` twin nothing seated — never one built from this action alone: that one holds its
|
|
87
|
+
* own path by construction, so the assertion could not fail. What can go wrong is in the rest of
|
|
88
|
+
* the registry: a second action deriving the same route, whose operation is the one published.
|
|
89
|
+
*/
|
|
90
|
+
function assertDocumented(target: AnyAction, name: string): void {
|
|
91
|
+
const seated = listActions();
|
|
92
|
+
const actions = seated.some((other) => other.name === name) ? seated : [...seated, target];
|
|
93
|
+
const { path } = servedActionRoute(name);
|
|
94
|
+
const published = buildOpenApi({ actions }).paths[path];
|
|
95
|
+
const owner = operationIdAt(published);
|
|
96
|
+
if (owner === toOpenApiOperation(target).operationId) return;
|
|
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(
|
|
111
|
+
owner === undefined
|
|
112
|
+
? `OpenAPI document has no entry for ${path}`
|
|
113
|
+
: `OpenAPI document serves ${path} as ${owner}, so ${name} is not in the published contract`,
|
|
114
|
+
owner === undefined
|
|
115
|
+
? 'x verify --json # the contract suite is a step of it'
|
|
116
|
+
: `x actions describe ${renderFixShellArg(name, '<action>')} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `paths[path].post.operationId`, read off a value typed `unknown` — narrowed, never cast. */
|
|
121
|
+
function operationIdAt(entry: unknown): string | undefined {
|
|
122
|
+
if (!isJsonObject(entry) || !isJsonObject(entry['post'])) return undefined;
|
|
123
|
+
const id = entry['post']['operationId'];
|
|
124
|
+
return typeof id === 'string' ? id : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
91
127
|
/**
|
|
92
128
|
* A `pattern` cannot be inverted, so the framework cannot build a payload for
|
|
93
129
|
* `t.string.pattern(...)` — but the pattern IS in the IR, so it knows that BEFORE it invokes.
|
|
@@ -152,7 +188,7 @@ async function expectDenied(
|
|
|
152
188
|
if (error instanceof ActionDeniedError) return;
|
|
153
189
|
// `invoke` runs the policy's actor half → parse input → row → policy → handle → parse
|
|
154
190
|
// output, and every stage lands here identically. Only `X_INPUT_INVALID` is attributable: it
|
|
155
|
-
// is what `validateInput` raises before `
|
|
191
|
+
// is what `validateInput` raises before `guardAction()` is reached (for a policy whose actor half
|
|
156
192
|
// could not decide), and `input:` is the knob that answers it. Any other
|
|
157
193
|
// code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
|
|
158
194
|
// `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather
|