@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/src/registry.ts CHANGED
@@ -4,18 +4,35 @@
4
4
  * actions. Registration is also where a missing policy becomes a build error.
5
5
  */
6
6
 
7
+ import { SchemaUnsupportedError } from '@ultimat3/schema';
7
8
  import type { ActionDescriptor, AnyAction } from './action';
8
9
  import { isAction, nameAction } from './action';
9
- import { ActionDuplicateError, ActionPolicyMissingError } from './errors';
10
+ import { ActionDuplicateError, ActionPathDuplicateError, ActionPolicyMissingError } from './errors';
11
+ import { assertIdempotencyScope } from './idempotency';
12
+ import { jsonSchemaOf, mcpSchemaOf } from './json-schema';
13
+ import { derivePath } from './naming';
10
14
 
11
15
  const registry = new Map<string, AnyAction>();
12
16
 
17
+ /**
18
+ * Derived route -> the action name that owns it. A second index because the name is not the
19
+ * path: `pluralize` leaves a trailing `s` alone by design, so `archiveOrder` and `archiveOrders`
20
+ * are two names and one route. Nothing downstream can refuse that — the router seats whichever
21
+ * came last and the shadowed action stays in the OpenAPI document and the MCP tool list.
22
+ */
23
+ const paths = new Map<string, string>();
24
+
13
25
  /**
14
26
  * Register one action under an explicit name. The name lands on the action you
15
27
  * passed, so the module's own export is projectable after boot and there is no
16
28
  * "use the return value instead" rule to forget.
17
29
  */
