@ultimat3/action 1.1.0 → 2.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 +387 -0
- package/README.md +271 -10
- package/package.json +7 -6
- package/src/action.ts +81 -7
- package/src/audit-gate.ts +78 -0
- package/src/audit.ts +123 -0
- package/src/cache-gate.ts +32 -0
- package/src/client.ts +67 -13
- package/src/contract-test.ts +82 -13
- package/src/deprecation.ts +82 -0
- package/src/errors.ts +276 -3
- package/src/http.ts +90 -13
- package/src/idempotency-key.ts +47 -0
- package/src/idempotency-memory.ts +148 -0
- package/src/idempotency-postgres.ts +271 -0
- package/src/idempotency.ts +157 -48
- package/src/index.ts +81 -5
- package/src/invoke.ts +155 -10
- package/src/job-handle.ts +22 -3
- package/src/json-schema.ts +17 -10
- package/src/mcp-tool.ts +18 -4
- package/src/mutator.ts +8 -0
- package/src/naming.ts +7 -7
- package/src/policy-gate.ts +14 -2
- package/src/registry.ts +52 -1
- package/src/sample-input.ts +177 -0
- package/src/stable.ts +55 -20
- package/src/type-pins.ts +45 -0
- package/src/tags.ts +0 -17
package/src/audit.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audit seam: THAT an action or mutator can be recorded at all. One `AuditSink`, installed
|
|
3
|
+
* once, handed every fact `invoke` genuinely knows about an attempt — who acted, which primitive,
|
|
4
|
+
* when, on which surface, with which idempotency key, and whether it was allowed, denied or
|
|
5
|
+
* failed. What the ROW says — its fields, its retention, its hash chain, its subject index, what
|
|
6
|
+
* "who" means under impersonation — is the app's, and this file declares none of it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Ctx } from '@ultimat3/core';
|
|
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
|
+
readonly ctx: Ctx;
|
|
58
|
+
/**
|
|
59
|
+
* The PARSED input, or `undefined` when the parse is what failed. Never the raw payload:
|
|
60
|
+
* an unvalidated body is attacker-shaped, and handing one to a sink that writes it to a table
|
|
61
|
+
* is how an audit trail becomes an injection surface.
|
|
62
|
+
*/
|
|
63
|
+
readonly input: unknown;
|
|
64
|
+
/** The namespaced key an `idempotent` action was retried under, or `null`. */
|
|
65
|
+
readonly idempotencyKey: string | null;
|
|
66
|
+
/** True when the response was replayed from an earlier settled record — a call, not a write. */
|
|
67
|
+
readonly replayed: boolean;
|
|
68
|
+
readonly outcome: AuditOutcome;
|
|
69
|
+
/** Present exactly when `outcome !== 'allowed'`. */
|
|
70
|
+
readonly failure: AuditFailure | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Where a record goes. The app's implementation: a table, an append-only hash chain, an OTel
|
|
75
|
+
* log, a queue. `@ultimat3/admin`'s `AuditSink` is the same noun one tier up, over its own
|
|
76
|
+
* fixed entry type; this one carries no `AdminActor` and no `permission`, because an action
|
|
77
|
+
* outside `/admin` has neither.
|
|
78
|
+
*
|
|
79
|
+
* A sink that throws is never swallowed — see `audit-gate.ts` for which failure wins.
|
|
80
|
+
*/
|
|
81
|
+
export interface AuditSink {
|
|
82
|
+
write(record: AuditRecord): Promise<void> | void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The seam's memory implementation, for tests and `x dev`. Not a system of record. */
|
|
86
|
+
export interface MemoryAuditSink extends AuditSink {
|
|
87
|
+
/** In the order `invoke` produced them. A copy — the log cannot be mutated through it. */
|
|
88
|
+
records(): readonly AuditRecord[];
|
|
89
|
+
clear(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function memoryAuditSink(): MemoryAuditSink {
|
|
93
|
+
const log: AuditRecord[] = [];
|
|
94
|
+
return {
|
|
95
|
+
write(record: AuditRecord): void {
|
|
96
|
+
log.push(record);
|
|
97
|
+
},
|
|
98
|
+
records: (): readonly AuditRecord[] => [...log],
|
|
99
|
+
clear: (): void => {
|
|
100
|
+
log.length = 0;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* No default. A logger-backed default would satisfy `audit: true` with a line nobody stores,
|
|
107
|
+
* which is the silent pass this seam exists to remove: an audited action with no sink installed
|
|
108
|
+
* is `X_AUDIT_SINK_MISSING`, refused before the handler runs.
|
|
109
|
+
*/
|
|
110
|
+
let installed: AuditSink | null = null;
|
|
111
|
+
|
|
112
|
+
export function setAuditSink(sink: AuditSink): void {
|
|
113
|
+
installed = sink;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getAuditSink(): AuditSink | null {
|
|
117
|
+
return installed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Test seam: back to "nothing installed", which restoring a literal cannot express. */
|
|
121
|
+
export function resetAuditSink(): void {
|
|
122
|
+
installed = null;
|
|
123
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The post-commit cache bust — the only file in this package that calls `invalidateTags`.
|
|
3
|
+
* The handler has already committed by the time it runs, so a fan-out that refuses degrades to
|
|
4
|
+
* one logged failure instead of a failed action: the stale entries expire by TTL, and the caller
|
|
5
|
+
* keeps the write it already made.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CacheTag, InvalidationReport } from '@ultimat3/cache';
|
|
9
|
+
import { invalidateTags } from '@ultimat3/cache';
|
|
10
|
+
import { logger } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fan `tags` out and never throw. `undefined` back means the fan-out itself refused and nothing
|
|
14
|
+
* was cleared — an undeclared tag (`X_CACHE_TAG_UNKNOWN`) is the one an app hits. One dead tier
|
|
15
|
+
* is not that case: `invalidateTags` absorbs those into `report.errors` and still reports.
|
|
16
|
+
*/
|
|
17
|
+
export async function bustAfterCommit(
|
|
18
|
+
action: string,
|
|
19
|
+
tags: readonly CacheTag[],
|
|
20
|
+
): Promise<InvalidationReport | undefined> {
|
|
21
|
+
try {
|
|
22
|
+
return await invalidateTags(tags);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// Neither the tags nor `ctx.logger`. Reading a malformed `invalidates` entry back to render
|
|
25
|
+
// it throws a second time, out of the branch whose whole job is not to — and the failure
|
|
26
|
+
// names the offending tag while `action` names the one place `invalidates` is declared. Core's
|
|
27
|
+
// logger already carries `requestId`/`traceId` from the ambient context; an HTTP `Ctx` is a
|
|
28
|
+
// cast request context that carries no `logger` at all.
|
|
29
|
+
logger.error('action.invalidate.failed', { action, error });
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* the same pure derivation the server uses, so a renamed or mistyped action is a
|
|
4
4
|
* compile error in a Solid component — not a 404 at runtime.
|
|
5
5
|
*/
|
|
6
|
-
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import { currentSpanContext, traceparent } from '@ultimat3/core';
|
|
7
8
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
9
|
import type { Action } from './action';
|
|
9
|
-
import { ContractDriftError, RpcFailedError } from './errors';
|
|
10
|
+
import { ContractDriftError, RemoteActionError, RpcFailedError } from './errors';
|
|
10
11
|
import { BUILD_ID_HEADER, IDEMPOTENCY_HEADER } from './http';
|
|
11
12
|
import { derivePath } from './naming';
|
|
12
13
|
import { isJsonObject } from './stable';
|
|
@@ -58,7 +59,10 @@ export function rpc<TActions extends ActionMap>(options: ClientOptions): Client<
|
|
|
58
59
|
{},
|
|
59
60
|
{
|
|
60
61
|
get(_target, property: string | symbol) {
|
|
61
|
-
|
|
62
|
+
// `then` is `undefined` for the same reason a symbol is, and `queryClient` draws the line
|
|
63
|
+
// in the same place: `await client` reads it, so a method there makes the client a
|
|
64
|
+
// thenable that posts an action named "then" and resolves the await to its answer.
|
|
65
|
+
if (typeof property !== 'string' || property === 'then') return undefined;
|
|
62
66
|
return clientMethodFor(property, options);
|
|
63
67
|
},
|
|
64
68
|
},
|
|
@@ -93,6 +97,10 @@ async function call(
|
|
|
93
97
|
): Promise<unknown> {
|
|
94
98
|
const headers: Record<string, string> = {
|
|
95
99
|
'content-type': 'application/json',
|
|
100
|
+
// Before the caller's headers, so an explicit `traceparent` still wins. Without this a
|
|
101
|
+
// service-to-service hop started a fresh root trace on the other side, which makes "which of
|
|
102
|
+
// my downstreams is slow" unanswerable across every Ultimate-to-Ultimate call.
|
|
103
|
+
...traceHeaders(),
|
|
96
104
|
...options.headers,
|
|
97
105
|
};
|
|
98
106
|
if (options.buildId !== undefined) headers[BUILD_ID_HEADER] = options.buildId;
|
|
@@ -114,6 +122,27 @@ async function call(
|
|
|
114
122
|
return body;
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
/** A `traceparent` is `00-<32 hex>-<16 hex>-<2 hex>`, and nothing else may be sent as one. */
|
|
126
|
+
const TRACE_ID = /^[0-9a-f]{32}$/;
|
|
127
|
+
const SPAN_ID = /^[0-9a-f]{16}$/;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The current trace, as the W3C header — or nothing at all. `currentSpanContext()` answers with
|
|
131
|
+
* an empty `spanId` when a request context exists but no span is active, and `00-<trace>--01` is
|
|
132
|
+
* a header every collector drops, so an incomplete context sends none. In a browser there is no
|
|
133
|
+
* ambient context and this is always empty, which is also what keeps a cross-origin GET from
|
|
134
|
+
* acquiring a CORS preflight it did not have.
|
|
135
|
+
*
|
|
136
|
+
* `@ultimat3/query`'s client carries the twin of this function: both are tier 3, so neither may
|
|
137
|
+
* import the other.
|
|
138
|
+
*/
|
|
139
|
+
function traceHeaders(): Record<string, string> {
|
|
140
|
+
const context = currentSpanContext();
|
|
141
|
+
if (context === undefined) return {};
|
|
142
|
+
if (!TRACE_ID.test(context.traceId) || !SPAN_ID.test(context.spanId)) return {};
|
|
143
|
+
return { traceparent: traceparent(context) };
|
|
144
|
+
}
|
|
145
|
+
|
|
117
146
|
/**
|
|
118
147
|
* Version skew is a contract problem, not a network problem: the client holds
|
|
119
148
|
* types from build A while build B answers. Fail loudly so the shell reloads.
|
|
@@ -131,20 +160,45 @@ function assertSameBuild(
|
|
|
131
160
|
);
|
|
132
161
|
}
|
|
133
162
|
|
|
134
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* A framework code, spelled the one way codes are spelled. `typeof code === 'string'` alone
|
|
165
|
+
* accepted `""` and `"error"` — a gateway's JSON body became an `UltimateError` whose code
|
|
166
|
+
* nothing in the framework or the app declares, rendering `: ` under a humanised title.
|
|
167
|
+
*/
|
|
168
|
+
const FRAMEWORK_CODE = /^X_[A-Z0-9]+(?:_[A-Z0-9]+)*$/;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `application/problem+json` back into the error the server threw. The code rides along
|
|
172
|
+
* verbatim — carrying one is the point of the document — but it is a code this bundle may never
|
|
173
|
+
* have registered, so the result is a `RemoteActionError`: marked remote-origin, and linked only
|
|
174
|
+
* to a page that exists. A body naming no framework code is a proxy answering rather than the
|
|
175
|
+
* app, which is what `RpcFailedError` already says.
|
|
176
|
+
*/
|
|
135
177
|
async function toUltimateError(response: Response, name: string): Promise<UltimateError> {
|
|
136
178
|
const body: unknown = await response.json().catch(() => null);
|
|
137
|
-
if (isJsonObject(body)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
fix: stringOr(body['fix'], `x actions describe ${name} --json`),
|
|
142
|
-
docs: stringOr(body['docs'], `https://ultimate.dev/errors/${body['code']}`),
|
|
143
|
-
});
|
|
179
|
+
if (!isJsonObject(body)) return new RpcFailedError(name, response.status);
|
|
180
|
+
const code = body['code'];
|
|
181
|
+
if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
|
|
182
|
+
return new RpcFailedError(name, response.status);
|
|
144
183
|
}
|
|
145
|
-
return new
|
|
184
|
+
return new RemoteActionError({
|
|
185
|
+
action: name,
|
|
186
|
+
status: response.status,
|
|
187
|
+
code,
|
|
188
|
+
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${response.status}`),
|
|
189
|
+
fix: stringOr(body['fix'], `x actions describe ${name} --json`),
|
|
190
|
+
// RFC-9457's `type` IS a documentation URI, so a server that sends no `docs` extension has
|
|
191
|
+
// still offered one. Both travel, in preference order: `??` picked `docs` on presence alone,
|
|
192
|
+
// so a `javascript:` one hid a perfectly good `type` behind it. `remoteDocs` takes the first
|
|
193
|
+
// that is an absolute HTTP(S) URL — neither is trusted for being there.
|
|
194
|
+
docs: [nonEmpty(body['docs']), nonEmpty(body['type'])],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function nonEmpty(value: unknown): string | undefined {
|
|
199
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
146
200
|
}
|
|
147
201
|
|
|
148
202
|
function stringOr(value: unknown, fallback: string): string {
|
|
149
|
-
return
|
|
203
|
+
return nonEmpty(value) ?? fallback;
|
|
150
204
|
}
|
package/src/contract-test.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
import type { Ctx } from '@ultimat3/core';
|
|
8
8
|
import { createContext, isUltimateError } from '@ultimat3/core';
|
|
9
9
|
import type { AnyAction } from './action';
|
|
10
|
-
import { ContractDriftError } from './errors';
|
|
10
|
+
import { ActionDeniedError, ContractDriftError } from './errors';
|
|
11
11
|
import { actionName, invoke } from './invoke';
|
|
12
12
|
import { derivePath } from './naming';
|
|
13
13
|
import { buildOpenApi } from './openapi';
|
|
14
|
+
import { describeSampleGap, sampleGaps, sampleInput } from './sample-input';
|
|
14
15
|
|
|
15
16
|
export interface ContractTest {
|
|
16
17
|
readonly name: string;
|
|
@@ -20,6 +21,13 @@ export interface ContractTest {
|
|
|
20
21
|
export interface ContractTestOptions {
|
|
21
22
|
/** Value the input schema must reject. `null` fails every object schema. */
|
|
22
23
|
readonly garbage?: unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Input for the policy assertion. Omitted means one synthesized from `input:` itself — pass it
|
|
26
|
+
* when the schema carries a `pattern` (named for you, before the invocation, by
|
|
27
|
+
* `assertSampleable`), a provider refinement the IR does not carry, or a `row:` loader that
|
|
28
|
+
* needs an id which resolves.
|
|
29
|
+
*/
|
|
30
|
+
readonly input?: unknown;
|
|
23
31
|
readonly ctx?: Ctx;
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -51,12 +59,12 @@ export function contractTestsFor(
|
|
|
51
59
|
{
|
|
52
60
|
name: `${name}: policy denies an anonymous actor`,
|
|
53
61
|
run: async () => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
);
|
|
62
|
+
if ('input' in options) {
|
|
63
|
+
await expectDenied(target, name, options.input, ctx);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
assertSampleable(target, name);
|
|
67
|
+
await expectDenied(target, name, sampleInput(target.input), ctx);
|
|
60
68
|
},
|
|
61
69
|
},
|
|
62
70
|
{
|
|
@@ -75,6 +83,24 @@ export function contractTestsFor(
|
|
|
75
83
|
];
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A `pattern` cannot be inverted, so the framework cannot build a payload for
|
|
88
|
+
* `t.string.pattern(...)` — but the pattern IS in the IR, so it knows that BEFORE it invokes.
|
|
89
|
+
* Reporting it here rather than letting `X_INPUT_INVALID` surface out of the action's own parse
|
|
90
|
+
* is the difference between an instruction and a misattribution: the action is correct, `input:`
|
|
91
|
+
* is correct, and the only thing that can supply the value is the author. The `fix:` is therefore
|
|
92
|
+
* the exact call to paste, not an edit to the declaration.
|
|
93
|
+
*/
|
|
94
|
+
function assertSampleable(target: AnyAction, name: string): void {
|
|
95
|
+
const gaps = sampleGaps(target.input);
|
|
96
|
+
if (gaps.length === 0) return;
|
|
97
|
+
const described = gaps.map((path) => describeSampleGap(target.input, path)).join(', ');
|
|
98
|
+
throw new ContractDriftError(
|
|
99
|
+
`${name}: no value can be synthesized for ${described}, so the denial would be unproven`,
|
|
100
|
+
`contractTestsFor(${name}, { input: { … } }) # x actions describe ${name} --json prints the schema`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
/**
|
|
79
105
|
* The generated policy test. Emitted as source (not executed here) because the
|
|
80
106
|
* app owns which actors it considers privileged.
|
|
@@ -85,7 +111,8 @@ export function policyTestStubFor(target: AnyAction): string {
|
|
|
85
111
|
import { ${name} } from './actions';
|
|
86
112
|
|
|
87
113
|
// Fill in: arrange a foreign actor, expect the policy to deny.
|
|
88
|
-
// The contract tests below are framework-generated and always included.
|
|
114
|
+
// The contract tests below are framework-generated and always included. Pass
|
|
115
|
+
// \`{ input }\` if the synthesized one cannot satisfy this action's schema or row loader.
|
|
89
116
|
for (const contract of contractTestsFor(${name})) {
|
|
90
117
|
test(contract.name, async () => {
|
|
91
118
|
await contract.run();
|
|
@@ -94,14 +121,51 @@ for (const contract of contractTestsFor(${name})) {
|
|
|
94
121
|
`;
|
|
95
122
|
}
|
|
96
123
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The assertion the second test is named for, and the reason it refuses to accept just any
|
|
126
|
+
* thrown error: this used to pass on ANY `UltimateError`, and the input it sent was `{}` —
|
|
127
|
+
* which fails `input:` for every action with a required field, so `X_INPUT_INVALID` was
|
|
128
|
+
* thrown before the policy ran and the authz claim was never tested at all.
|
|
129
|
+
*
|
|
130
|
+
* `ActionDeniedError` is the one outcome that means the policy decided. It is asserted as a
|
|
131
|
+
* class rather than as `X_FORBIDDEN`, because it re-uses the policy decision's own code and
|
|
132
|
+
* the blessed `can()` answers a null actor with `X_UNAUTHENTICATED` — pinning one code would
|
|
133
|
+
* fail every action that authors its policy the way the framework tells it to.
|
|
134
|
+
*/
|
|
135
|
+
async function expectDenied(
|
|
136
|
+
target: AnyAction,
|
|
137
|
+
name: string,
|
|
138
|
+
input: unknown,
|
|
139
|
+
ctx: Ctx,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
try {
|
|
142
|
+
await invoke(target, input, { ctx, surface: 'http' });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// A handler's own bug keeps its stack: wrapping a TypeError from a `row:` loader in a
|
|
145
|
+
// drift error would hide the line that threw behind a fix that does not apply.
|
|
146
|
+
if (!isUltimateError(error)) throw error;
|
|
147
|
+
if (error instanceof ActionDeniedError) return;
|
|
148
|
+
// `invoke` runs parse input → row → policy → handle → parse output, and every stage lands
|
|
149
|
+
// here identically. Only `X_INPUT_INVALID` is attributable: it is what `validateInput`
|
|
150
|
+
// raises before `guard()` is reached, and `input:` is the knob that answers it. Any other
|
|
151
|
+
// code — `X_TENANCY_UNSCOPED` from a `row:` loader, `X_DB_CONFLICT` from a handler,
|
|
152
|
+
// `X_OUTPUT_INVALID` from the parse after it — keeps its own code and its own fix rather
|
|
153
|
+
// than being retold as an input problem with a fix that changes nothing.
|
|
154
|
+
if (error.code !== 'X_INPUT_INVALID') throw error;
|
|
155
|
+
throw new ContractDriftError(
|
|
156
|
+
`${name} failed with ${error.code} before its policy decided, so the denial is unproven`,
|
|
157
|
+
`pass \`input:\` to contractTestsFor(${name}) — x actions describe ${name} --json prints the schema`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
throw new ContractDriftError(
|
|
161
|
+
`${name} ran for an actor of null`,
|
|
162
|
+
`make the ${name} policy require an authenticated actor`,
|
|
163
|
+
);
|
|
100
164
|
}
|
|
101
165
|
|
|
102
166
|
async function expectThrow(
|
|
103
167
|
run: () => Promise<unknown>,
|
|
104
|
-
code: string
|
|
168
|
+
code: string,
|
|
105
169
|
cause: string,
|
|
106
170
|
fix: string,
|
|
107
171
|
): Promise<void> {
|
|
@@ -109,7 +173,12 @@ async function expectThrow(
|
|
|
109
173
|
await run();
|
|
110
174
|
} catch (error) {
|
|
111
175
|
if (!isUltimateError(error)) throw error;
|
|
112
|
-
if (
|
|
176
|
+
if (error.code === code) return;
|
|
177
|
+
// `X_AUDIT_SINK_MISSING` is the one refusal `invoke` raises BEFORE the input parse, so
|
|
178
|
+
// "the schema accepted garbage" is a false statement about it and `input:` is not what
|
|
179
|
+
// answers it. It keeps its own code and its own runnable fix — the same rule `expectDenied`
|
|
180
|
+
// follows for every code it cannot attribute to `input:`.
|
|
181
|
+
if (error.code === 'X_AUDIT_SINK_MISSING') throw error;
|
|
113
182
|
throw new ContractDriftError(`${cause} (got ${error.code}, expected ${code})`, fix);
|
|
114
183
|
}
|
|
115
184
|
throw new ContractDriftError(cause, fix);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A declared retirement, rendered as the two headers the standards already define — RFC 9745
|
|
3
|
+
* `Deprecation` and RFC 8594 `Sunset` — plus the successor link. Pure string and date maths, and
|
|
4
|
+
* deliberately throw-free: each package raises its own `X_*` for a date it cannot render.
|
|
5
|
+
*
|
|
6
|
+
* `@ultimat3/query` carries a twin of this file. Both are tier 3, so neither may import the
|
|
7
|
+
* other, and the shared home is `@ultimat3/http` (tier 2) once that package grows one — the same
|
|
8
|
+
* compromise `naming.ts` is ported under.
|
|
9
|
+
*/
|
|
10
|
+
import { counter } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
export interface Deprecation {
|
|
13
|
+
/** When it was deprecated. ISO-8601, e.g. `'2026-08-01T00:00:00Z'`. */
|
|
14
|
+
readonly since: string;
|
|
15
|
+
/** When it stops answering. ISO-8601 — the date `Sunset` publishes and clients plan against. */
|
|
16
|
+
readonly sunset: string;
|
|
17
|
+
/** The export name of the replacement, projected to a `rel="successor-version"` link. */
|
|
18
|
+
readonly replacedBy?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type DeprecationField = 'since' | 'sunset';
|
|
22
|
+
|
|
23
|
+
export type DeprecationRender =
|
|
24
|
+
| {
|
|
25
|
+
readonly ok: true;
|
|
26
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
27
|
+
/** The same facts as data, for `x-ultimate` in the OpenAPI operation and the manifest. */
|
|
28
|
+
readonly meta: Readonly<Record<string, string>>;
|
|
29
|
+
}
|
|
30
|
+
| { readonly ok: false; readonly field: DeprecationField; readonly value: string };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How many calls a deprecated declaration is still taking — the number "can we remove it yet?"
|
|
34
|
+
* needs and the one nothing in the framework could answer. Attributes are the primitive and the
|
|
35
|
+
* declared NAME, both bounded by the size of the codebase; a caller id here would be an unbounded
|
|
36
|
+
* series, which is the cardinality mistake core's own overflow bucket exists to catch.
|
|
37
|
+
*/
|
|
38
|
+
const deprecatedCalls = counter('deprecated_calls_total', {
|
|
39
|
+
unit: '{call}',
|
|
40
|
+
description: 'Calls served by a declaration that has been deprecated, by primitive and name',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export function recordDeprecatedCall(primitive: 'action' | 'query', name: string): void {
|
|
44
|
+
deprecatedCalls.add(1, { primitive, name });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
|
|
49
|
+
* HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
|
|
50
|
+
* differently — never render one in the other's format, and never emit `Invalid Date`.
|
|
51
|
+
*/
|
|
52
|
+
export function renderDeprecation(
|
|
53
|
+
deprecation: Deprecation,
|
|
54
|
+
successorPath: string | undefined,
|
|
55
|
+
): DeprecationRender {
|
|
56
|
+
const since = Date.parse(deprecation.since);
|
|
57
|
+
if (Number.isNaN(since)) return { ok: false, field: 'since', value: deprecation.since };
|
|
58
|
+
const sunset = Date.parse(deprecation.sunset);
|
|
59
|
+
if (Number.isNaN(sunset)) return { ok: false, field: 'sunset', value: deprecation.sunset };
|
|
60
|
+
|
|
61
|
+
const headers: Record<string, string> = {
|
|
62
|
+
deprecation: `@${Math.floor(since / 1000)}`,
|
|
63
|
+
sunset: new Date(sunset).toUTCString(),
|
|
64
|
+
};
|
|
65
|
+
// The successor's URL, derived by the caller from the same `naming.ts` the client uses — a
|
|
66
|
+
// link this file built from the export name would be the second URL derivation in the package.
|
|
67
|
+
if (successorPath !== undefined) {
|
|
68
|
+
headers['link'] = `<${successorPath}>; rel="successor-version"`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const meta: Record<string, string> = {
|
|
72
|
+
since: new Date(since).toISOString(),
|
|
73
|
+
sunset: new Date(sunset).toISOString(),
|
|
74
|
+
...(deprecation.replacedBy === undefined ? {} : { replacedBy: deprecation.replacedBy }),
|
|
75
|
+
};
|
|
76
|
+
return { ok: true, headers, meta };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Set on a response that already exists, so a redirect and a problem document carry them too. */
|
|
80
|
+
export function applyHeaders(response: Response, headers: Readonly<Record<string, string>>): void {
|
|
81
|
+
for (const [name, value] of Object.entries(headers)) response.headers.set(name, value);
|
|
82
|
+
}
|