@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,367 @@
1
+ /**
2
+ * Fetch transport for the single `/invoke` route.
3
+ *
4
+ * Browser-safe: uses `fetch` only, no Node built-ins. Invoke is never retried
5
+ * automatically — a disconnect, deadline, or malformed result after a mutation
6
+ * may mean the effect happened, so uncertainty is surfaced rather than replayed.
7
+ * The request is serialized, size-checked, and the token obtained and validated
8
+ * BEFORE any network call, so a pre-flight failure reports `outcome:
9
+ * "not_started"`; `network` is used solely for a `fetch` rejection. Response
10
+ * bodies are read under a byte cap before parsing, and redirects are refused so
11
+ * a bearer credential is never replayed to a redirected origin.
12
+ */
13
+ import { isErrorBody, parseSuccess, readRequestId, } from "./envelope.js";
14
+ import { SystemAPIError } from "./errors.js";
15
+ import { isJsonObject } from "./json.js";
16
+ /**
17
+ * The closed error wire is `{request_id, error:{code, message, details}}`, so a
18
+ * lifecycle outcome, when the server sends one, lives at `error.details.outcome`.
19
+ * Copy it onto the error only when it is exactly one of the two wire values;
20
+ * an absent, malformed, or unrecognized outcome stays unset. The HTTP status is
21
+ * never used to infer it — a status does not tell us whether the action ran.
22
+ */
23
+ function outcomeFromDetails(details) {
24
+ if (!isJsonObject(details))
25
+ return undefined;
26
+ const outcome = details["outcome"];
27
+ return outcome === "not_started" || outcome === "unknown"
28
+ ? outcome
29
+ : undefined;
30
+ }
31
+ export const DEFAULT_BASE_URL = "https://api.zo.computer/zo-system-api/v1";
32
+ /** Plan inline ceiling for a result payload: 1 MiB. */
33
+ const MAX_INLINE_BYTES = 1024 * 1024;
34
+ /**
35
+ * Total serialized request-body cap: 5 MiB. Inline file content may be up to
36
+ * 4 MiB of UTF-8, but the whole body as sent must fit here: JSON escaping
37
+ * (quotes, backslashes, control characters) counts, so heavily escaped
38
+ * content may need a prepared upload.
39
+ */
40
+ export const MAX_REQUEST_BYTES = 5 * 1024 * 1024;
41
+ /**
42
+ * The response may carry a full 1 MiB inline result plus envelope framing
43
+ * (request_id, action, options, JSON punctuation), so its cap reserves overhead
44
+ * above the inline ceiling — otherwise a valid near-limit result is rejected.
45
+ */
46
+ const FRAMING_OVERHEAD_BYTES = 64 * 1024;
47
+ export const MAX_RESPONSE_BYTES = MAX_INLINE_BYTES + FRAMING_OVERHEAD_BYTES;
48
+ /** Upper bound on an honored `Retry-After`, so a hostile header can't stall a caller. */
49
+ const MAX_RETRY_AFTER_SECONDS = 3600;
50
+ /** Header-safe token characters are visible ASCII, 0x21 ('!') to 0x7e ('~'). */
51
+ const MIN_VISIBLE_ASCII = 0x21;
52
+ const MAX_VISIBLE_ASCII = 0x7e;
53
+ function byteLength(text) {
54
+ return new TextEncoder().encode(text).byteLength;
55
+ }
56
+ function normalizeBaseUrl(baseUrl) {
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(baseUrl);
60
+ }
61
+ catch {
62
+ throw new SystemAPIError({
63
+ code: "invalid_envelope",
64
+ message: `invalid baseUrl: ${baseUrl}`,
65
+ outcome: "not_started",
66
+ });
67
+ }
68
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
69
+ throw new SystemAPIError({
70
+ code: "invalid_envelope",
71
+ message: `unsupported baseUrl protocol: ${parsed.protocol}`,
72
+ outcome: "not_started",
73
+ });
74
+ }
75
+ return parsed.href.replace(/\/+$/, "");
76
+ }
77
+ /**
78
+ * Validate a bearer token before it reaches a header. The runtime type is
79
+ * checked (a provider may return a non-string through untyped JavaScript), and
80
+ * the token must be non-empty header-safe visible ASCII — control, whitespace
81
+ * (ASCII or Unicode such as U+00A0/U+0085), and any character a header builder
82
+ * could reject are refused here, before any request is sent, so they surface as
83
+ * `unauthenticated`/`not_started` rather than a raw error or a misfiled network
84
+ * failure.
85
+ */
86
+ function validateToken(token) {
87
+ if (typeof token !== "string") {
88
+ throw new SystemAPIError({
89
+ code: "unauthenticated",
90
+ message: "the access token provider did not return a string",
91
+ outcome: "not_started",
92
+ });
93
+ }
94
+ if (token.length === 0) {
95
+ throw new SystemAPIError({
96
+ code: "unauthenticated",
97
+ message: "access token is empty",
98
+ outcome: "not_started",
99
+ });
100
+ }
101
+ for (let i = 0; i < token.length; i++) {
102
+ const code = token.charCodeAt(i);
103
+ if (code < MIN_VISIBLE_ASCII || code > MAX_VISIBLE_ASCII) {
104
+ throw new SystemAPIError({
105
+ code: "unauthenticated",
106
+ message: "access token contains characters that are not header-safe",
107
+ outcome: "not_started",
108
+ });
109
+ }
110
+ }
111
+ return token;
112
+ }
113
+ function parseRetryAfter(header) {
114
+ if (header === null)
115
+ return undefined;
116
+ const trimmed = header.trim();
117
+ if (/^\d+$/.test(trimmed)) {
118
+ return Math.min(Number(trimmed), MAX_RETRY_AFTER_SECONDS);
119
+ }
120
+ const when = Date.parse(trimmed);
121
+ if (Number.isNaN(when))
122
+ return undefined;
123
+ const seconds = Math.ceil((when - Date.now()) / 1000);
124
+ if (seconds <= 0)
125
+ return 0;
126
+ return Math.min(seconds, MAX_RETRY_AFTER_SECONDS);
127
+ }
128
+ async function readBoundedText(response, maxBytes) {
129
+ const stream = response.body;
130
+ if (stream === null) {
131
+ // An injected fetch (tests) may not expose a stream; fall back to text().
132
+ const text = await response.text();
133
+ if (byteLength(text) > maxBytes)
134
+ throwOversized(response.status);
135
+ return text;
136
+ }
137
+ const reader = stream.getReader();
138
+ const chunks = [];
139
+ let total = 0;
140
+ for (;;) {
141
+ const { done, value } = await reader.read();
142
+ if (done)
143
+ break;
144
+ total += value.byteLength;
145
+ if (total > maxBytes) {
146
+ await reader.cancel();
147
+ throwOversized(response.status);
148
+ }
149
+ chunks.push(value);
150
+ }
151
+ const joined = new Uint8Array(total);
152
+ let offset = 0;
153
+ for (const chunk of chunks) {
154
+ joined.set(chunk, offset);
155
+ offset += chunk.byteLength;
156
+ }
157
+ // Strict UTF-8: invalid bytes are rejected (parity with Python's strict
158
+ // decode), never silently replaced with U+FFFD. This is the single place the
159
+ // response body is decoded, so an optional validator sees the exact same text.
160
+ try {
161
+ return new TextDecoder("utf-8", { fatal: true }).decode(joined);
162
+ }
163
+ catch (cause) {
164
+ throw new SystemAPIError({
165
+ code: "invalid_response",
166
+ message: "response body was not valid UTF-8",
167
+ httpStatus: response.status,
168
+ outcome: "unknown",
169
+ cause,
170
+ });
171
+ }
172
+ }
173
+ function throwOversized(httpStatus) {
174
+ // Client-side protective cap: the response exceeded the inline result budget,
175
+ // so we could not read it. The request was sent, so the effect is uncertain;
176
+ // the HTTP response did arrive, so its status is carried on the error.
177
+ throw new SystemAPIError({
178
+ code: "output_limit_exceeded",
179
+ message: "response exceeded the inline result size limit",
180
+ httpStatus,
181
+ outcome: "unknown",
182
+ });
183
+ }
184
+ export function createTransport(config) {
185
+ const validator = config.validator;
186
+ const doFetch = config.fetch ?? globalThis.fetch;
187
+ if (typeof doFetch !== "function") {
188
+ throw new SystemAPIError({
189
+ code: "invalid_envelope",
190
+ message: "no fetch implementation available",
191
+ outcome: "not_started",
192
+ });
193
+ }
194
+ const baseUrl = normalizeBaseUrl(config.baseUrl ?? DEFAULT_BASE_URL);
195
+ const workspaceId = config.workspaceId;
196
+ if (workspaceId !== undefined &&
197
+ (typeof workspaceId !== "string" ||
198
+ !/^[\x21-\x7e]{1,200}$/.test(workspaceId))) {
199
+ throw new SystemAPIError({
200
+ code: "invalid_envelope",
201
+ message: "workspaceId must contain 1 to 200 header-safe characters",
202
+ outcome: "not_started",
203
+ });
204
+ }
205
+ async function invokeText(requestBody, sent, target, decodeBody = JSON.parse) {
206
+ if (byteLength(requestBody) > MAX_REQUEST_BYTES) {
207
+ throw new SystemAPIError({
208
+ code: "payload_too_large",
209
+ message: "request body exceeds the inline size limit",
210
+ outcome: "not_started",
211
+ });
212
+ }
213
+ // Obtain and validate the token before fetch; a failure here never sent.
214
+ let rawToken;
215
+ try {
216
+ rawToken = await config.accessToken();
217
+ }
218
+ catch (cause) {
219
+ throw new SystemAPIError({
220
+ code: "unauthenticated",
221
+ message: "the access token provider failed",
222
+ outcome: "not_started",
223
+ cause,
224
+ });
225
+ }
226
+ const token = validateToken(rawToken);
227
+ // Optional request validation on the EXACT bytes about to be sent, still
228
+ // before any network call. A throw here — including a SystemAPIError with
229
+ // misreported lifecycle fields — is normalized: the request was not sent,
230
+ // so core sets not_started with no httpStatus and keeps the throw as cause.
231
+ if (validator !== undefined) {
232
+ try {
233
+ validator.validateRequestBody(target, requestBody);
234
+ }
235
+ catch (cause) {
236
+ throw new SystemAPIError({
237
+ code: "invalid_input",
238
+ message: "request body failed client validation before sending",
239
+ outcome: "not_started",
240
+ cause,
241
+ });
242
+ }
243
+ }
244
+ let response;
245
+ try {
246
+ response = await doFetch(`${baseUrl}/invoke`, {
247
+ method: "POST",
248
+ headers: {
249
+ "content-type": "application/json",
250
+ authorization: `Bearer ${token}`,
251
+ ...(workspaceId !== undefined
252
+ ? { "Zo-Workspace-Id": workspaceId }
253
+ : {}),
254
+ },
255
+ body: requestBody,
256
+ // Refuse redirects: never replay the bearer token to a redirected origin.
257
+ redirect: "error",
258
+ });
259
+ }
260
+ catch (cause) {
261
+ // No HTTP response was produced. httpStatus is absent; the effect is
262
+ // uncertain because we cannot know whether the request reached the server.
263
+ throw new SystemAPIError({
264
+ code: "network",
265
+ message: "request failed before a response was received",
266
+ outcome: "unknown",
267
+ cause,
268
+ });
269
+ }
270
+ let text;
271
+ try {
272
+ text = await readBoundedText(response, MAX_RESPONSE_BYTES);
273
+ }
274
+ catch (cause) {
275
+ // Our own protective cap re-throws as SystemAPIError; anything else is a
276
+ // failed body read after a response arrived — a malformed protocol result.
277
+ if (cause instanceof SystemAPIError)
278
+ throw cause;
279
+ throw new SystemAPIError({
280
+ code: "invalid_response",
281
+ message: "response body could not be read",
282
+ httpStatus: response.status,
283
+ outcome: "unknown",
284
+ cause,
285
+ });
286
+ }
287
+ let body;
288
+ try {
289
+ body = text.length === 0 ? undefined : decodeBody(text);
290
+ }
291
+ catch {
292
+ throw new SystemAPIError({
293
+ code: "invalid_response",
294
+ message: "response body was not valid JSON",
295
+ httpStatus: response.status,
296
+ outcome: "unknown",
297
+ });
298
+ }
299
+ if (!response.ok) {
300
+ const retryAfterSeconds = parseRetryAfter(response.headers.get("retry-after"));
301
+ if (isErrorBody(body)) {
302
+ // Preserve the server's well-formed error code verbatim, including
303
+ // custom action-declared failures we do not enumerate.
304
+ throw new SystemAPIError({
305
+ code: body.error.code,
306
+ message: body.error.message,
307
+ requestId: body.request_id,
308
+ httpStatus: response.status,
309
+ details: body.error.details,
310
+ outcome: outcomeFromDetails(body.error.details),
311
+ retryAfterSeconds,
312
+ });
313
+ }
314
+ // Non-2xx without a well-formed error envelope: malformed protocol response.
315
+ throw new SystemAPIError({
316
+ code: "invalid_response",
317
+ message: `received status ${response.status} without a valid error envelope`,
318
+ requestId: readRequestId(body),
319
+ httpStatus: response.status,
320
+ retryAfterSeconds,
321
+ outcome: "unknown",
322
+ });
323
+ }
324
+ // Validate and correlate the success envelope FIRST: a malformed or
325
+ // miscorrelated 2xx envelope must surface as invalid_response, never as a
326
+ // schema error. Only a trusted InvocationSuccess reaches output validation.
327
+ const success = parseSuccess(body, sent, response.status);
328
+ // Optional output validation on the exact response text plus the validated
329
+ // envelope. The action already executed, so a throw — again including a
330
+ // SystemAPIError with wrong lifecycle fields — is normalized to unknown
331
+ // with the response status and NO retry; the throw is kept as cause.
332
+ if (validator !== undefined) {
333
+ try {
334
+ validator.validateSuccessBody(target, text, success);
335
+ }
336
+ catch (cause) {
337
+ throw new SystemAPIError({
338
+ code: "invalid_response",
339
+ message: "response body failed client validation",
340
+ requestId: success.request_id,
341
+ httpStatus: response.status,
342
+ outcome: "unknown",
343
+ cause,
344
+ });
345
+ }
346
+ }
347
+ return { success, text, httpStatus: response.status };
348
+ }
349
+ return {
350
+ invokeText,
351
+ async invoke(envelope, target) {
352
+ let requestBody;
353
+ try {
354
+ requestBody = JSON.stringify(envelope);
355
+ }
356
+ catch (cause) {
357
+ throw new SystemAPIError({
358
+ code: "invalid_envelope",
359
+ message: "request envelope could not be serialized",
360
+ outcome: "not_started",
361
+ cause,
362
+ });
363
+ }
364
+ return (await invokeText(requestBody, envelope, target)).success;
365
+ },
366
+ };
367
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The optional validation seam.
3
+ *
4
+ * `createClient` accepts an optional, structural `InvocationValidator`. When one
5
+ * is injected, the single transport calls its two synchronous hooks on the exact
6
+ * request/response bytes — the core owns serialize / size / token / fetch /
7
+ * bounded fatal decode / `parseSuccess` exactly once, and the hooks import
8
+ * nothing from a validator's heavy dependencies. A typed-only consumer that
9
+ * injects no validator bundles none of that.
10
+ *
11
+ * These are TYPES ONLY. The heavy factory that implements this interface
12
+ * (`createSchemaValidator`) lives behind the separate `@zocomputer/system-api/validation`
13
+ * entry so it never enters a typed-only bundle.
14
+ */
15
+ import type { InvocationSuccess } from "./envelope.ts";
16
+ /**
17
+ * What the client already resolved for a call: the action, its concrete integer
18
+ * `version` (matching `InvocationOptions.version`), and that version's catalog
19
+ * `VersionEntry.digest`. A validator resolves its schema bundle by this exact
20
+ * contract, so a payload is never validated against a schema whose digest
21
+ * disagrees with the pinned catalog entry.
22
+ */
23
+ export interface InvocationTarget {
24
+ readonly action: string;
25
+ readonly version: number;
26
+ readonly digest: string;
27
+ }
28
+ /**
29
+ * An optional structural validator. Both hooks are synchronous and signal a
30
+ * failure by throwing; the transport normalizes every throw (see
31
+ * `createTransport`) into a `SystemAPIError` whose lifecycle fields
32
+ * (`code`/`httpStatus`/`outcome`/`retryAfterSeconds`) are always set by the
33
+ * transport, never trusted from the hook — the original throw is preserved only
34
+ * as `cause`. A hook that returns normally accepts the payload.
35
+ */
36
+ export interface InvocationValidator {
37
+ /**
38
+ * Validate the exact request body bytes before they are sent. `exactBody` is
39
+ * the precise string the transport will send (never a re-serialization).
40
+ * Throwing here fails the call before any network request.
41
+ */
42
+ validateRequestBody(target: InvocationTarget, exactBody: string): void;
43
+ /**
44
+ * Validate the exact response text after the transport has already validated
45
+ * and correlated the success envelope. `rawText` is the exact fatal-decoded
46
+ * response body; `success` is the validated envelope `parseSuccess` returned.
47
+ * Its query metadata uses ordinary JS numbers, including for `callJson`.
48
+ * Use `rawText` for comparisons that require exact decimal values.
49
+ * Throwing here fails a call whose action already executed.
50
+ */
51
+ validateSuccessBody(target: InvocationTarget, rawText: string, success: InvocationSuccess): void;
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { rename } from "node:fs/promises";
3
+ interface GenerateArgs {
4
+ out: string;
5
+ baseUrl?: string;
6
+ workspace?: string;
7
+ catalog?: string;
8
+ from?: string;
9
+ check: boolean;
10
+ }
11
+ declare function parseArgs(argv: readonly string[]): GenerateArgs;
12
+ /**
13
+ * Validate the base URL as a credential-bearing endpoint: HTTPS, or explicit
14
+ * loopback HTTP for local/integration use. Reject any other scheme, non-loopback
15
+ * HTTP, and any userinfo, query, or fragment — a bearer token must never be able
16
+ * to ride along in the URL, and the endpoint must not carry hidden state.
17
+ */
18
+ declare function validateBaseUrl(raw: string): URL;
19
+ /** Read the access token from the environment, falling back to stdin. */
20
+ declare function readToken(): Promise<string>;
21
+ /**
22
+ * Fetch the raw catalog export bytes from the service. The service requires
23
+ * `workspace_id` and accepts `catalog` as query parameters. Redirects are
24
+ * refused (a redirect could send the bearer token to another origin), the
25
+ * request is bounded by an explicit deadline, and the response body is counted
26
+ * against {@link MAX_EXPORT_BYTES} while streaming.
27
+ */
28
+ /** Bounds on the export request, overridable in tests for fast, focused cases. */
29
+ interface FetchExportOptions {
30
+ readonly timeoutMs?: number;
31
+ readonly maxBytes?: number;
32
+ }
33
+ declare function fetchExport(baseUrl: URL, workspace: string, catalog: string | undefined, token: string, options?: FetchExportOptions): Promise<Uint8Array>;
34
+ /**
35
+ * Write the bundle into `outDir`, replacing it atomically at the directory
36
+ * boundary: stage every file in a sibling temp directory, then swap it into
37
+ * place, so a reader never sees a half-written mix of old and new files.
38
+ */
39
+ declare function writeBundleAtomically(outDir: string, files: Record<string, string | Uint8Array>, deps?: {
40
+ rename: typeof rename;
41
+ restore?: typeof rename;
42
+ }): Promise<void>;
43
+ declare function main(argv: readonly string[]): Promise<number>;
44
+ export { fetchExport, main, parseArgs, readToken, validateBaseUrl, writeBundleAtomically, };