@zocomputer/system-api 0.0.0-stage → 0.1.1

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.
Files changed (57) hide show
  1. package/README.md +51 -2
  2. package/dist/catalog/index.d.ts +106 -0
  3. package/dist/catalog/index.js +237 -0
  4. package/dist/core/catalog.d.ts +155 -0
  5. package/dist/core/catalog.js +24 -0
  6. package/dist/core/client.d.ts +156 -0
  7. package/dist/core/client.js +87 -0
  8. package/dist/core/database.d.ts +124 -0
  9. package/dist/core/database.js +314 -0
  10. package/dist/core/envelope.d.ts +45 -0
  11. package/dist/core/envelope.js +112 -0
  12. package/dist/core/errors.d.ts +52 -0
  13. package/dist/core/errors.js +33 -0
  14. package/dist/core/index.d.ts +14 -0
  15. package/dist/core/index.js +13 -0
  16. package/dist/core/json-call.d.ts +4 -0
  17. package/dist/core/json-call.js +109 -0
  18. package/dist/core/json.d.ts +17 -0
  19. package/dist/core/json.js +4 -0
  20. package/dist/core/transport.d.ts +47 -0
  21. package/dist/core/transport.js +367 -0
  22. package/dist/core/validator.d.ts +52 -0
  23. package/dist/core/validator.js +1 -0
  24. package/dist/generate/cli.d.ts +44 -0
  25. package/dist/generate/cli.js +358 -0
  26. package/dist/generate/generate.d.ts +46 -0
  27. package/dist/generate/generate.js +215 -0
  28. package/dist/generate/identifiers.d.ts +27 -0
  29. package/dist/generate/identifiers.js +67 -0
  30. package/dist/generate/index.d.ts +15 -0
  31. package/dist/generate/index.js +15 -0
  32. package/dist/generate/manifest.d.ts +1 -0
  33. package/dist/generate/manifest.js +1 -0
  34. package/dist/generate/types.d.ts +57 -0
  35. package/dist/generate/types.js +357 -0
  36. package/dist/generated/catalog.d.ts +2 -0
  37. package/dist/generated/catalog.js +854 -0
  38. package/dist/generated/index.d.ts +2 -0
  39. package/dist/generated/index.js +4 -0
  40. package/dist/generated/manifest.json +799 -0
  41. package/dist/generated/operations.d.ts +8450 -0
  42. package/dist/generated/operations.js +3 -0
  43. package/dist/generated/schema.json +39451 -0
  44. package/dist/index.d.ts +11 -0
  45. package/dist/index.js +11 -0
  46. package/dist/json/index.d.ts +81 -0
  47. package/dist/json/index.js +404 -0
  48. package/dist/validation/index.d.ts +19 -0
  49. package/dist/validation/index.js +149 -0
  50. package/dist/validation/json.d.ts +1 -0
  51. package/dist/validation/json.js +1 -0
  52. package/dist/validation/pattern.d.ts +56 -0
  53. package/dist/validation/pattern.js +382 -0
  54. package/dist/validation/schema.d.ts +103 -0
  55. package/dist/validation/schema.js +1300 -0
  56. package/openapi.json +40754 -0
  57. package/package.json +93 -4
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@zocomputer/system-api` — the package root entry: the typed invocation client.
3
+ *
4
+ * Re-exports the dependency-free core surface (`createClient`, the catalog
5
+ * helpers, the client/envelope/transport/error types). This is the import the
6
+ * plan's examples use — `import { createClient } from "@zocomputer/system-api"`. The
7
+ * optional structural validator stays behind the separate
8
+ * `@zocomputer/system-api/validation` specifier, so a typed-only consumer that imports
9
+ * the root never pulls Ajv, decimal.js, jsonc-parser, or re2js into its bundle.
10
+ */
11
+ export * from "./core/index.ts";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@zocomputer/system-api` — the package root entry: the typed invocation client.
3
+ *
4
+ * Re-exports the dependency-free core surface (`createClient`, the catalog
5
+ * helpers, the client/envelope/transport/error types). This is the import the
6
+ * plan's examples use — `import { createClient } from "@zocomputer/system-api"`. The
7
+ * optional structural validator stays behind the separate
8
+ * `@zocomputer/system-api/validation` specifier, so a typed-only consumer that imports
9
+ * the root never pulls Ajv, decimal.js, jsonc-parser, or re2js into its bundle.
10
+ */
11
+ export * from "./core/index.js";
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Lossless, bounded JSON for the System API SDK — a faithful port of the Python
3
+ * `hostagent.system_api.json` module, so the client validates exactly what the
4
+ * server published.
5
+ *
6
+ * Every value that crosses the boundary is a {@link JsonValue}: `null`,
7
+ * `boolean`, `string`, a {@link JsonNumber} (an exact decimal, never a lossy
8
+ * JS `number`), an array, or a string-keyed object. Numbers are decoded from
9
+ * their JSON text with no binary-float round trip and canonicalized so equal
10
+ * numeric values share one representation: integral values (`1`, `1.0`, `1e0`)
11
+ * become integers; others become a normalized decimal with no trailing zeros.
12
+ *
13
+ * Parsing preserves duplicate-key, non-finite, unpaired-surrogate, oversized-
14
+ * number and bound violations as {@link JsonError} with a bounded `code` and the
15
+ * instance `path`; messages never echo the offending value.
16
+ */
17
+ import { Decimal } from "decimal.js";
18
+ export declare const MAX_JSON_BYTES: number;
19
+ export declare const MAX_JSON_NODES = 10000;
20
+ export declare const MAX_JSON_DEPTH = 32;
21
+ export declare const MAX_SERIALIZE_DEPTH = 256;
22
+ export declare const MAX_NUMBER_DIGITS = 100;
23
+ export declare const MAX_NUMBER_EXPONENT = 100;
24
+ /**
25
+ * A canonical JSON number: an exact decimal plus whether it is integral. Kept
26
+ * distinct from a JS `number` so no value ever passes through a lossy double.
27
+ */
28
+ export declare class JsonNumber {
29
+ readonly decimal: Decimal;
30
+ readonly isInteger: boolean;
31
+ /** Canonical JSON text (plain notation, no exponent, no trailing zeros). */
32
+ readonly text: string;
33
+ constructor(decimal: Decimal, isInteger: boolean,
34
+ /** Canonical JSON text (plain notation, no exponent, no trailing zeros). */
35
+ text: string);
36
+ }
37
+ export type JsonScalar = null | boolean | string | JsonNumber;
38
+ export type JsonValue = JsonScalar | JsonValue[] | {
39
+ [key: string]: JsonValue;
40
+ };
41
+ export type JsonObject = {
42
+ [key: string]: JsonValue;
43
+ };
44
+ export type JsonPath = ReadonlyArray<string | number>;
45
+ /** A value is not bounded, finite, lossless JSON. `code` is a bounded identifier. */
46
+ export declare class JsonError extends Error {
47
+ readonly code: string;
48
+ readonly path: JsonPath;
49
+ constructor(code: string, message: string, path?: JsonPath);
50
+ }
51
+ export declare function formatPointer(path: JsonPath): string;
52
+ export declare function splitPointer(pointer: string): string[];
53
+ /** Canonicalize a numeric token from its exact JSON lexeme. */
54
+ export declare function canonicalNumberFromToken(token: string, path?: JsonPath): JsonNumber;
55
+ /** Render a canonical number as JSON number text (plain notation, no exponent). */
56
+ export declare function formatNumber(value: JsonNumber): string;
57
+ /**
58
+ * Parse JSON text losslessly, preserving the exact lexeme of every number and
59
+ * rejecting duplicate keys, `NaN`/`Infinity`, unpaired surrogates, oversized
60
+ * numbers and anything beyond the byte / node / depth bounds.
61
+ */
62
+ export declare function loadsJson(data: string | Uint8Array, options?: {
63
+ maxBytes?: number;
64
+ maxNodes?: number | null;
65
+ maxDepth?: number;
66
+ }): JsonValue;
67
+ export declare function canonicalizeJson(value: JsonValue, options?: {
68
+ maxNodes?: number | null;
69
+ maxDepth?: number;
70
+ }): JsonValue;
71
+ export declare function checkJsonValue(value: JsonValue, options?: {
72
+ maxNodes?: number | null;
73
+ maxDepth?: number;
74
+ }): void;
75
+ /** Return `[nodes, depth]` of a JSON tree without copying it. */
76
+ export declare function measureJson(value: JsonValue, maxDepth?: number): [number, number];
77
+ /** Serialize deterministically: sorted keys (code-unit order), no whitespace. */
78
+ export declare function canonicalJsonString(value: JsonValue): string;
79
+ /** Serialize keeping key order (the wire form the validator returns on success). */
80
+ export declare function dumpsJson(value: JsonValue): string;
81
+ export declare function canonicalJsonBytes(value: JsonValue): Uint8Array;
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Lossless, bounded JSON for the System API SDK — a faithful port of the Python
3
+ * `hostagent.system_api.json` module, so the client validates exactly what the
4
+ * server published.
5
+ *
6
+ * Every value that crosses the boundary is a {@link JsonValue}: `null`,
7
+ * `boolean`, `string`, a {@link JsonNumber} (an exact decimal, never a lossy
8
+ * JS `number`), an array, or a string-keyed object. Numbers are decoded from
9
+ * their JSON text with no binary-float round trip and canonicalized so equal
10
+ * numeric values share one representation: integral values (`1`, `1.0`, `1e0`)
11
+ * become integers; others become a normalized decimal with no trailing zeros.
12
+ *
13
+ * Parsing preserves duplicate-key, non-finite, unpaired-surrogate, oversized-
14
+ * number and bound violations as {@link JsonError} with a bounded `code` and the
15
+ * instance `path`; messages never echo the offending value.
16
+ */
17
+ import { Decimal } from "decimal.js";
18
+ import { parseTree } from "jsonc-parser";
19
+ /** Exact decimal arithmetic for bounded numbers; matches the Python precision. */
20
+ const Dec = Decimal.clone({ precision: 400 });
21
+ export const MAX_JSON_BYTES = 16 * 1024 * 1024;
22
+ export const MAX_JSON_NODES = 10_000;
23
+ export const MAX_JSON_DEPTH = 32;
24
+ export const MAX_SERIALIZE_DEPTH = 256;
25
+ export const MAX_NUMBER_DIGITS = 100;
26
+ export const MAX_NUMBER_EXPONENT = 100;
27
+ const NUMBER_TOKEN_MAX_LENGTH = MAX_NUMBER_DIGITS + MAX_NUMBER_EXPONENT + 8;
28
+ const INT_TOKEN = /^-?(?:0|[1-9][0-9]*)$/;
29
+ /**
30
+ * A canonical JSON number: an exact decimal plus whether it is integral. Kept
31
+ * distinct from a JS `number` so no value ever passes through a lossy double.
32
+ */
33
+ export class JsonNumber {
34
+ decimal;
35
+ isInteger;
36
+ text;
37
+ constructor(decimal, isInteger,
38
+ /** Canonical JSON text (plain notation, no exponent, no trailing zeros). */
39
+ text) {
40
+ this.decimal = decimal;
41
+ this.isInteger = isInteger;
42
+ this.text = text;
43
+ }
44
+ }
45
+ /** A value is not bounded, finite, lossless JSON. `code` is a bounded identifier. */
46
+ export class JsonError extends Error {
47
+ code;
48
+ path;
49
+ constructor(code, message, path = []) {
50
+ super(path.length > 0 ? `${message} at ${formatPointer(path)}` : message);
51
+ this.code = code;
52
+ this.path = path;
53
+ this.name = "JsonError";
54
+ }
55
+ }
56
+ // --- JSON Pointer (RFC 6901) -------------------------------------------------
57
+ function escapeToken(token) {
58
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
59
+ }
60
+ export function formatPointer(path) {
61
+ return path.map((token) => "/" + escapeToken(String(token))).join("");
62
+ }
63
+ function unescapeToken(token) {
64
+ let out = "";
65
+ let i = 0;
66
+ while (i < token.length) {
67
+ const char = token[i];
68
+ if (char === "~") {
69
+ const next = i + 1 < token.length ? token[i + 1] : "";
70
+ if (next === "0")
71
+ out += "~";
72
+ else if (next === "1")
73
+ out += "/";
74
+ else
75
+ throw new JsonError("invalid_pointer", "pointer has an invalid escape sequence");
76
+ i += 2;
77
+ }
78
+ else {
79
+ out += char;
80
+ i += 1;
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+ export function splitPointer(pointer) {
86
+ if (pointer === "")
87
+ return [];
88
+ if (!pointer.startsWith("/"))
89
+ throw new JsonError("invalid_pointer", "pointer must be empty or start with '/'");
90
+ return pointer
91
+ .split("/")
92
+ .slice(1)
93
+ .map((token) => unescapeToken(token));
94
+ }
95
+ // --- number canonicalization -------------------------------------------------
96
+ /** Count significant digits (trailing zeros excluded), matching Python normalize. */
97
+ function significantDigits(value) {
98
+ return value.precision(false);
99
+ }
100
+ /**
101
+ * Canonicalize a decimal value the way Python `canonical_number` does. Integral
102
+ * values become integers (re-checked against the digit rule); others keep a
103
+ * normalized decimal. Raises `number_too_large` past the digit/exponent bounds.
104
+ */
105
+ function canonicalizeDecimal(value, path) {
106
+ if (!value.isFinite())
107
+ throw new JsonError("nonfinite_number", "number is not finite", path);
108
+ const normalized = value; // Decimal already carries no representational noise.
109
+ const sig = significantDigits(normalized);
110
+ if (sig > MAX_NUMBER_DIGITS)
111
+ throw new JsonError("number_too_large", "number exceeds the supported digit count", path);
112
+ if (normalized.isZero())
113
+ return new JsonNumber(new Dec(0), true, "0");
114
+ const adjusted = normalized.e;
115
+ if (Math.abs(adjusted) > MAX_NUMBER_EXPONENT)
116
+ throw new JsonError("number_too_large", "number exceeds the supported exponent range", path);
117
+ const tupleExponent = adjusted - (sig - 1);
118
+ if (tupleExponent >= 0) {
119
+ // Integral: re-check through the integer digit rule (1e100 → 101 digits).
120
+ const intDigits = adjusted + 1;
121
+ if (intDigits > MAX_NUMBER_DIGITS)
122
+ throw new JsonError("number_too_large", "integer exceeds the supported digit count", path);
123
+ return new JsonNumber(normalized, true, normalized.toFixed(0));
124
+ }
125
+ return new JsonNumber(normalized, false, normalized.toFixed());
126
+ }
127
+ /** Canonicalize a numeric token from its exact JSON lexeme. */
128
+ export function canonicalNumberFromToken(token, path = []) {
129
+ if (INT_TOKEN.test(token)) {
130
+ const digits = token.replace("-", "");
131
+ if (digits.length > MAX_NUMBER_DIGITS)
132
+ throw new JsonError("number_too_large", "integer exceeds the supported digit count", path);
133
+ const value = new Dec(token);
134
+ return new JsonNumber(value, true, value.toFixed(0));
135
+ }
136
+ if (token.length > NUMBER_TOKEN_MAX_LENGTH)
137
+ throw new JsonError("number_too_large", "number exceeds the supported digit count", path);
138
+ let value;
139
+ try {
140
+ value = new Dec(token);
141
+ }
142
+ catch {
143
+ throw new JsonError("not_json", "document is not valid JSON", path);
144
+ }
145
+ return canonicalizeDecimal(value, path);
146
+ }
147
+ /** Render a canonical number as JSON number text (plain notation, no exponent). */
148
+ export function formatNumber(value) {
149
+ return value.text;
150
+ }
151
+ // --- string checks -----------------------------------------------------------
152
+ /** True when the string contains an unpaired UTF-16 surrogate. */
153
+ function hasLoneSurrogate(value) {
154
+ for (let i = 0; i < value.length; i++) {
155
+ const code = value.charCodeAt(i);
156
+ if (code >= 0xd800 && code <= 0xdbff) {
157
+ const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
158
+ if (next < 0xdc00 || next > 0xdfff)
159
+ return true;
160
+ i++;
161
+ }
162
+ else if (code >= 0xdc00 && code <= 0xdfff) {
163
+ return true;
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+ function checkString(value, path) {
169
+ if (hasLoneSurrogate(value))
170
+ throw new JsonError("invalid_string", "string contains an unpaired surrogate", path);
171
+ return value;
172
+ }
173
+ // --- bounded walk (node + depth budget) --------------------------------------
174
+ class Walk {
175
+ maxNodes;
176
+ nodes = 0;
177
+ maxDepth;
178
+ constructor(maxNodes, maxDepth) {
179
+ this.maxNodes = maxNodes;
180
+ this.maxDepth = Math.min(maxDepth, MAX_SERIALIZE_DEPTH);
181
+ }
182
+ visit(path, depth) {
183
+ if (depth > this.maxDepth)
184
+ throw new JsonError("too_deep", "value nesting exceeds the supported depth", path);
185
+ this.nodes += 1;
186
+ if (this.maxNodes !== null && this.nodes > this.maxNodes)
187
+ throw new JsonError("too_many_nodes", "value exceeds the supported node count", path);
188
+ }
189
+ }
190
+ // --- parse (jsonc-parser tree → canonical JsonValue) -------------------------
191
+ function buildValue(node, path, depth, walk) {
192
+ walk.visit(path, depth);
193
+ switch (node.type) {
194
+ case "null":
195
+ return null;
196
+ case "boolean":
197
+ return node.value;
198
+ case "string":
199
+ return checkString(node.value, path);
200
+ case "number":
201
+ return canonicalNumberFromToken(node.offsetText ?? String(node.value), path);
202
+ case "array": {
203
+ const out = [];
204
+ const children = node.children ?? [];
205
+ for (let i = 0; i < children.length; i++) {
206
+ out.push(buildValue(children[i], [...path, i], depth + 1, walk));
207
+ }
208
+ return out;
209
+ }
210
+ case "object": {
211
+ // Null-prototype record: assigning a `__proto__` key must create an own
212
+ // data property (not invoke the inherited setter), so the literal is
213
+ // preserved and a duplicate `__proto__` is caught by the hasOwn check.
214
+ const out = Object.create(null);
215
+ const children = node.children ?? [];
216
+ for (const prop of children) {
217
+ const keyNode = prop.children?.[0];
218
+ const valNode = prop.children?.[1];
219
+ if (keyNode === undefined || valNode === undefined)
220
+ throw new JsonError("not_json", "document is not valid JSON", path);
221
+ const key = keyNode.value;
222
+ if (Object.prototype.hasOwnProperty.call(out, key))
223
+ throw new JsonError("duplicate_key", "object has a duplicate key", path);
224
+ out[checkString(key, path)] = buildValue(valNode, [...path, key], depth + 1, walk);
225
+ }
226
+ return out;
227
+ }
228
+ default:
229
+ throw new JsonError("not_json", "document is not valid JSON", path);
230
+ }
231
+ }
232
+ /**
233
+ * Parse JSON text losslessly, preserving the exact lexeme of every number and
234
+ * rejecting duplicate keys, `NaN`/`Infinity`, unpaired surrogates, oversized
235
+ * numbers and anything beyond the byte / node / depth bounds.
236
+ */
237
+ export function loadsJson(data, options = {}) {
238
+ const maxBytes = options.maxBytes ?? MAX_JSON_BYTES;
239
+ const maxNodes = options.maxNodes === undefined ? MAX_JSON_NODES : options.maxNodes;
240
+ const maxDepth = options.maxDepth ?? MAX_JSON_DEPTH;
241
+ let text;
242
+ if (typeof data === "string") {
243
+ if (new TextEncoder().encode(data).length > maxBytes)
244
+ throw new JsonError("too_large", "document exceeds the supported size");
245
+ text = data;
246
+ }
247
+ else {
248
+ if (data.length > maxBytes)
249
+ throw new JsonError("too_large", "document exceeds the supported size");
250
+ try {
251
+ text = new TextDecoder("utf-8", { fatal: true }).decode(data);
252
+ }
253
+ catch {
254
+ throw new JsonError("invalid_string", "document is not valid UTF-8");
255
+ }
256
+ }
257
+ const errors = [];
258
+ const root = parseTree(text, errors, {
259
+ disallowComments: true,
260
+ allowTrailingComma: false,
261
+ allowEmptyContent: false,
262
+ });
263
+ if (errors.length > 0 || root === undefined) {
264
+ for (const error of errors) {
265
+ const fragment = text.slice(error.offset, error.offset + 12).trimStart();
266
+ if (/^-?(NaN|Infinity)/.test(fragment))
267
+ throw new JsonError("nonfinite_number", "NaN and Infinity are not JSON numbers");
268
+ }
269
+ throw new JsonError("not_json", "document is not valid JSON");
270
+ }
271
+ return buildValue(attachLexemes(root, text), [], 1, new Walk(maxNodes, maxDepth));
272
+ }
273
+ /** Attach each number node's exact source lexeme (jsonc-parser drops precision). */
274
+ function attachLexemes(node, text) {
275
+ if (node.type === "number") {
276
+ node.offsetText = text.slice(node.offset, node.offset + node.length);
277
+ }
278
+ for (const child of node.children ?? [])
279
+ attachLexemes(child, text);
280
+ return node;
281
+ }
282
+ // --- canonicalization / measurement of an existing JsonValue tree ------------
283
+ function canonicalizeValue(value, path, depth, walk) {
284
+ walk.visit(path, depth);
285
+ if (value === null || typeof value === "boolean")
286
+ return value;
287
+ if (typeof value === "string")
288
+ return checkString(value, path);
289
+ if (value instanceof JsonNumber)
290
+ return canonicalizeDecimal(value.decimal, path);
291
+ if (Array.isArray(value))
292
+ return value.map((item, index) => canonicalizeValue(item, [...path, index], depth + 1, walk));
293
+ // Null-prototype record so a `__proto__` key survives canonicalization as an
294
+ // own data property rather than mutating the prototype chain.
295
+ const out = Object.create(null);
296
+ for (const key of Object.keys(value))
297
+ out[checkString(key, path)] = canonicalizeValue(value[key], [...path, key], depth + 1, walk);
298
+ return out;
299
+ }
300
+ export function canonicalizeJson(value, options = {}) {
301
+ const maxNodes = options.maxNodes === undefined ? MAX_JSON_NODES : options.maxNodes;
302
+ const maxDepth = options.maxDepth ?? MAX_JSON_DEPTH;
303
+ return canonicalizeValue(value, [], 1, new Walk(maxNodes, maxDepth));
304
+ }
305
+ export function checkJsonValue(value, options = {}) {
306
+ const maxNodes = options.maxNodes === undefined ? MAX_JSON_NODES : options.maxNodes;
307
+ const maxDepth = options.maxDepth ?? MAX_JSON_DEPTH;
308
+ canonicalizeValue(value, [], 1, new Walk(maxNodes, maxDepth));
309
+ }
310
+ /** Return `[nodes, depth]` of a JSON tree without copying it. */
311
+ export function measureJson(value, maxDepth = MAX_SERIALIZE_DEPTH) {
312
+ const walk = new Walk(null, maxDepth);
313
+ const depth = measure(value, [], 1, walk);
314
+ return [walk.nodes, depth];
315
+ }
316
+ function measure(value, path, depth, walk) {
317
+ walk.visit(path, depth);
318
+ if (Array.isArray(value)) {
319
+ let deepest = depth;
320
+ value.forEach((item, index) => {
321
+ deepest = Math.max(deepest, measure(item, [...path, index], depth + 1, walk));
322
+ });
323
+ return deepest;
324
+ }
325
+ if (value !== null &&
326
+ !(value instanceof JsonNumber) &&
327
+ typeof value === "object") {
328
+ let deepest = depth;
329
+ for (const key of Object.keys(value))
330
+ deepest = Math.max(deepest, measure(value[key], [...path, key], depth + 1, walk));
331
+ return deepest;
332
+ }
333
+ return depth;
334
+ }
335
+ // --- canonical serialization -------------------------------------------------
336
+ function encodeString(value) {
337
+ return JSON.stringify(value);
338
+ }
339
+ function writeValue(value, parts, sortKeys) {
340
+ if (value === null)
341
+ parts.push("null");
342
+ else if (value === true)
343
+ parts.push("true");
344
+ else if (value === false)
345
+ parts.push("false");
346
+ else if (typeof value === "string")
347
+ parts.push(encodeString(value));
348
+ else if (value instanceof JsonNumber)
349
+ parts.push(value.text);
350
+ else if (Array.isArray(value)) {
351
+ if (value.length === 0) {
352
+ parts.push("[]");
353
+ return;
354
+ }
355
+ parts.push("[");
356
+ value.forEach((item, index) => {
357
+ if (index)
358
+ parts.push(",");
359
+ writeValue(item, parts, sortKeys);
360
+ });
361
+ parts.push("]");
362
+ }
363
+ else {
364
+ const keys = Object.keys(value);
365
+ if (keys.length === 0) {
366
+ parts.push("{}");
367
+ return;
368
+ }
369
+ if (sortKeys)
370
+ keys.sort();
371
+ parts.push("{");
372
+ keys.forEach((key, index) => {
373
+ if (index)
374
+ parts.push(",");
375
+ parts.push(encodeString(key));
376
+ parts.push(":");
377
+ writeValue(value[key], parts, sortKeys);
378
+ });
379
+ parts.push("}");
380
+ }
381
+ }
382
+ /** Serialize deterministically: sorted keys (code-unit order), no whitespace. */
383
+ export function canonicalJsonString(value) {
384
+ const canonical = canonicalizeJson(value, {
385
+ maxNodes: null,
386
+ maxDepth: MAX_SERIALIZE_DEPTH,
387
+ });
388
+ const parts = [];
389
+ writeValue(canonical, parts, true);
390
+ return parts.join("");
391
+ }
392
+ /** Serialize keeping key order (the wire form the validator returns on success). */
393
+ export function dumpsJson(value) {
394
+ const canonical = canonicalizeJson(value, {
395
+ maxNodes: null,
396
+ maxDepth: MAX_SERIALIZE_DEPTH,
397
+ });
398
+ const parts = [];
399
+ writeValue(canonical, parts, false);
400
+ return parts.join("");
401
+ }
402
+ export function canonicalJsonBytes(value) {
403
+ return new TextEncoder().encode(canonicalJsonString(value));
404
+ }
@@ -0,0 +1,19 @@
1
+ import type { InvocationValidator } from "../core/validator.ts";
2
+ export { CompiledSchema, SchemaDefinitionError, SchemaError, SchemaValidationError, compileSchema, } from "./schema.ts";
3
+ /** Thrown when the schema bundle handed to the factory is malformed. */
4
+ export declare class SchemaBundleError extends Error {
5
+ constructor(message: string, options?: {
6
+ cause?: unknown;
7
+ });
8
+ }
9
+ /** Thrown when a call resolves to no manifest entry or a disagreeing digest. */
10
+ export declare class ValidatorTargetError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ /**
14
+ * Build a structural {@link InvocationValidator} from the offline schema bundle
15
+ * bytes. The bundle is decoded and every action schema compiled up front, so
16
+ * both hooks are synchronous and allocation-light. A malformed bundle throws
17
+ * {@link SchemaBundleError} at construction; it never fails silently.
18
+ */
19
+ export declare function createSchemaValidator(schemaBundleBytes: Uint8Array): InvocationValidator;
@@ -0,0 +1,149 @@
1
+ import { JsonNumber, MAX_JSON_DEPTH, loadsJson, } from "./json.js";
2
+ import { MAX_SCHEMA_DEPTH, compileSchema, } from "./schema.js";
3
+ export { CompiledSchema, SchemaDefinitionError, SchemaError, SchemaValidationError, compileSchema, } from "./schema.js";
4
+ /** The manifest wraps each bounded schema, so it nests a few levels deeper. */
5
+ const MAX_MANIFEST_DEPTH = MAX_SCHEMA_DEPTH + 8;
6
+ /** The only offline-manifest format this factory accepts (codegen emits `1`). */
7
+ const SUPPORTED_MANIFEST_FORMAT_VERSION = 1;
8
+ /** Action/contract versions are positive 32-bit integers, matching the contract. */
9
+ const MAX_CONTRACT_VERSION = 2_147_483_647;
10
+ /**
11
+ * The invocation envelope nests the validated `input`/`result` one level down,
12
+ * so it is parsed with the node bound disabled and only wrapper depth headroom;
13
+ * the selected value carries its own node/depth budget inside `CompiledSchema`.
14
+ * Mirrors the server's `MAX_JSON_DEPTH + ENVELOPE_DEPTH_HEADROOM`.
15
+ */
16
+ const ENVELOPE_DEPTH_HEADROOM = 2;
17
+ const MAX_ENVELOPE_DEPTH = MAX_JSON_DEPTH + ENVELOPE_DEPTH_HEADROOM;
18
+ /** Thrown when the schema bundle handed to the factory is malformed. */
19
+ export class SchemaBundleError extends Error {
20
+ constructor(message, options) {
21
+ super(message, options);
22
+ this.name = "SchemaBundleError";
23
+ }
24
+ }
25
+ /** Thrown when a call resolves to no manifest entry or a disagreeing digest. */
26
+ export class ValidatorTargetError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "ValidatorTargetError";
30
+ }
31
+ }
32
+ function isObject(value) {
33
+ return (value !== null &&
34
+ typeof value === "object" &&
35
+ !Array.isArray(value) &&
36
+ !(value instanceof JsonNumber));
37
+ }
38
+ function targetKey(action, version) {
39
+ return `${action}\u0000${version}`;
40
+ }
41
+ /**
42
+ * Build a structural {@link InvocationValidator} from the offline schema bundle
43
+ * bytes. The bundle is decoded and every action schema compiled up front, so
44
+ * both hooks are synchronous and allocation-light. A malformed bundle throws
45
+ * {@link SchemaBundleError} at construction; it never fails silently.
46
+ */
47
+ export function createSchemaValidator(schemaBundleBytes) {
48
+ let manifest;
49
+ try {
50
+ manifest = loadsJson(schemaBundleBytes, {
51
+ maxNodes: null,
52
+ maxDepth: MAX_MANIFEST_DEPTH,
53
+ });
54
+ }
55
+ catch (error) {
56
+ throw new SchemaBundleError("schema bundle is not valid JSON", {
57
+ cause: error,
58
+ });
59
+ }
60
+ if (!isObject(manifest))
61
+ throw new SchemaBundleError("schema bundle must be a JSON object");
62
+ // Refuse any bundle that does not declare the supported format, so an unknown
63
+ // future or foreign shape can never silently claim validation. Extra
64
+ // top-level metadata fields (an authenticated catalog export's
65
+ // `protocol_version`, `view_revision`, `selection`, `default_versions`,
66
+ // `availability`, `catalog_digest`, …) are ignored, not rejected.
67
+ const formatVersion = manifest["format_version"];
68
+ if (!(formatVersion instanceof JsonNumber) ||
69
+ !formatVersion.isInteger ||
70
+ Number(formatVersion.text) !== SUPPORTED_MANIFEST_FORMAT_VERSION)
71
+ throw new SchemaBundleError(`schema bundle format is unsupported (expected format_version ${SUPPORTED_MANIFEST_FORMAT_VERSION})`);
72
+ const rawActions = manifest["actions"];
73
+ if (!Array.isArray(rawActions))
74
+ throw new SchemaBundleError("schema bundle is missing an 'actions' array");
75
+ const index = new Map();
76
+ for (const raw of rawActions) {
77
+ if (!isObject(raw))
78
+ throw new SchemaBundleError("each manifest action must be an object");
79
+ const name = raw["name"];
80
+ const version = raw["version"];
81
+ const digest = raw["digest"];
82
+ const inputSchema = raw["input_schema"];
83
+ const outputSchema = raw["output_schema"];
84
+ if (typeof name !== "string" || name.length === 0)
85
+ throw new SchemaBundleError("a manifest action is missing its name");
86
+ if (!(version instanceof JsonNumber && version.isInteger))
87
+ throw new SchemaBundleError(`manifest action '${name}' has a non-integer version`);
88
+ const versionValue = Number(version.text);
89
+ if (versionValue < 1 || versionValue > MAX_CONTRACT_VERSION)
90
+ throw new SchemaBundleError(`manifest action '${name}' has an out-of-range version`);
91
+ if (typeof digest !== "string" || digest.length === 0)
92
+ throw new SchemaBundleError(`manifest action '${name}' is missing its digest`);
93
+ if (!isObject(inputSchema) || !isObject(outputSchema))
94
+ throw new SchemaBundleError(`manifest action '${name}' is missing an input or output schema`);
95
+ let input;
96
+ let output;
97
+ try {
98
+ input = compileSchema(inputSchema);
99
+ output = compileSchema(outputSchema);
100
+ }
101
+ catch (error) {
102
+ throw new SchemaBundleError(`manifest action '${name}' has an uncompilable schema`, { cause: error });
103
+ }
104
+ const key = targetKey(name, versionValue);
105
+ // A duplicate (action, version) makes the schema source ambiguous — refuse
106
+ // rather than let a later entry silently overwrite an earlier one.
107
+ if (index.has(key))
108
+ throw new SchemaBundleError(`manifest has a duplicate entry for action '${name}' version ${versionValue}`);
109
+ index.set(key, { digest, input, output });
110
+ }
111
+ const resolve = (target) => {
112
+ const entry = index.get(targetKey(target.action, target.version));
113
+ if (entry === undefined)
114
+ throw new ValidatorTargetError(`no schema for action '${target.action}' version ${target.version}`);
115
+ if (entry.digest !== target.digest)
116
+ throw new ValidatorTargetError(`schema digest disagrees with the pinned catalog for action '${target.action}' version ${target.version}`);
117
+ return entry;
118
+ };
119
+ const field = (body, key) => {
120
+ if (!isObject(body) || !Object.hasOwn(body, key))
121
+ throw new SchemaBundleError(`invocation body is missing '${key}'`);
122
+ return body[key];
123
+ };
124
+ // Parse the wrapping envelope with the node bound disabled and only wrapper
125
+ // depth headroom; the selected `input`/`result` is budgeted inside
126
+ // `CompiledSchema.validate`, so a server-legal payload is never rejected as an
127
+ // envelope for the wrapper's own nodes.
128
+ const parseEnvelope = (text) => loadsJson(text, { maxNodes: null, maxDepth: MAX_ENVELOPE_DEPTH });
129
+ return {
130
+ validateRequestBody(target, exactBody) {
131
+ const entry = resolve(target);
132
+ // Validate the exact bytes the way the server canonicalizes input:
133
+ // defaults are filled before conformance is judged, so the hook accepts
134
+ // exactly the inputs the server accepts.
135
+ entry.input.validate(field(parseEnvelope(exactBody), "input"), {
136
+ applyDefaults: true,
137
+ });
138
+ },
139
+ validateSuccessBody(target, rawText, _success) {
140
+ const entry = resolve(target);
141
+ // Re-parse the raw bytes losslessly (the correlated `success` carries a
142
+ // precision-lossy `result`) and validate the result against the output
143
+ // schema without filling defaults, matching `validate_output`.
144
+ entry.output.validate(field(parseEnvelope(rawText), "result"), {
145
+ applyDefaults: false,
146
+ });
147
+ },
148
+ };
149
+ }
@@ -0,0 +1 @@
1
+ export * from "../json/index.ts";
@@ -0,0 +1 @@
1
+ export * from "../json/index.js";