@ultimat3/action 1.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/LICENSE +21 -0
- package/README.md +210 -0
- package/package.json +39 -0
- package/src/action.ts +295 -0
- package/src/client.ts +150 -0
- package/src/contract-test.ts +116 -0
- package/src/define-api.ts +138 -0
- package/src/errors.ts +195 -0
- package/src/facade.ts +42 -0
- package/src/http.ts +149 -0
- package/src/idempotency.ts +122 -0
- package/src/index.ts +110 -0
- package/src/invoke.ts +120 -0
- package/src/job-handle.ts +38 -0
- package/src/json-schema.ts +39 -0
- package/src/mcp-tool.ts +73 -0
- package/src/mutator.ts +193 -0
- package/src/naming.ts +99 -0
- package/src/openapi.ts +85 -0
- package/src/policy-gate.ts +81 -0
- package/src/registry.ts +70 -0
- package/src/stable.ts +68 -0
- package/src/tags.ts +17 -0
- package/src/validate.ts +38 -0
package/src/mutator.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mutator` primitive: an action plus an optimistic local twin. It is built
|
|
3
|
+
* on `action()`, not beside it — a mutator IS an action, so it gets the route,
|
|
4
|
+
* the OpenAPI entry, the client method, the MCP tool, the job handle and the
|
|
5
|
+
* contract tests for free, and its authz is the same single evaluation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Ctx } from '@ultimat3/core';
|
|
9
|
+
import { assertNever } from '@ultimat3/core';
|
|
10
|
+
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
|
+
import type { Action, ActionCache, ActionDef, ActionDescriptor, ActionMcp } from './action';
|
|
12
|
+
import { action, isAction } from './action';
|
|
13
|
+
import type { ActionPolicy } from './policy-gate';
|
|
14
|
+
|
|
15
|
+
/** Minimum shape of a locally-stored row: an id the local twin can address. */
|
|
16
|
+
export interface LocalRow {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Augmented by the app so `tx.posts` is typed:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* declare module '@ultimat3/action' {
|
|
25
|
+
* interface LocalTables { posts: PostRow }
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export interface LocalTables {
|
|
30
|
+
/** Reserved marker so augmentation, not this key, defines the table set. */
|
|
31
|
+
readonly '~ultimate': never;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type LocalTableName = Exclude<keyof LocalTables, '~ultimate'>;
|
|
35
|
+
|
|
36
|
+
export interface LocalTable<TRow extends LocalRow> {
|
|
37
|
+
insert(row: TRow): void;
|
|
38
|
+
update(id: string, patch: Partial<TRow> | ((row: TRow) => Partial<TRow>)): void;
|
|
39
|
+
delete(id: string): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The client-side write surface a mutator's `local()` gets. @ultimat3/realtime
|
|
44
|
+
* implements it over OPFS SQLite; tests implement it over a Map.
|
|
45
|
+
*/
|
|
46
|
+
export type LocalTx = {
|
|
47
|
+
readonly [K in LocalTableName]: LocalTable<Extract<LocalTables[K], LocalRow>>;
|
|
48
|
+
} & {
|
|
49
|
+
/** Escape hatch for generated code that only knows the table name as a string. */
|
|
50
|
+
table<TRow extends LocalRow>(name: string): LocalTable<TRow>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface CustomConflict<TOutput> {
|
|
54
|
+
readonly strategy: 'custom';
|
|
55
|
+
merge(local: TOutput, server: TOutput): TOutput;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Conflict<TOutput> = 'server-wins' | 'last-write-wins' | CustomConflict<TOutput>;
|
|
59
|
+
|
|
60
|
+
export function custom<TOutput>(
|
|
61
|
+
merge: (local: TOutput, server: TOutput) => TOutput,
|
|
62
|
+
): CustomConflict<TOutput> {
|
|
63
|
+
return { strategy: 'custom', merge };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MutatorDef<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> {
|
|
67
|
+
readonly input: TInput;
|
|
68
|
+
readonly output: TOutput;
|
|
69
|
+
readonly policy: ActionPolicy;
|
|
70
|
+
readonly cache?: ActionCache;
|
|
71
|
+
readonly mcp?: ActionMcp;
|
|
72
|
+
readonly idempotent?: boolean;
|
|
73
|
+
/** Optimistic twin: runs against the local store, synchronously, no I/O. */
|
|
74
|
+
local(tx: LocalTx, input: InferOutput<TInput>): void;
|
|
75
|
+
/** Authoritative write. Identical to an action `handle`, ctx-first for symmetry. */
|
|
76
|
+
server(
|
|
77
|
+
ctx: Ctx,
|
|
78
|
+
input: InferOutput<TInput>,
|
|
79
|
+
): Promise<InferOutput<TOutput>> | InferOutput<TOutput>;
|
|
80
|
+
readonly conflict: Conflict<InferOutput<TOutput>>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type MutatorDescriptor = Omit<ActionDescriptor, 'kind'> & {
|
|
84
|
+
readonly kind: 'mutator';
|
|
85
|
+
readonly conflict: 'server-wins' | 'last-write-wins' | 'custom';
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export interface Mutator<
|
|
89
|
+
TInput extends StandardSchemaV1 = StandardSchemaV1,
|
|
90
|
+
TOutput extends StandardSchemaV1 = StandardSchemaV1,
|
|
91
|
+
> extends Action<TInput, TOutput> {
|
|
92
|
+
/**
|
|
93
|
+
* The brand, and the only thing `describeAction` has to go on: it reads this field to set
|
|
94
|
+
* `ActionDescriptor.mutator`, because a mutator's `describe()` still reports `kind: 'action'`.
|
|
95
|
+
* Renaming it here would silently turn every mutator back into a plain action downstream.
|
|
96
|
+
*/
|
|
97
|
+
readonly isMutator: true;
|
|
98
|
+
readonly conflict: Conflict<InferOutput<TOutput>>;
|
|
99
|
+
/**
|
|
100
|
+
* Applied on the client before the server round trip, and replayed on every
|
|
101
|
+
* rebase — so it must stay a pure function of `(tx, input)`: no I/O, no clock,
|
|
102
|
+
* no randomness. Takes parsed input because nothing re-parses on this half.
|
|
103
|
+
*/
|
|
104
|
+
local(tx: LocalTx, input: InferOutput<TInput>): void;
|
|
105
|
+
/**
|
|
106
|
+
* The authoritative half. Routes through the action's own callable, never the
|
|
107
|
+
* declared `server` — so input parsing, policy, the handler and output parsing
|
|
108
|
+
* run exactly once, in the one core, the same as every other surface. Takes raw
|
|
109
|
+
* input, like the callable and `.as()`, because this is where parsing happens.
|
|
110
|
+
*/
|
|
111
|
+
server(ctx: Ctx, input: InferInput<TInput>): Promise<InferOutput<TOutput>>;
|
|
112
|
+
describeMutator(): MutatorDescriptor;
|
|
113
|
+
named(name: string): Mutator<TInput, TOutput>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function mutator<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
117
|
+
def: MutatorDef<TInput, TOutput>,
|
|
118
|
+
): Mutator<TInput, TOutput> {
|
|
119
|
+
const actionDef: ActionDef<TInput, TOutput> = {
|
|
120
|
+
input: def.input,
|
|
121
|
+
output: def.output,
|
|
122
|
+
policy: def.policy,
|
|
123
|
+
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
124
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
125
|
+
...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
|
|
126
|
+
handle: ({ input, ctx }) => def.server(ctx, input),
|
|
127
|
+
};
|
|
128
|
+
return wrap(def, action(actionDef));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Structural, exactly like `isAction`: the brand alone is not enough, because a
|
|
133
|
+
* mutator's authoritative half runs through the action's declaration and a
|
|
134
|
+
* look-alike has none. Branding without `isAction` would have made this the one
|
|
135
|
+
* primitive whose façade a hand-rolled object could counterfeit.
|
|
136
|
+
*/
|
|
137
|
+
export function isMutator(value: unknown): value is Mutator {
|
|
138
|
+
return isAction(value) && (value as { isMutator?: unknown }).isMutator === true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function wrap<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
|
|
142
|
+
def: MutatorDef<TInput, TOutput>,
|
|
143
|
+
base: Action<TInput, TOutput>,
|
|
144
|
+
): Mutator<TInput, TOutput> {
|
|
145
|
+
// Captured before we overwrite it: wrapping in place would otherwise make
|
|
146
|
+
// `named` call itself forever instead of reaching the action's own rename.
|
|
147
|
+
const rename = base.named.bind(base);
|
|
148
|
+
const self: Mutator<TInput, TOutput> = Object.assign(base, {
|
|
149
|
+
isMutator: true as const,
|
|
150
|
+
conflict: def.conflict,
|
|
151
|
+
local: (tx: LocalTx, input: InferOutput<TInput>): void => {
|
|
152
|
+
def.local(tx, input);
|
|
153
|
+
},
|
|
154
|
+
// `base(...)` and not `def.server(...)`: the callable IS `invoke`, so the
|
|
155
|
+
// authoritative half cannot skip the input parse, the policy or the output
|
|
156
|
+
// parse. Calling the declaration here would be the second execution path.
|
|
157
|
+
server: (ctx: Ctx, input: InferInput<TInput>): Promise<InferOutput<TOutput>> =>
|
|
158
|
+
base(input, { ctx }),
|
|
159
|
+
describeMutator: (): MutatorDescriptor => ({
|
|
160
|
+
...base.describe(),
|
|
161
|
+
kind: 'mutator' as const,
|
|
162
|
+
conflict: strategyOf(def.conflict),
|
|
163
|
+
}),
|
|
164
|
+
named: (name: string): Mutator<TInput, TOutput> => wrap(def, rename(name)),
|
|
165
|
+
});
|
|
166
|
+
return self;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function strategyOf<TOutput>(
|
|
170
|
+
conflict: Conflict<TOutput>,
|
|
171
|
+
): 'server-wins' | 'last-write-wins' | 'custom' {
|
|
172
|
+
return typeof conflict === 'string' ? conflict : conflict.strategy;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rebase decision for @ultimat3/realtime: which value survives when the local
|
|
177
|
+
* twin and the server disagree.
|
|
178
|
+
*/
|
|
179
|
+
export function resolveConflict<TOutput>(
|
|
180
|
+
conflict: Conflict<TOutput>,
|
|
181
|
+
local: TOutput,
|
|
182
|
+
server: TOutput,
|
|
183
|
+
): TOutput {
|
|
184
|
+
if (typeof conflict !== 'string') return conflict.merge(local, server);
|
|
185
|
+
switch (conflict) {
|
|
186
|
+
case 'server-wins':
|
|
187
|
+
return server;
|
|
188
|
+
case 'last-write-wins':
|
|
189
|
+
return local;
|
|
190
|
+
default:
|
|
191
|
+
return assertNever(conflict);
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one naming rule: an action's export name derives its HTTP path and MCP
|
|
3
|
+
* tool name. Pure string math so the browser client can derive the same path
|
|
4
|
+
* without importing a byte of server code.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Irregular plurals we actually hit in domain models. Extend deliberately, not eagerly. */
|
|
8
|
+
const IRREGULAR: Readonly<Record<string, string>> = {
|
|
9
|
+
person: 'people',
|
|
10
|
+
child: 'children',
|
|
11
|
+
man: 'men',
|
|
12
|
+
woman: 'women',
|
|
13
|
+
datum: 'data',
|
|
14
|
+
index: 'indexes',
|
|
15
|
+
entry: 'entries',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface ActionPath {
|
|
19
|
+
/** First camelCase word, kebab-cased. `publishPost` -> `publish`. */
|
|
20
|
+
readonly verb: string;
|
|
21
|
+
/** Remaining words, last one pluralized, kebab-cased. `publishPost` -> `posts`. */
|
|
22
|
+
readonly resource: string;
|
|
23
|
+
/** `POST /api/<resource>/<verb>`. */
|
|
24
|
+
readonly path: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
|
|
28
|
+
export function splitWords(name: string): string[] {
|
|
29
|
+
return name
|
|
30
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
31
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
32
|
+
.split(/[\s_-]+/)
|
|
33
|
+
.filter((word) => word.length > 0)
|
|
34
|
+
.map((word) => word.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Naive-on-purpose English pluralizer. A word that already ends in `s` is left
|
|
39
|
+
* alone, so `publishPosts` and `publishPost` agree on the `posts` resource.
|
|
40
|
+
*/
|
|
41
|
+
export function pluralize(word: string): string {
|
|
42
|
+
const irregular = IRREGULAR[word];
|
|
43
|
+
if (irregular !== undefined) return irregular;
|
|
44
|
+
if (word.endsWith('s')) return word;
|
|
45
|
+
if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
|
|
46
|
+
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
|
|
47
|
+
return `${word}s`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `publishPost` -> POST /api/posts/publish
|
|
52
|
+
* `updateUserProfile` -> POST /api/user-profiles/update
|
|
53
|
+
* `likePost` -> POST /api/posts/like
|
|
54
|
+
* `checkout` -> POST /api/checkouts/invoke (single-word fallback)
|
|
55
|
+
*/
|
|
56
|
+
export function derivePath(name: string): ActionPath {
|
|
57
|
+
const words = splitWords(name);
|
|
58
|
+
const head = words[0] ?? 'invoke';
|
|
59
|
+
if (words.length < 2) {
|
|
60
|
+
const resource = pluralize(head);
|
|
61
|
+
return { verb: 'invoke', resource, path: `/api/${resource}/invoke` };
|
|
62
|
+
}
|
|
63
|
+
const nouns = words.slice(1);
|
|
64
|
+
const last = nouns[nouns.length - 1] ?? head;
|
|
65
|
+
const resource = [...nouns.slice(0, -1), pluralize(last)].join('-');
|
|
66
|
+
return { verb: head, resource, path: `/api/${resource}/${head}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** MCP tool names are `snake_case`: `publishPost` -> `publish_post`. */
|
|
70
|
+
export function toToolName(name: string): string {
|
|
71
|
+
return splitWords(name).join('_');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** OpenAPI `operationId` is the action name verbatim — it is already unique. */
|
|
75
|
+
export function toOperationId(name: string): string {
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pascal = (name: string): string =>
|
|
80
|
+
splitWords(name)
|
|
81
|
+
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
|
|
82
|
+
.join('');
|
|
83
|
+
|
|
84
|
+
/** OpenAPI component name for an action's input schema. */
|
|
85
|
+
export function inputSchemaName(name: string): string {
|
|
86
|
+
return `${pascal(name)}Input`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** OpenAPI component name for an action's output schema. */
|
|
90
|
+
export function outputSchemaName(name: string): string {
|
|
91
|
+
return `${pascal(name)}Output`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** RFC 9457 body shared by every error response. */
|
|
95
|
+
export const PROBLEM_SCHEMA_NAME = 'Problem';
|
|
96
|
+
|
|
97
|
+
export function schemaRef(component: string): string {
|
|
98
|
+
return `#/components/schemas/${component}`;
|
|
99
|
+
}
|
package/src/openapi.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection 2: the whole registry as one OpenAPI 3.1 document.
|
|
3
|
+
*
|
|
4
|
+
* DETERMINISM IS A HARD REQUIREMENT. `x verify` diffs this output against the
|
|
5
|
+
* committed spec to detect contract drift, so: keys are sorted at every depth
|
|
6
|
+
* (`stableStringify`), paths and components are built from the name-sorted
|
|
7
|
+
* registry, and nothing here reads the clock, the environment or a random source.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AnyAction } from './action';
|
|
11
|
+
import { toOpenApiOperation } from './http';
|
|
12
|
+
import { actionName } from './invoke';
|
|
13
|
+
import { type JsonSchemaObject, jsonSchemaOf, sortSchema } from './json-schema';
|
|
14
|
+
import { derivePath, inputSchemaName, outputSchemaName, PROBLEM_SCHEMA_NAME } from './naming';
|
|
15
|
+
import { listActions } from './registry';
|
|
16
|
+
import { stableStringify } from './stable';
|
|
17
|
+
|
|
18
|
+
export interface OpenApiInfo {
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly version: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OpenApiDocument {
|
|
24
|
+
readonly openapi: '3.1.0';
|
|
25
|
+
readonly info: OpenApiInfo;
|
|
26
|
+
readonly paths: Record<string, unknown>;
|
|
27
|
+
readonly components: { readonly schemas: Record<string, JsonSchemaObject> };
|
|
28
|
+
readonly tags: readonly { readonly name: string }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BuildOpenApiOptions {
|
|
32
|
+
readonly title?: string;
|
|
33
|
+
readonly version?: string;
|
|
34
|
+
/** Defaults to the whole registry. Pass a subset to spec one surface only. */
|
|
35
|
+
readonly actions?: readonly AnyAction[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildOpenApi(options: BuildOpenApiOptions = {}): OpenApiDocument {
|
|
39
|
+
const actions = [...(options.actions ?? listActions())].sort(compareByName);
|
|
40
|
+
const paths: Record<string, unknown> = {};
|
|
41
|
+
const schemas: Record<string, JsonSchemaObject> = { [PROBLEM_SCHEMA_NAME]: PROBLEM_SCHEMA };
|
|
42
|
+
const tags = new Set<string>();
|
|
43
|
+
|
|
44
|
+
for (const target of actions) {
|
|
45
|
+
const name = actionName(target);
|
|
46
|
+
const { path, resource } = derivePath(name);
|
|
47
|
+
paths[path] = { post: toOpenApiOperation(target) };
|
|
48
|
+
schemas[inputSchemaName(name)] = sortSchema(jsonSchemaOf(target.input));
|
|
49
|
+
schemas[outputSchemaName(name)] = sortSchema(jsonSchemaOf(target.output));
|
|
50
|
+
tags.add(resource);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
openapi: '3.1.0',
|
|
55
|
+
info: { title: options.title ?? 'Ultimate API', version: options.version ?? '0.0.0' },
|
|
56
|
+
paths,
|
|
57
|
+
components: { schemas },
|
|
58
|
+
tags: [...tags].sort().map((name) => ({ name })),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The bytes `x verify` compares. Sorted keys, trailing newline, 2-space indent. */
|
|
63
|
+
export function serializeOpenApi(document: OpenApiDocument): string {
|
|
64
|
+
return `${stableStringify(document, 2)}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compareByName(a: AnyAction, b: AnyAction): number {
|
|
68
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** RFC 9457 + the Ultimate error contract (code / cause / fix / docs). */
|
|
72
|
+
const PROBLEM_SCHEMA: JsonSchemaObject = {
|
|
73
|
+
type: 'object',
|
|
74
|
+
required: ['type', 'title', 'status', 'code'],
|
|
75
|
+
properties: {
|
|
76
|
+
type: { type: 'string' },
|
|
77
|
+
title: { type: 'string' },
|
|
78
|
+
status: { type: 'integer' },
|
|
79
|
+
detail: { type: 'string' },
|
|
80
|
+
code: { type: 'string', pattern: '^X_[A-Z0-9_]+$' },
|
|
81
|
+
cause: { type: 'string' },
|
|
82
|
+
fix: { type: 'string' },
|
|
83
|
+
docs: { type: 'string', format: 'uri' },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single point of contact with @ultimat3/policy. Every surface (HTTP, MCP,
|
|
3
|
+
* job, direct server call) reaches authz through `guard()` — there is no second
|
|
4
|
+
* code path, which is what makes "one authz system" true rather than aspirational.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Actor, Ctx } from '@ultimat3/core';
|
|
8
|
+
import { assertNever, isAnonymous } from '@ultimat3/core';
|
|
9
|
+
import type { Policy, Surface as PolicySurface } from '@ultimat3/policy';
|
|
10
|
+
import { enforce } from '@ultimat3/policy';
|
|
11
|
+
import { ActionDeniedError } from './errors';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Policies are opaque here: we evaluate them, we never introspect their rules.
|
|
15
|
+
* `TRow` is what a row-level rule decides about, defaulted so the bare
|
|
16
|
+
* `ActionPolicy` keeps meaning "decides on input, any row or none".
|
|
17
|
+
*/
|
|
18
|
+
export type ActionPolicy<TRow = unknown> = Policy<unknown, TRow>;
|
|
19
|
+
|
|
20
|
+
/** Which projection is running. Selects the deny renderer, never the decision. */
|
|
21
|
+
export type Surface = 'server' | 'http' | 'mcp' | 'job';
|
|
22
|
+
|
|
23
|
+
export interface PolicySubject {
|
|
24
|
+
readonly actor: Actor | null;
|
|
25
|
+
readonly input: unknown;
|
|
26
|
+
/**
|
|
27
|
+
* The already-loaded row a row-level rule decides about; `null` when the action
|
|
28
|
+
* declared no loader. Optional here and required in `PolicyArgs` for the same
|
|
29
|
+
* reason `EvaluateArgs.row` is: a surface deciding on input alone should not have
|
|
30
|
+
* to write `row: null`, but the predicate it reaches must still see the field.
|
|
31
|
+
* `invoke` always passes it, so the gap closes before any rule runs.
|
|
32
|
+
*/
|
|
33
|
+
readonly row?: unknown;
|
|
34
|
+
readonly ctx: Ctx;
|
|
35
|
+
readonly action: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Evaluate once, render the denial per surface. `enforce` runs the same policy
|
|
40
|
+
* object for every surface, so an actor denied over HTTP is denied over MCP with
|
|
41
|
+
* the same reason and the same code.
|
|
42
|
+
*/
|
|
43
|
+
export function guard(policy: ActionPolicy, subject: PolicySubject, surface: Surface): void {
|
|
44
|
+
const denial = enforce(policySurface(surface), policy, {
|
|
45
|
+
input: subject.input,
|
|
46
|
+
actor: subject.actor,
|
|
47
|
+
// `evaluate()` normalises a missing row to `null`, so an input-only rule and a
|
|
48
|
+
// row rule reach the predicate through one shape rather than two.
|
|
49
|
+
row: subject.row,
|
|
50
|
+
ctx: subject.ctx,
|
|
51
|
+
});
|
|
52
|
+
if (denial !== undefined) throw new ActionDeniedError(subject.action, denial);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A direct server call is the job surface: no request, no response to shape. */
|
|
56
|
+
function policySurface(surface: Surface): PolicySurface {
|
|
57
|
+
switch (surface) {
|
|
58
|
+
case 'http':
|
|
59
|
+
return 'http';
|
|
60
|
+
case 'mcp':
|
|
61
|
+
return 'mcp';
|
|
62
|
+
case 'job':
|
|
63
|
+
case 'server':
|
|
64
|
+
return 'job';
|
|
65
|
+
default:
|
|
66
|
+
return assertNever(surface);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Core models "nobody" as an anonymous actor; policy models it as `null`, which is
|
|
72
|
+
* what turns a missing session into `X_UNAUTHENTICATED` instead of a bare denial.
|
|
73
|
+
*/
|
|
74
|
+
export function actorOf(ctx: Ctx): Actor | null {
|
|
75
|
+
return isAnonymous(ctx.actor) ? null : ctx.actor;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The capability an action requires, for manifests and OpenAPI metadata. */
|
|
79
|
+
export function policyCapability(policy: ActionPolicy): string {
|
|
80
|
+
return policy.label;
|
|
81
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action registry. Names come from export names — `registerActions(module)`
|
|
3
|
+
* is how a module namespace becomes named, collision-checked, projectable
|
|
4
|
+
* actions. Registration is also where a missing policy becomes a build error.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ActionDescriptor, AnyAction } from './action';
|
|
8
|
+
import { isAction, nameAction } from './action';
|
|
9
|
+
import { ActionDuplicateError, ActionPolicyMissingError } from './errors';
|
|
10
|
+
|
|
11
|
+
const registry = new Map<string, AnyAction>();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register one action under an explicit name. The name lands on the action you
|
|
15
|
+
* passed, so the module's own export is projectable after boot and there is no
|
|
16
|
+
* "use the return value instead" rule to forget.
|
|
17
|
+
*/
|
|
18
|
+
export function registerAction<A extends AnyAction>(name: string, target: A): A {
|
|
19
|
+
const seated = registry.get(name);
|
|
20
|
+
if (seated !== undefined) {
|
|
21
|
+
// Re-registering the SAME object under the SAME name is one registration seen twice, not a
|
|
22
|
+
// collision: `defineApi` registers a feature module at boot and the framework's module scan
|
|
23
|
+
// reaches the same declaration file directly, so both arrive at the identical action. Only a
|
|
24
|
+
// DIFFERENT action under a taken name is the ambiguity `X_ACTION_DUPLICATE` exists to refuse.
|
|
25
|
+
if (seated !== (target as AnyAction)) throw new ActionDuplicateError(name);
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
if (target.policy === undefined || target.policy === null) {
|
|
29
|
+
throw new ActionPolicyMissingError(name);
|
|
30
|
+
}
|
|
31
|
+
const named = nameAction(target, name);
|
|
32
|
+
registry.set(name, named);
|
|
33
|
+
return named;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register every action exported by a module namespace, keyed by export name.
|
|
38
|
+
* `registerActions(await import('./actions'))` at boot; the CLI does the same
|
|
39
|
+
* during `x verify` so the manifest and the server agree by construction.
|
|
40
|
+
*/
|
|
41
|
+
export function registerActions(module: Record<string, unknown>): readonly AnyAction[] {
|
|
42
|
+
const registered: AnyAction[] = [];
|
|
43
|
+
for (const name of Object.keys(module).sort()) {
|
|
44
|
+
const value = module[name];
|
|
45
|
+
if (isAction(value)) registered.push(registerAction(name, value));
|
|
46
|
+
}
|
|
47
|
+
return registered;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getAction(name: string): AnyAction | undefined {
|
|
51
|
+
return registry.get(name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Sorted by name: iteration order is part of the deterministic contract output. */
|
|
55
|
+
export function listActions(): readonly AnyAction[] {
|
|
56
|
+
return [...registry.entries()].sort(byName).map(([, value]) => value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function describeActions(): readonly ActionDescriptor[] {
|
|
60
|
+
return listActions().map((target) => target.describe());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Test-only. Production registers once at boot and never unregisters. */
|
|
64
|
+
export function resetRegistry(): void {
|
|
65
|
+
registry.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {
|
|
69
|
+
return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
|
|
70
|
+
}
|
package/src/stable.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON: key-sorted serialization plus a cheap content hash.
|
|
3
|
+
* Both the OpenAPI document and idempotency fingerprints depend on byte-stable
|
|
4
|
+
* output, so this is the only serializer either path is allowed to use.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type JsonObject = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export function isJsonObject(value: unknown): value is JsonObject {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
|
|
14
|
+
export function stableStringify(value: unknown, indent = 0): string {
|
|
15
|
+
return write(value, indent, 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function write(value: unknown, indent: number, depth: number): string {
|
|
19
|
+
if (value === null) return 'null';
|
|
20
|
+
switch (typeof value) {
|
|
21
|
+
case 'string':
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
case 'number':
|
|
24
|
+
return Number.isFinite(value) ? String(value) : 'null';
|
|
25
|
+
case 'boolean':
|
|
26
|
+
return String(value);
|
|
27
|
+
case 'bigint':
|
|
28
|
+
return JSON.stringify(`${value}n`);
|
|
29
|
+
case 'undefined':
|
|
30
|
+
case 'function':
|
|
31
|
+
case 'symbol':
|
|
32
|
+
return 'null';
|
|
33
|
+
default:
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
const pad = indent > 0 ? '\n'.padEnd(1 + indent * (depth + 1), ' ') : '';
|
|
37
|
+
const close = indent > 0 ? '\n'.padEnd(1 + indent * depth, ' ') : '';
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
if (value.length === 0) return '[]';
|
|
40
|
+
const items = value.map((item) => write(item, indent, depth + 1));
|
|
41
|
+
return `[${pad}${items.join(`,${pad || ''}`)}${close}]`;
|
|
42
|
+
}
|
|
43
|
+
const record = value as JsonObject;
|
|
44
|
+
const keys = Object.keys(record)
|
|
45
|
+
.filter((key) => record[key] !== undefined)
|
|
46
|
+
.sort();
|
|
47
|
+
if (keys.length === 0) return '{}';
|
|
48
|
+
const gap = indent > 0 ? ' ' : '';
|
|
49
|
+
const entries = keys.map(
|
|
50
|
+
(key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1)}`,
|
|
51
|
+
);
|
|
52
|
+
return `{${pad}${entries.join(`,${pad || ''}`)}${close}}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** FNV-1a/32 as hex. Fingerprinting only — never a security boundary. */
|
|
56
|
+
export function fnv1a(input: string): string {
|
|
57
|
+
let hash = 0x811c9dc5;
|
|
58
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
59
|
+
hash ^= input.charCodeAt(i);
|
|
60
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
61
|
+
}
|
|
62
|
+
return hash.toString(16).padStart(8, '0');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stable fingerprint of any JSON-ish value. */
|
|
66
|
+
export function fingerprint(value: unknown): string {
|
|
67
|
+
return fnv1a(stableStringify(value));
|
|
68
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache tags stay opaque to actions — we hand them back to @ultimat3/cache
|
|
3
|
+
* untouched. The only thing this package needs is the wire string per tag, for
|
|
4
|
+
* manifests, OpenAPI metadata and the invalidation graph in `/_x`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CacheTag } from '@ultimat3/cache';
|
|
8
|
+
import { serializeTag } from '@ultimat3/cache';
|
|
9
|
+
|
|
10
|
+
export function tagKey(value: CacheTag): string {
|
|
11
|
+
return serializeTag(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Sorted + de-duplicated: descriptor output must not depend on declaration order. */
|
|
15
|
+
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
16
|
+
return [...new Set(tags.map(tagKey))].sort();
|
|
17
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema validation for every surface. One code path covers HTTP, MCP, jobs and
|
|
3
|
+
* direct server calls, so all four reject the same payload with the same code
|
|
4
|
+
* and the same issue text — on the way in and on the way out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
8
|
+
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
9
|
+
import { InputInvalidError, OutputInvalidError } from './errors';
|
|
10
|
+
|
|
11
|
+
export async function validateInput<S extends StandardSchemaV1>(
|
|
12
|
+
schema: S,
|
|
13
|
+
raw: unknown,
|
|
14
|
+
actionName: string,
|
|
15
|
+
): Promise<InferOutput<S>> {
|
|
16
|
+
const result = await validateAsync(schema, raw);
|
|
17
|
+
if (result.issues !== undefined) {
|
|
18
|
+
throw new InputInvalidError(actionName, formatIssues(result.issues).join('; '));
|
|
19
|
+
}
|
|
20
|
+
return result.value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The handler's return value is data too. Parsing it is what makes the OpenAPI
|
|
25
|
+
* response schema, the typed client and the MCP `outputSchema` true rather than
|
|
26
|
+
* documentation — a handler that drifts from `output` fails on its own call.
|
|
27
|
+
*/
|
|
28
|
+
export async function validateOutput<S extends StandardSchemaV1>(
|
|
29
|
+
schema: S,
|
|
30
|
+
produced: unknown,
|
|
31
|
+
actionName: string,
|
|
32
|
+
): Promise<InferOutput<S>> {
|
|
33
|
+
const result = await validateAsync(schema, produced);
|
|
34
|
+
if (result.issues !== undefined) {
|
|
35
|
+
throw new OutputInvalidError(actionName, formatIssues(result.issues).join('; '));
|
|
36
|
+
}
|
|
37
|
+
return result.value;
|
|
38
|
+
}
|