@ultimat3/action 24.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 +63 -37
- package/README.md +115 -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 +26 -8
- package/src/idempotency-postgres.ts +20 -9
- package/src/idempotency-redact.ts +140 -0
- package/src/idempotency.ts +54 -7
- 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/stable.ts +0 -7
- package/src/type-pins.ts +98 -3
- package/src/deprecation.ts +0 -77
- package/src/mcp-tool.ts +0 -95
- package/src/wire-headers.ts +0 -20
package/src/json-schema.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* MCP tool's schema is `@ultimat3/schema`'s `toWireSchema` — the one `tools/list` serves.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { isJsonObject } from '@ultimat3/core';
|
|
6
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
8
|
import { SchemaUnsupportedError, toJsonSchema } from '@ultimat3/schema';
|
|
8
|
-
import {
|
|
9
|
+
import { stableStringify } from './stable';
|
|
9
10
|
|
|
10
11
|
export type JsonSchemaObject = Record<string, unknown>;
|
|
11
12
|
|
package/src/mutator.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* contract tests for free, and its authz is the same single evaluation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
|
|
8
|
+
import type { ConflictPolicy, Ctx, Deprecation, Row } from '@ultimat3/core';
|
|
9
9
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
10
|
import type {
|
|
11
11
|
Action,
|
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
ActionRowArgs,
|
|
18
18
|
} from './action';
|
|
19
19
|
import { action, isAction } from './action';
|
|
20
|
-
import type { Deprecation } from './deprecation';
|
|
21
20
|
import { MutatorNotIdempotentError } from './errors';
|
|
22
21
|
import type { ActionHttp } from './http-path';
|
|
23
22
|
import { assertConflictClock } from './mutator-clock';
|
package/src/naming.ts
CHANGED
|
@@ -11,10 +11,9 @@ import { resolveActionRoute } from './http-path';
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
|
|
14
|
-
* spec derive one URL from one function
|
|
15
|
-
*
|
|
14
|
+
* spec derive one URL from one function; `pluralize` and `splitWords` are imported from core by
|
|
15
|
+
* whoever needs them, never re-exported here (`X_HELPER_COPY`).
|
|
16
16
|
*/
|
|
17
|
-
export { pluralize, splitWords } from '@ultimat3/core';
|
|
18
17
|
|
|
19
18
|
export type ActionPath = ActionRoute;
|
|
20
19
|
|
package/src/openapi-complete.ts
CHANGED
|
@@ -79,10 +79,25 @@ interface Refusals {
|
|
|
79
79
|
readonly unauthorizedHeaders?: Record<string, unknown>;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* An ACTION's limit is spent inside `invoke`, so its route carries no bucket to read; the operation
|
|
84
|
+
* itself publishes the declaration (`x-ultimate.rateLimit`, `null` when none). A query's route
|
|
85
|
+
* still does, hence both reads above.
|
|
86
|
+
*/
|
|
87
|
+
function declaresRateLimit(operation: Operation): boolean {
|
|
88
|
+
const extensions = operation['x-ultimate'];
|
|
89
|
+
if (typeof extensions !== 'object' || extensions === null) return false;
|
|
90
|
+
const declared = (extensions as Record<string, unknown>)['rateLimit'];
|
|
91
|
+
return declared !== undefined && declared !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
82
94
|
/** One operation, with its route's refusals and security added. Never mutates `operation`. */
|
|
83
95
|
function completeOperation(operation: Operation, route: Route | undefined, refusals: Refusals) {
|
|
84
96
|
const authenticated = route?.meta.auth === 'required';
|
|
85
|
-
const limited =
|
|
97
|
+
const limited =
|
|
98
|
+
refusals.everyOpLimited ||
|
|
99
|
+
route?.meta.rateLimitBucket !== undefined ||
|
|
100
|
+
declaresRateLimit(operation);
|
|
86
101
|
if (!authenticated && !limited) return operation;
|
|
87
102
|
const responses = { ...(operation['responses'] as Record<string, unknown>) };
|
|
88
103
|
if (limited) {
|
package/src/policy-gate.ts
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP,
|
|
3
|
-
* job, direct server call) reaches authz through `
|
|
3
|
+
* job, direct server call) reaches authz through `guardAction()` — there is no second
|
|
4
4
|
* code path, which is what makes "one authz system" true rather than aspirational.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
-
import { assertNever
|
|
8
|
+
import { assertNever } from '@ultimat3/core';
|
|
9
9
|
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
-
import {
|
|
11
|
-
enforce,
|
|
12
|
-
enforceBeforeInput,
|
|
13
|
-
policyPermissions as flattenedPermissions,
|
|
14
|
-
admitsAnonymous as policyAdmitsAnonymous,
|
|
15
|
-
} from '@ultimat3/policy';
|
|
10
|
+
import { enforce, enforceBeforeInput } from '@ultimat3/policy';
|
|
16
11
|
import { ActionDeniedError } from './errors';
|
|
17
12
|
|
|
18
13
|
/**
|
|
@@ -45,7 +40,7 @@ export interface PolicySubject {
|
|
|
45
40
|
* object for every surface, so an actor denied over HTTP is denied over MCP with
|
|
46
41
|
* the same reason and the same code.
|
|
47
42
|
*/
|
|
48
|
-
export function
|
|
43
|
+
export function guardAction(policy: ActionPolicy, subject: PolicySubject, surface: Surface): void {
|
|
49
44
|
const denial = enforce(policySurface(surface), policy, {
|
|
50
45
|
input: subject.input,
|
|
51
46
|
actor: subject.actor,
|
|
@@ -58,13 +53,13 @@ export function guard(policy: ActionPolicy, subject: PolicySubject, surface: Sur
|
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
/**
|
|
61
|
-
* The actor-only half of `
|
|
56
|
+
* The actor-only half of `guardAction`, run BEFORE the input is parsed: a caller the policy refuses
|
|
62
57
|
* whatever they send gets the 403 (or 401) here, and never the 400 whose issue list describes
|
|
63
58
|
* the input schema of an operation they may not call. Undecided — a predicate that reads the input
|
|
64
|
-
* or the row — passes, and `
|
|
65
|
-
* same code, same surface rendering as `
|
|
59
|
+
* or the row — passes, and `guardAction` decides after the parse exactly as before. Same denial
|
|
60
|
+
* class, same code, same surface rendering as `guardAction`'s.
|
|
66
61
|
*/
|
|
67
|
-
export function
|
|
62
|
+
export function guardActionBeforeInput(
|
|
68
63
|
policy: ActionPolicy,
|
|
69
64
|
subject: Omit<PolicySubject, 'input' | 'row'>,
|
|
70
65
|
surface: Surface,
|
|
@@ -90,41 +85,3 @@ function policySurface(surface: Surface): PolicySurface {
|
|
|
90
85
|
return assertNever(surface);
|
|
91
86
|
}
|
|
92
87
|
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Core models "nobody" as an anonymous actor; policy models it as `null`, which is
|
|
96
|
-
* what turns a missing session into `X_UNAUTHENTICATED` instead of a bare denial.
|
|
97
|
-
*/
|
|
98
|
-
export function actorOf(ctx: Ctx): Actor | null {
|
|
99
|
-
return isAnonymous(ctx.actor) ? null : ctx.actor;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The capability an action requires, for manifests and OpenAPI metadata. A DISPLAY label. */
|
|
103
|
-
export function policyCapability(policy: ActionPolicy): string {
|
|
104
|
-
return policy.label;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Every permission the policy tree references, flattened and deduped — and the only field a
|
|
109
|
-
* compliance report may match a grant against. `label` renders a composite as
|
|
110
|
-
* `and(post:publish, org:administer)`, which is a sentence and never equals a permission string,
|
|
111
|
-
* so matching on it reported every action guarded by a composite as enforcing nothing: `x policy
|
|
112
|
-
* list` showed real grants as dead. The two are kept side by side rather than one replacing the
|
|
113
|
-
* other — the label is what a human reads, this is what a machine compares.
|
|
114
|
-
*/
|
|
115
|
-
export function policyPermissions(policy: ActionPolicy): readonly string[] {
|
|
116
|
-
return flattenedPermissions(policy);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Whether a policy admits an ANONYMOUS caller — `@ultimat3/policy`'s answer, re-exported here so
|
|
121
|
-
* `http.ts` reads it through this file like every other authz question. `toRoute` derives
|
|
122
|
-
* `meta.auth` from it, never from `policy.kind === 'allow'`: that read looked at the ROOT
|
|
123
|
-
* combinator only, so `or(allow(), can('x:y'))` was 401'd by the pipeline before `invoke` ran
|
|
124
|
-
* while the MCP tool and the job handle allowed it. `true` is not "unguarded" — `invoke` still
|
|
125
|
-
* evaluates the policy for every call. Declared once in `policy.ts`, exactly as
|
|
126
|
-
* `policyPermissions` is: the answer is a property of the combinators it walks.
|
|
127
|
-
*/
|
|
128
|
-
export function admitsAnonymous(policy: ActionPolicy): boolean {
|
|
129
|
-
return policyAdmitsAnonymous(policy);
|
|
130
|
-
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a declared `rateLimit:` is spent: once, inside `invoke`, so HTTP, MCP, the in-app agent
|
|
3
|
+
* tool and `.job()` draw on ONE bucket. The store is `@ultimat3/http`'s installed one — the slot
|
|
4
|
+
* `@ultimat3/query` spends from too, filled at boot with the instance `httpServer` is handed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { asyncContext, isAnonymous } from '@ultimat3/core';
|
|
9
|
+
import type { RateLimitDecision } from '@ultimat3/http';
|
|
10
|
+
// The bucket maths, the subject precedence and the refusal are all `@ultimat3/http`'s (tier 2):
|
|
11
|
+
// a copy here would be a second answer to "what does this limit mean" and "whose bucket is it".
|
|
12
|
+
import { installedRateLimitStore, rateLimited, rateLimitSpends, toBucket } from '@ultimat3/http';
|
|
13
|
+
import type { ActionRateLimit } from './action';
|
|
14
|
+
import type { Surface } from './policy-gate';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Who a call chain started from: the address of the request, or the fact that it is a job run.
|
|
18
|
+
* An `agent()` is an action whose handler calls its tools through `invoke` with no address of its
|
|
19
|
+
* own, so without this every anonymous visitor's tool calls shared one `ip:unknown` bucket and one
|
|
20
|
+
* visitor could exhaust it for all. Opened by `invoke`; read by every nested call that has none.
|
|
21
|
+
*/
|
|
22
|
+
interface CallerFrame {
|
|
23
|
+
readonly address: string | null;
|
|
24
|
+
/** Inside a job run: nested calls are the job's, never a visitor's. */
|
|
25
|
+
readonly job: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const callerFrame = asyncContext<CallerFrame>('the caller frame');
|
|
29
|
+
|
|
30
|
+
/** Run `fn` with `address` as the inherited caller address; an absent one changes nothing. */
|
|
31
|
+
export function withCallerAddress<T>(address: string | null | undefined, fn: () => T): T {
|
|
32
|
+
return typeof address === 'string' && address !== ''
|
|
33
|
+
? callerFrame.run({ address, job: false }, fn)
|
|
34
|
+
: fn();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run `fn` as a job: no inherited address, and every nested call unattributed unless it has an
|
|
39
|
+
* actor or an org of its own. A FRESH frame, not a read of the current one — a worker loop re-armed
|
|
40
|
+
* by `setTimeout` inside a request carries that request's frame, and a tenantless job charged to
|
|
41
|
+
* whichever visitor happened to start the loop is the leak this closes.
|
|
42
|
+
*/
|
|
43
|
+
export function withJobFrame<T>(fn: () => T): T {
|
|
44
|
+
return callerFrame.run({ address: null, job: true }, fn);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The subject of a job run nobody attributed — no actor, no tenant. Its own population: sharing
|
|
49
|
+
* `ip:unknown` let a flood of unaddressed anonymous requests park every tenantless job, and the
|
|
50
|
+
* reverse. Still ONE bucket for all such runs, deliberately: unlimited is the abuse this exists for.
|
|
51
|
+
*/
|
|
52
|
+
const UNATTRIBUTED_JOB = 'job:unattributed';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Namespaced, never the bare action name: the HTTP pipeline still spends its `default` bucket
|
|
56
|
+
* under `<route name>|<subject>`, and one key taken against two bucket shapes in one store would
|
|
57
|
+
* corrupt both counters.
|
|
58
|
+
*/
|
|
59
|
+
const ACTION_SCOPE = 'action';
|
|
60
|
+
|
|
61
|
+
export interface RateLimitSpendOptions {
|
|
62
|
+
readonly surface: Surface;
|
|
63
|
+
/** The connection address — the only subject an anonymous caller has. */
|
|
64
|
+
readonly clientAddress?: string | null | undefined;
|
|
65
|
+
/** The decision, before a refusal is thrown: how HTTP puts `RateLimit-*` on the answer. */
|
|
66
|
+
readonly onRateLimit?: ((decision: RateLimitDecision) => void) | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Spend one token of `limit` for this caller, or throw `X_RATE_LIMITED` — the code, the cause and
|
|
71
|
+
* the `Retry-After` seconds every limit in the framework answers with.
|
|
72
|
+
*
|
|
73
|
+
* `surface: 'server'` spends nothing: that is app code calling its own action in-process (a
|
|
74
|
+
* service composing two, a seed, a test), not a caller arriving from outside. A refused attempt
|
|
75
|
+
* spends nothing either — the token bucket only decrements on an admission — so a job retried
|
|
76
|
+
* into a refusal is not charged for it.
|
|
77
|
+
*/
|
|
78
|
+
export async function spendActionLimit(
|
|
79
|
+
name: string,
|
|
80
|
+
limit: ActionRateLimit | undefined,
|
|
81
|
+
ctx: Ctx,
|
|
82
|
+
options: RateLimitSpendOptions,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (limit === undefined || options.surface === 'server') return;
|
|
85
|
+
const frame = callerFrame.get();
|
|
86
|
+
// A job run, or any call nested inside one (an `agent()` run as a job calling its tools as
|
|
87
|
+
// `'mcp'`): never a visitor's address, and the unattributed bucket rather than `ip:unknown`.
|
|
88
|
+
const inJob = options.surface === 'job' || frame?.job === true;
|
|
89
|
+
const spends = rateLimitSpends(
|
|
90
|
+
{
|
|
91
|
+
actorId: isAnonymous(ctx.actor) ? null : ctx.actor.id,
|
|
92
|
+
// Read off an anonymous actor too: a job run IS the worker's anonymous identity carrying the
|
|
93
|
+
// job's org (`jobRunActor`), and that org is who the run is — charging every tenant's runs
|
|
94
|
+
// to one `ip:unknown` key let one tenant's backlog refuse everyone's.
|
|
95
|
+
orgId: ctx.actor.orgId ?? null,
|
|
96
|
+
ip: options.clientAddress ?? (inJob ? null : (frame?.address ?? null)),
|
|
97
|
+
routeName: `${ACTION_SCOPE}:${name}`,
|
|
98
|
+
...(inJob ? { unattributed: UNATTRIBUTED_JOB } : {}),
|
|
99
|
+
},
|
|
100
|
+
// The tenant allowance is the HTTP pipeline's (it spends it for every route, a handler-limited
|
|
101
|
+
// one included); counting it here too would charge an HTTP call against the tenant twice.
|
|
102
|
+
{ route: name, tenant: null },
|
|
103
|
+
);
|
|
104
|
+
for (const spend of spends) {
|
|
105
|
+
const decision = await installedRateLimitStore().take(
|
|
106
|
+
spend.key,
|
|
107
|
+
toBucket(name, limit),
|
|
108
|
+
1,
|
|
109
|
+
ctx.now().getTime(),
|
|
110
|
+
);
|
|
111
|
+
options.onRateLimit?.(decision);
|
|
112
|
+
if (!decision.allowed) throw rateLimited(spend.key, decision.retryAfterSeconds);
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/record-wire.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
8
8
|
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
9
|
-
import {
|
|
9
|
+
import { jsonResponse } from '@ultimat3/http';
|
|
10
10
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
11
|
|
|
12
12
|
/** The header value that says "this body is an envelope". Presence alone is not enough. */
|
|
@@ -26,7 +26,7 @@ export function carriesRecords(output: StandardSchemaV1): boolean {
|
|
|
26
26
|
|
|
27
27
|
/** The 200 for an action that carries records: the envelope, and the header that names it. */
|
|
28
28
|
export function recordResponse(output: StandardSchemaV1, result: unknown): Response {
|
|
29
|
-
const response =
|
|
29
|
+
const response = jsonResponse(encodeRecordEnvelope(result, rowsOf(output, result)));
|
|
30
30
|
response.headers.set(RECORDS_HEADER, ENVELOPED);
|
|
31
31
|
return response;
|
|
32
32
|
}
|
package/src/registry.ts
CHANGED
|
@@ -111,7 +111,7 @@ function assertProjectable(name: string, target: AnyAction): void {
|
|
|
111
111
|
for (const field of ['input', 'output'] as const) {
|
|
112
112
|
try {
|
|
113
113
|
jsonSchemaOf(target[field]);
|
|
114
|
-
// The schema
|
|
114
|
+
// The schema `tools/list` publishes — the one an MCP client reads.
|
|
115
115
|
toWireSchema(target[field]);
|
|
116
116
|
} catch {
|
|
117
117
|
// The thrown value is deliberately not rendered into the cause: it is the provider's, of
|
|
@@ -154,7 +154,7 @@ export function describeActions(): readonly ActionDescriptor[] {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/** Test-only. Production registers once at boot and never unregisters. */
|
|
157
|
-
export function
|
|
157
|
+
export function resetActions(): void {
|
|
158
158
|
registry.clear();
|
|
159
159
|
paths.clear();
|
|
160
160
|
resetActionPathStyle();
|
package/src/stable.ts
CHANGED
|
@@ -9,13 +9,6 @@
|
|
|
9
9
|
* published. Ordinary payloads are byte-identical between the two; `stable.test.ts` pins that.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* `@ultimat3/core`'s, re-exported so `./stable` stays this package's one import path for the JSON
|
|
14
|
-
* helpers. It was declared here AND identically in `@ultimat3/query`'s own `stable.ts`, which is
|
|
15
|
-
* the duplication `client-wire.ts` moving to tier 0 made unnecessary.
|
|
16
|
-
*/
|
|
17
|
-
export { isJsonObject } from '@ultimat3/core';
|
|
18
|
-
|
|
19
12
|
export type JsonObject = Record<string, unknown>;
|
|
20
13
|
|
|
21
14
|
/** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
|
package/src/type-pins.ts
CHANGED
|
@@ -3,18 +3,32 @@
|
|
|
3
3
|
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
4
|
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
5
|
|
|
6
|
-
import type { Row } from '@ultimat3/core';
|
|
6
|
+
import type { AuditRecord, McpExposureDeclaration, Row } from '@ultimat3/core';
|
|
7
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
-
import type { Action, AnyAction } from './action';
|
|
8
|
+
import type { Action, ActionDef, AnyAction, AnyActionDef } from './action';
|
|
9
9
|
import type { ClientMethod } from './client';
|
|
10
|
+
import type {
|
|
11
|
+
IdempotencyRecord,
|
|
12
|
+
IdempotencyReservation,
|
|
13
|
+
IdempotencyScope,
|
|
14
|
+
IdempotencyStore,
|
|
15
|
+
} from './idempotency';
|
|
10
16
|
import type { ActionJobHandle } from './job-handle';
|
|
11
|
-
import type { LocalTable } from './mutator';
|
|
17
|
+
import type { LocalTable, MutatorDef } from './mutator';
|
|
12
18
|
|
|
13
19
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
14
20
|
type Assert<T extends true> = T;
|
|
15
21
|
|
|
16
22
|
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
17
23
|
|
|
24
|
+
/**
|
|
25
|
+
* IDENTICAL, not merely mutually assignable: an object type without an optional key is assignable
|
|
26
|
+
* to one with it and back, so `Equals` cannot see an optional field added on one side only — and
|
|
27
|
+
* every field of the `mcp` block is optional but `expose`.
|
|
28
|
+
*/
|
|
29
|
+
type Identical<A, B> =
|
|
30
|
+
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
31
|
+
|
|
18
32
|
type PublishInput = StandardSchemaV1<{ readonly postId: string }>;
|
|
19
33
|
type PublishOutput = StandardSchemaV1<{ readonly published: boolean }>;
|
|
20
34
|
type PublishPost = Action<PublishInput, PublishOutput>;
|
|
@@ -59,3 +73,84 @@ export type _LocalTableIsTheStoreTxShape = Assert<
|
|
|
59
73
|
export type _LocalInsertTakesTheKey = Assert<
|
|
60
74
|
Equals<Parameters<LocalTable['insert']>, [key: string, row: Row]>
|
|
61
75
|
>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* #591: a store written before answers were redacted at rest drops `settle`'s `redacted` flag and
|
|
79
|
+
* would replay `[redacted]` as the answer. A required 4th PARAMETER cannot refuse it — TypeScript
|
|
80
|
+
* accepts an implementation with fewer parameters — so the store DECLARES it keeps the flag
|
|
81
|
+
* (`keepsRedaction: true`), and a pre-25 store without the declaration is a build error here.
|
|
82
|
+
*/
|
|
83
|
+
interface PreRedactionStore {
|
|
84
|
+
readonly scope?: IdempotencyScope | undefined;
|
|
85
|
+
reserve(key: string, requestHash: string): Promise<IdempotencyReservation>;
|
|
86
|
+
settle(key: string, value: unknown, reservationId: string): Promise<void>;
|
|
87
|
+
release(key: string): Promise<void>;
|
|
88
|
+
get(key: string): Promise<IdempotencyRecord | undefined>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type _APreRedactionStoreIsNotAStore = Assert<
|
|
92
|
+
Equals<PreRedactionStore extends IdempotencyStore ? true : false, false>
|
|
93
|
+
>;
|
|
94
|
+
|
|
95
|
+
/** …and `settle`'s flag is required of every CALLER, so a wrapping store cannot drop it either. */
|
|
96
|
+
export type _SettleRequiresTheRedactedFlag = Assert<
|
|
97
|
+
Equals<Parameters<IdempotencyStore['settle']>['length'], 4>
|
|
98
|
+
>;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 25.0.0 (plan 101, M9): `name` and `primitive` are REQUIRED on an audit record and the `action`
|
|
102
|
+
* alias is gone. Pinned as keys rather than with a literal record, because a literal would need a
|
|
103
|
+
* `Ctx` this module has no business building: a field that turned optional again, or an alias
|
|
104
|
+
* that came back, flips one of these to `false`.
|
|
105
|
+
*/
|
|
106
|
+
type RequiredKeys<T> = { [K in keyof T]-?: object extends Pick<T, K> ? never : K }[keyof T];
|
|
107
|
+
|
|
108
|
+
export type _AuditRecordRequiresNameAndPrimitive = Assert<
|
|
109
|
+
Equals<Extract<RequiredKeys<AuditRecord>, 'name' | 'primitive'>, 'name' | 'primitive'>
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
export type _AuditRecordHasNoActionAlias = Assert<
|
|
113
|
+
Equals<Extract<keyof AuditRecord, 'action'>, never>
|
|
114
|
+
>;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 25.0.0 (plan 101, M5): the deprecation TYPES are `@ultimat3/core`'s alone, as the helpers are.
|
|
118
|
+
* A runtime test cannot see a type re-export, so each is an expected error: a re-export coming
|
|
119
|
+
* back makes the directive unused, which `tsc` refuses.
|
|
120
|
+
*/
|
|
121
|
+
// @ts-expect-error — `Deprecation` is imported from `@ultimat3/core`, never from this barrel.
|
|
122
|
+
export type _NoDeprecationReexport = import('./index').Deprecation;
|
|
123
|
+
// @ts-expect-error — `DeprecationField` is imported from `@ultimat3/core`.
|
|
124
|
+
export type _NoDeprecationFieldReexport = import('./index').DeprecationField;
|
|
125
|
+
// @ts-expect-error — `DeprecationRender` is imported from `@ultimat3/core`.
|
|
126
|
+
export type _NoDeprecationRenderReexport = import('./index').DeprecationRender;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* O-tool, 25.0.0: an action projects no MCP tool of its own — `@ultimat3/mcp`'s `toolFrom`
|
|
130
|
+
* is the one projection, and a tier-3 `.tool()` could only ever be a second one. A `tool` member
|
|
131
|
+
* coming back on either view flips these to `false`.
|
|
132
|
+
*/
|
|
133
|
+
export type _AnActionHasNoToolTwin = Assert<Equals<Extract<keyof AnyAction, 'tool'>, never>>;
|
|
134
|
+
export type _ATypedActionHasNoToolTwin = Assert<Equals<Extract<keyof PublishPost, 'tool'>, never>>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 25.0.0: an action's `mcp` block is core's ONE `McpExposureDeclaration`, minus the list
|
|
138
|
+
* whitelist an action has nothing to compose over — on the declaration, the erased declaration,
|
|
139
|
+
* the erased action and a mutator alike. `ActionMcp` restated the block field by field (and
|
|
140
|
+
* `McpAnnotationHints` its hints); a field drifting on either side flips these to `false`.
|
|
141
|
+
*/
|
|
142
|
+
type ActionBlock = Omit<McpExposureDeclaration, 'listParams'>;
|
|
143
|
+
export type _ActionDeclaresCoresBlock = Assert<
|
|
144
|
+
Identical<NonNullable<ActionDef<PublishInput, PublishOutput>['mcp']>, ActionBlock>
|
|
145
|
+
>;
|
|
146
|
+
export type _ErasedActionDefDeclaresCoresBlock = Assert<
|
|
147
|
+
Identical<NonNullable<AnyActionDef['mcp']>, ActionBlock>
|
|
148
|
+
>;
|
|
149
|
+
export type _AnyActionCarriesCoresBlock = Assert<
|
|
150
|
+
Identical<NonNullable<AnyAction['mcp']>, ActionBlock>
|
|
151
|
+
>;
|
|
152
|
+
export type _MutatorDeclaresCoresBlock = Assert<
|
|
153
|
+
Identical<NonNullable<MutatorDef<PublishInput, PublishOutput>['mcp']>, ActionBlock>
|
|
154
|
+
>;
|
|
155
|
+
// @ts-expect-error — `McpAnnotationHints` is `@ultimat3/core`'s, never this barrel's.
|
|
156
|
+
export type _NoAnnotationHintsTwin = import('./index').McpAnnotationHints;
|
package/src/deprecation.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|
package/src/mcp-tool.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Projection 4: an action as an MCP tool. Its `invoke` is the package's `invoke`
|
|
3
|
-
* — the same core the HTTP route calls — so the tool cannot drift from the
|
|
4
|
-
* endpoint and cannot acquire a second authz path. One authz system, never two.
|
|
5
|
-
*/
|
|
6
|
-
import type { Ctx } from '@ultimat3/core';
|
|
7
|
-
import { isMcpExposed } from '@ultimat3/core';
|
|
8
|
-
import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
|
|
9
|
-
import type { AnyAction } from './action';
|
|
10
|
-
import { actionName, defOf, invoke } from './invoke';
|
|
11
|
-
import { type JsonSchemaObject, sortSchema } from './json-schema';
|
|
12
|
-
import type { ActionPolicy } from './policy-gate';
|
|
13
|
-
import { listActions } from './registry';
|
|
14
|
-
|
|
15
|
-
export interface McpToolDescriptor {
|
|
16
|
-
/**
|
|
17
|
-
* The export name VERBATIM — `@ultimat3/mcp` serves that name and nothing else, so any
|
|
18
|
-
* transformation here would be a label no `tools/call` could spell. See `toMcpTool`.
|
|
19
|
-
*/
|
|
20
|
-
readonly name: string;
|
|
21
|
-
/** The action's `mcp.description`, or its name when the author gave none. */
|
|
22
|
-
readonly description: string;
|
|
23
|
-
/**
|
|
24
|
-
* The action this tool projects. Equal to `name` by construction since 2026-08 — kept because
|
|
25
|
-
* a reader asking "which action is behind this tool?" should not have to know that.
|
|
26
|
-
*/
|
|
27
|
-
readonly action: string;
|
|
28
|
-
/**
|
|
29
|
-
* The action's own policy object, not a copy — `tool().policy === action.policy`
|
|
30
|
-
* is what makes "an MCP call cannot reach a different authz path" checkable.
|
|
31
|
-
*/
|
|
32
|
-
readonly policy: ActionPolicy;
|
|
33
|
-
/**
|
|
34
|
-
* What `@ultimat3/mcp`'s `tools/list` serves for this action — `@ultimat3/schema`'s wire subset,
|
|
35
|
-
* key-sorted — minus the reserved `idempotencyKey` argument an idempotent action's tool adds,
|
|
36
|
-
* which `invoke` here takes as an option instead.
|
|
37
|
-
*/
|
|
38
|
-
readonly inputSchema: JsonSchemaObject;
|
|
39
|
-
/** The served `outputSchema`: structure only, and absent when `output` has no object root. */
|
|
40
|
-
readonly outputSchema?: JsonSchemaObject;
|
|
41
|
-
invoke(input: unknown, options?: McpInvokeOptions): Promise<unknown>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface McpInvokeOptions {
|
|
45
|
-
readonly ctx?: Ctx;
|
|
46
|
-
readonly idempotencyKey?: string | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The tool name is the export name VERBATIM — it was `snake_case`d until 2026-08, and
|
|
51
|
-
* `@ultimat3/mcp` has only ever served the verbatim one, so `.tool().name` was a label no
|
|
52
|
-
* `tools/call` accepted. One name per action, on every surface.
|
|
53
|
-
*/
|
|
54
|
-
export function toMcpTool(target: AnyAction): McpToolDescriptor {
|
|
55
|
-
const name = actionName(target);
|
|
56
|
-
const def = defOf(target);
|
|
57
|
-
const output = toWireOutputSchema(def.output);
|
|
58
|
-
return {
|
|
59
|
-
name,
|
|
60
|
-
description: def.mcp?.description ?? name,
|
|
61
|
-
action: name,
|
|
62
|
-
policy: def.policy,
|
|
63
|
-
inputSchema: sortSchema({ ...toWireSchema(def.input) }),
|
|
64
|
-
...(output === undefined ? {} : { outputSchema: sortSchema({ ...output }) }),
|
|
65
|
-
invoke: (input, options = {}) =>
|
|
66
|
-
invoke(target, input, {
|
|
67
|
-
surface: 'mcp',
|
|
68
|
-
...(options.ctx === undefined ? {} : { ctx: options.ctx }),
|
|
69
|
-
idempotencyKey: options.idempotencyKey ?? null,
|
|
70
|
-
}),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Opt-in, exactly like a query's: only a literal `mcp: { expose: true }` exposes an action.
|
|
76
|
-
*
|
|
77
|
-
* It read `!== false` until 2026-08, which made writing an action silently hand every agent a
|
|
78
|
-
* new write capability — and disagreed with `@ultimat3/mcp`'s `exposedPrimitives`, the projection
|
|
79
|
-
* that actually builds a catalog. Two functions answering "is this a tool?" differently is the
|
|
80
|
-
* ambiguity axiom 1 rejects, so the fail-closed one wins — and `isMcpExposed` from
|
|
81
|
-
* `@ultimat3/core` is now the single answer every reader in the framework asks.
|
|
82
|
-
*/
|
|
83
|
-
export function isExposed(target: AnyAction): boolean {
|
|
84
|
-
return isMcpExposed(target.mcp);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Deterministic order — the tool list is part of the agent-visible contract. */
|
|
88
|
-
export function toMcpTools(
|
|
89
|
-
actions: readonly AnyAction[] = listActions(),
|
|
90
|
-
): readonly McpToolDescriptor[] {
|
|
91
|
-
return actions
|
|
92
|
-
.filter(isExposed)
|
|
93
|
-
.map(toMcpTool)
|
|
94
|
-
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
95
|
-
}
|
package/src/wire-headers.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The two request headers the typed client and the HTTP projection both name. Their own module and
|
|
3
|
-
* not `http.ts`'s, because `client.ts` needs exactly these two strings: importing them from there
|
|
4
|
-
* dragged `@ultimat3/http`, `@ultimat3/cache`, `@ultimat3/policy` and the whole `invoke` runtime
|
|
5
|
-
* into every browser bundle that called `rpc()` — 42,204 B against the read client's 11,977 B,
|
|
6
|
-
* measured, which is why two islands in this repo write a bare `fetch` instead of importing one.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Matches `HttpConfig.buildIdHeader`; the pipeline reads it into `ctx.clientBuildId` — the
|
|
11
|
-
* CLIENT's claim, never `ctx.buildId`, which is the build this process serves. A mismatch the
|
|
12
|
-
* client sees on the way back is `X_CONTRACT_DRIFT`.
|
|
13
|
-
*/
|
|
14
|
-
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* RFC 9110's spelling, lower-cased. Core's, re-exported by name: `clientTransport` sets it and
|
|
18
|
-
* this package's route reads it, so a second declaration here would be two answers to one header.
|
|
19
|
-
*/
|
|
20
|
-
export { IDEMPOTENCY_HEADER } from '@ultimat3/core';
|