18
30
  export function registerAction<A extends AnyAction>(name: string, target: A): A {
31
+ // Boot, never the first request, and here rather than in `registerActions` because this is the
32
+ // funnel every registration path goes through — and it necessarily runs before a route is
33
+ // mounted. A no-op unless the app declared `scope: 'shared'`, which is the only case where the
34
+ // framework has been told something it can check. See `assertRateLimitScope`, its twin.
35
+ assertIdempotencyScope();
19
36
  const seated = registry.get(name);
20
37
  if (seated !== undefined) {
21
38
  // Re-registering the SAME object under the SAME name is one registration seen twice, not a
@@ -28,11 +45,44 @@ export function registerAction<A extends AnyAction>(name: string, target: A): A
28
45
  if (target.policy === undefined || target.policy === null) {
29
46
  throw new ActionPolicyMissingError(name);
30
47
  }
48
+ assertProjectable(name, target);
49
+ const { path } = derivePath(name);
50
+ const owner = paths.get(path);
51
+ if (owner !== undefined && owner !== name) {
52
+ throw new ActionPathDuplicateError({ name, existing: owner, path });
53
+ }
31
54
  const named = nameAction(target, name);
32
55
  registry.set(name, named);
56
+ paths.set(path, name);
33
57
  return named;
34
58
  }
35
59
 
60
+ /**
61
+ * Both schemas must reach JSON Schema, and this is where that is decided — boot, beside the policy
62
+ * check, never the first `tools/list`. `jsonSchemaOf` used to swallow the refusal into
63
+ * `additionalProperties: true`, so an action whose `input:` the provider cannot describe registered
64
+ * cleanly and then published "any object accepted" on three surfaces while `validateInput` rejected
65
+ * every payload. The shipped `X_SCHEMA_UNSUPPORTED` is re-raised rather than re-coded — the failure
66
+ * is the provider's, and only the cause needs to say which action and which field.
67
+ */
68
+ function assertProjectable(name: string, target: AnyAction): void {
69
+ for (const field of ['input', 'output'] as const) {
70
+ try {
71
+ jsonSchemaOf(target[field]);
72
+ mcpSchemaOf(target[field]);
73
+ } catch {
74
+ // The thrown value is deliberately not rendered into the cause: it is the provider's, of
75
+ // unknown shape, and this package's own two facts — which action, which field — are the
76
+ // ones a reader acts on.
77
+ throw new SchemaUnsupportedError({
78
+ cause: `${name}: \`${field}:\` cannot be projected to JSON Schema`,
79
+ fix: `declare ${name}'s \`${field}:\` with t.object({ ... }) from @ultimat3/action, or call configureSchemaProvider() with a provider that can introspect it`,
80
+ meta: { action: name, field },
81
+ });
82
+ }
83
+ }
84
+ }
85
+
36
86
  /**
37
87
  * Register every action exported by a module namespace, keyed by export name.
38
88
  * `registerActions(await import('./actions'))` at boot; the CLI does the same
@@ -63,6 +113,7 @@ export function describeActions(): readonly ActionDescriptor[] {
63
113
  /** Test-only. Production registers once at boot and never unregisters. */
64
114
  export function resetRegistry(): void {
65
115
  registry.clear();
116
+ paths.clear();
66
117
  }
67
118
 
68
119
  function byName(a: readonly [string, AnyAction], b: readonly [string, AnyAction]): number {
@@ -0,0 +1,177 @@
1
+ /**
2
+ * A value an input schema accepts, derived from that schema's own IR. The policy contract
3
+ * test needs its invocation to REACH the policy, and `{}` fails `input:` first for every
4
+ * action with a required field — which is how "policy denies an anonymous actor" passed on
5
+ * `X_INPUT_INVALID` and proved nothing about authz.
6
+ */
7
+
8
+ import type { SchemaFormat, SchemaNode, StandardSchemaV1 } from '@ultimat3/schema';
9
+ import { requiredKeys, tryIntrospect } from '@ultimat3/schema';
10
+
11
+ /** Version 4, variant 1 — the shape `t.uuid` insists on, and no byte of it means anything. */
12
+ const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
13
+
14
+ /** Short, and valid under `t.slug`'s and `t.cursor`'s patterns as well as bare `t.string`. */
15
+ const SAMPLE_STRING = 'sample';
16
+
17
+ /**
18
+ * One entry per `SchemaFormat`, exhaustively — a new format is a type error here rather than a
19
+ * silent `'sample'` that fails its own validator and reads as an authz test that cannot pass.
20
+ */
21
+ const BY_FORMAT: Readonly<Record<SchemaFormat, string>> = {
22
+ uuid: SAMPLE_UUID,
23
+ email: 'sample@example.test',
24
+ uri: 'https://example.test/sample',
25
+ 'date-time': '2020-01-01T00:00:00.000Z',
26
+ slug: SAMPLE_STRING,
27
+ timezone: 'UTC',
28
+ locale: 'en',
29
+ cursor: SAMPLE_STRING,
30
+ };
31
+
32
+ function sampleString(node: SchemaNode): string {
33
+ if (node.format !== undefined) {
34
+ // A format value is already the exact shape its validator wants; padding or truncating it
35
+ // to a length bound would break the only thing that makes it valid.
36
+ const known: string | undefined = BY_FORMAT[node.format];
37
+ return known ?? SAMPLE_STRING;
38
+ }
39
+ const min = node.minLength ?? 0;
40
+ const padded = SAMPLE_STRING.length >= min ? SAMPLE_STRING : SAMPLE_STRING.padEnd(min, 'x');
41
+ return node.maxLength === undefined ? padded : padded.slice(0, node.maxLength);
42
+ }
43
+
44
+ /**
45
+ * Whether the value this module built for `node` satisfies the node's own `pattern`.
46
+ *
47
+ * A regex cannot be inverted, so `sampleString` cannot construct a value for an arbitrary one —
48
+ * but `pattern` IS in the IR, so whether the value it DID construct is acceptable is knowable
49
+ * here, before the sample is ever handed to `invoke`. That is the whole difference between
50
+ * "the framework could not build your payload, pass one" and an `X_INPUT_INVALID` surfacing out
51
+ * of the action's own parse, which reads as the action being wrong when the action is fine.
52
+ *
53
+ * An uncompilable pattern is a gap, not a throw: only a foreign provider's IR can produce one
54
+ * (`t.string.pattern()` takes a `RegExp`, whose `.source` always recompiles), and a generated
55
+ * contract test must not die on the way to reporting what it needs.
56
+ */
57
+ function satisfiesPattern(node: SchemaNode, value: unknown): boolean {
58
+ if (node.pattern === undefined) return true;
59
+ if (typeof value !== 'string') return false;
60
+ try {
61
+ return new RegExp(node.pattern, node.patternFlags ?? '').test(value);
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function sampleNumber(node: SchemaNode): number {
68
+ const low = node.minimum ?? 0;
69
+ const value = node.integer === true ? Math.ceil(low) : low;
70
+ if (node.maximum === undefined || value <= node.maximum) return value;
71
+ return node.integer === true ? Math.floor(node.maximum) : node.maximum;
72
+ }
73
+
74
+ function sampleObject(node: SchemaNode): Record<string, unknown> {
75
+ const properties = node.properties ?? {};
76
+ const sample: Record<string, unknown> = {};
77
+ // Required-only is what "minimal" means: an optional key and a defaulted one are both
78
+ // absences the schema already accepts, so adding them would only widen what can go wrong.
79
+ for (const key of requiredKeys(node)) {
80
+ const child = properties[key];
81
+ if (child !== undefined) sample[key] = sampleFor(child);
82
+ }
83
+ return sample;
84
+ }
85
+
86
+ function sampleFor(node: SchemaNode): unknown {
87
+ // A nullable field accepts `null`, and `null` is the smallest thing it accepts.
88
+ if (node.nullable === true) return null;
89
+ switch (node.kind) {
90
+ case 'string':
91
+ return sampleString(node);
92
+ case 'number':
93
+ return sampleNumber(node);
94
+ case 'boolean':
95
+ return false;
96
+ case 'date':
97
+ return BY_FORMAT['date-time'];
98
+ case 'enum':
99
+ return node.values?.[0] ?? SAMPLE_STRING;
100
+ case 'literal':
101
+ return node.literal ?? null;
102
+ case 'array':
103
+ return [];
104
+ case 'union': {
105
+ const first = node.anyOf?.[0];
106
+ return first === undefined ? null : sampleFor(first);
107
+ }
108
+ case 'record':
109
+ return {};
110
+ case 'money':
111
+ return { minor: 0, currency: 'USD' };
112
+ case 'object':
113
+ return sampleObject(node);
114
+ default:
115
+ // `unknown`, and any kind a third-party provider emits that this build has never heard
116
+ // of. Deliberately not `assertNever`: a swapped schema provider must not turn a
117
+ // generated contract test into a crash, and the caller already reports a rejected
118
+ // sample as drift with the instruction to pass the input itself.
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Best effort, and honest about it: a schema carrying a constraint the IR does not — a provider's
125
+ * own refinement — yields a value the schema rejects. The caller turns that into
126
+ * `X_CONTRACT_DRIFT`, because a silently skipped assertion is the vacuous test this function
127
+ * exists to end. The one constraint the IR DOES carry is `pattern`, and `sampleGaps` reads it.
128
+ */
129
+ export function sampleInput(schema: StandardSchemaV1): unknown {
130
+ const node = tryIntrospect(schema);
131
+ return node === undefined ? {} : sampleFor(node);
132
+ }
133
+
134
+ /** The root's name in a gap path — a bare `t.string` input has no field to point at. */
135
+ const ROOT_PATH = '(the input)';
136
+
137
+ /**
138
+ * Dotted paths of every sampled field whose own `pattern` the synthesized value cannot satisfy —
139
+ * "what this schema needs that the framework cannot invent", in the order a reader fills them in.
140
+ *
141
+ * Empty is the common case, including for `t.slug` and `t.cursor`, whose patterns `'sample'`
142
+ * already matches. It walks the SAMPLED shape, so an optional key — which `sampleObject`
143
+ * deliberately omits — owes nothing.
144
+ */
145
+ export function sampleGaps(schema: StandardSchemaV1): readonly string[] {
146
+ const node = tryIntrospect(schema);
147
+ return node === undefined ? [] : gapsIn(node, '');
148
+ }
149
+
150
+ function gapsIn(node: SchemaNode, path: string): string[] {
151
+ if (node.nullable === true) return [];
152
+ if (node.kind === 'object') {
153
+ const properties = node.properties ?? {};
154
+ const out: string[] = [];
155
+ for (const key of requiredKeys(node)) {
156
+ const child = properties[key];
157
+ if (child !== undefined) out.push(...gapsIn(child, path === '' ? key : `${path}.${key}`));
158
+ }
159
+ return out;
160
+ }
161
+ if (satisfiesPattern(node, sampleFor(node))) return [];
162
+ return [path === '' ? ROOT_PATH : path];
163
+ }
164
+
165
+ /** `orderRef` -> `orderRef (must match ^ORD-\d{4}$)`, for a cause a reader can act on. */
166
+ export function describeSampleGap(schema: StandardSchemaV1, path: string): string {
167
+ const pattern = patternAt(tryIntrospect(schema), path);
168
+ return pattern === undefined ? path : `${path} (must match ${pattern})`;
169
+ }
170
+
171
+ function patternAt(node: SchemaNode | undefined, path: string): string | undefined {
172
+ if (node === undefined) return undefined;
173
+ if (path === '' || path === ROOT_PATH) return node.pattern;
174
+ const [head, ...rest] = path.split('.');
175
+ const child = head === undefined ? undefined : node.properties?.[head];
176
+ return child === undefined ? undefined : patternAt(child, rest.join('.'));
177
+ }
package/src/stable.ts CHANGED
@@ -1,7 +1,8 @@
1
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.
2
+ * Deterministic JSON in two forms, deliberately NOT one function. `stableStringify` is the
3
+ * DOCUMENT form `openapi.json` is published from it and `json-schema.ts` re-reads it with
4
+ * `JSON.parse`, so a non-finite number must be `null`. `canonicalJson` is the HASH form — it is
5
+ * only ever hashed, so it must be INJECTIVE. One walk, two number rules.
5
6
  */
6
7
 
7
8
  export type JsonObject = Record<string, unknown>;
@@ -10,18 +11,47 @@ export function isJsonObject(value: unknown): value is JsonObject {
10
11
  return typeof value === 'object' && value !== null && !Array.isArray(value);
11
12
  }
12
13
 
14
+ /** How the two forms disagree, and the only thing they disagree about. */
15
+ type NumberForm = (value: number) => string;
16
+
17
+ /** JSON's own rule: a non-finite number has no token in the grammar, so it is `null`. */
18
+ const jsonNumber: NumberForm = (value) => (Number.isFinite(value) ? String(value) : 'null');
19
+
20
+ /**
21
+ * Bare tokens, never quoted and never `'null'`. This output is only ever hashed, so an unquoted
22
+ * word cannot collide with the `string` branch (which always quotes), while `'null'` collided with
23
+ * JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }`, `{ n: -Infinity }` and `{ n: null }` were
24
+ * one `requestHash` and therefore one idempotency record. `-0` is spelled out for the same reason:
25
+ * `String(-0)` is `"0"`, so `-0` and `0` were one record too. The twin of `@ultimat3/query`'s rule
26
+ * in its own `stable.ts`; both are tier 3, so neither can import the other.
27
+ */
28
+ const hashNumber: NumberForm = (value) => {
29
+ if (Number.isNaN(value)) return 'NaN';
30
+ if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
31
+ return Object.is(value, -0) ? '-0' : String(value);
32
+ };
33
+
13
34
  /** JSON with object keys sorted at every depth. No timestamps, no insertion-order leaks. */
14
35
  export function stableStringify(value: unknown, indent = 0): string {
15
- return write(value, indent, 0);
36
+ return write(value, indent, 0, jsonNumber);
37
+ }
38
+
39
+ /**
40
+ * The canonical form a `fingerprint` is taken over. Byte-identical to `stableStringify(value)` for
41
+ * any value carrying no `NaN`, no `±Infinity` and no `-0`, which is why no idempotency record and
42
+ * no job dedupe key issued before this moved.
43
+ */
44
+ export function canonicalJson(value: unknown): string {
45
+ return write(value, 0, 0, hashNumber);
16
46
  }
17
47
 
18
- function write(value: unknown, indent: number, depth: number): string {
48
+ function write(value: unknown, indent: number, depth: number, number: NumberForm): string {
19
49
  if (value === null) return 'null';
20
50
  switch (typeof value) {
21
51
  case 'string':
22
52
  return JSON.stringify(value);
23
53
  case 'number':
24
- return Number.isFinite(value) ? String(value) : 'null';
54
+ return number(value);
25
55
  case 'boolean':
26
56
  return String(value);
27
57
  case 'bigint':
@@ -37,7 +67,7 @@ function write(value: unknown, indent: number, depth: number): string {
37
67
  const close = indent > 0 ? '\n'.padEnd(1 + indent * depth, ' ') : '';
38
68
  if (Array.isArray(value)) {
39
69
  if (value.length === 0) return '[]';
40
- const items = value.map((item) => write(item, indent, depth + 1));
70
+ const items = value.map((item) => write(item, indent, depth + 1, number));
41
71
  return `[${pad}${items.join(`,${pad || ''}`)}${close}]`;
42
72
  }
43
73
  const record = value as JsonObject;
@@ -47,22 +77,27 @@ function write(value: unknown, indent: number, depth: number): string {
47
77
  if (keys.length === 0) return '{}';
48
78
  const gap = indent > 0 ? ' ' : '';
49
79
  const entries = keys.map(
50
- (key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1)}`,
80
+ (key) => `${JSON.stringify(key)}:${gap}${write(record[key], indent, depth + 1, number)}`,
51
81
  );
52
82
  return `{${pad}${entries.join(`,${pad || ''}`)}${close}}`;
53
83
  }
54
84
 
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. */
85
+ /**
86
+ * SHA-256, first 16 hex characters — the same primitive and width `@ultimat3/query`'s `fingerprint`
87
+ * and `@ultimat3/realtime`'s `stableDigest` already chose, and for the same reason.
88
+ *
89
+ * A fingerprint here is a SHARING key over input a client chooses, not a checksum. It is the
90
+ * `requestHash` that decides "same request, replay the stored response" and the job dedupe key
91
+ * `job-handle.ts` files an enqueue under, so a collision hands one caller's stored response to a
92
+ * different request, or drops an enqueue as a duplicate of a job it shares nothing with. FNV-1a/32
93
+ * — what this was — is 4x10^9 values, brute-forceable offline in seconds, so a payload landing on
94
+ * another request's hash was something an attacker could mint rather than something they had to
95
+ * wait for.
96
+ *
97
+ * It is taken over `canonicalJson` and not `stableStringify` for the second half of the same
98
+ * argument: the document form is not injective, so four distinct inputs shared one hash without
99
+ * anyone having to mint anything.
100
+ */
66
101
  export function fingerprint(value: unknown): string {
67
- return fnv1a(stableStringify(value));
102
+ return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
68
103
  }
@@ -0,0 +1,45 @@
1
+ // Compile-time pins for the erased action view. Source, not a `.test.ts`, on purpose:
2
+ // `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
3
+ // type-level claim written in one can never fail. This module emits nothing and exports nothing
4
+ // anybody imports — a regression here is a build error, the only enforcement that counts.
5
+
6
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
7
+ import type { Action, AnyAction } from './action';
8
+ import type { ClientMethod } from './client';
9
+ import type { ActionJobHandle } from './job-handle';
10
+
11
+ /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
12
+ type Assert<T extends true> = T;
13
+
14
+ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
15
+
16
+ type PublishInput = StandardSchemaV1<{ readonly postId: string }>;
17
+ type PublishOutput = StandardSchemaV1<{ readonly published: boolean }>;
18
+ type PublishPost = Action<PublishInput, PublishOutput>;
19
+
20
+ /**
21
+ * The registry hands back `AnyAction` and nothing else, so the erased view has to project every
22
+ * surface — the queue included. Written as the return type rather than `'job' in keyof` because
23
+ * an `AnyAction['job']` that answered the wrong shape would satisfy a key check.
24
+ */
25
+ export type _ErasedViewProjectsAJobHandle = Assert<
26
+ Equals<ReturnType<AnyAction['job']>, ActionJobHandle>
27
+ >;
28
+
29
+ /** …and the typed action still narrows it to its own schemas, which is what `.job()` is for. */
30
+ export type _TypedActionNarrowsTheJobHandle = Assert<
31
+ Equals<ReturnType<PublishPost['job']>, ActionJobHandle<PublishInput, PublishOutput>>
32
+ >;
33
+
34
+ /**
35
+ * Why `client()` is NOT on the erased view, pinned rather than asserted in a comment: a
36
+ * `ClientMethod` is a function type, so its input is checked contravariantly and the erased
37
+ * `(input: unknown) => …` is a supertype of no concrete action's method. Spelling `ClientMethod`
38
+ * with method syntax — or with an `any` — would flip this to `true` and make the asymmetry
39
+ * between `job()` and `client()` look arbitrary.
40
+ */
41
+ export type _ErasedClientIsNotASupertype = Assert<
42
+ ClientMethod<PublishInput, PublishOutput> extends ClientMethod<StandardSchemaV1, StandardSchemaV1>
43
+ ? false
44
+ : true
45
+ >;
package/src/tags.ts DELETED
@@ -1,17 +0,0 @@
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
- }