@hraness/ghostget 0.17.6 → 0.18.2
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/CHANGELOG.md +301 -0
- package/README.md +16 -9
- package/dist/apple-photos-client.js +2 -1
- package/dist/beeper-client.js +2 -1
- package/dist/client.js +1 -0
- package/dist/imessage-direct-install-2gm3kge7.js +324 -0
- package/dist/index-01eeae9e.js +5 -0
- package/dist/index-2ymnp8xv.js +145 -0
- package/dist/index-en5hycxp.js +175 -0
- package/dist/{index-cytf8d9p.js → index-hfbygww8.js} +1 -1
- package/dist/index-n4szk3nw.js +50 -0
- package/dist/index-yq6maz71.js +1105 -0
- package/dist/index-z1w83f81.js +4 -0
- package/dist/index.js +4 -47
- package/dist/messaging-automation-api.js +21 -0
- package/dist/messaging-automation-j4274hvc.js +609 -0
- package/dist/messaging-native-install-8w1j36ah.js +16 -0
- package/dist/messaging.js +1 -0
- package/dist/omni-client.js +1 -0
- package/dist/whatsapp-automation-runtime-hgh1e9rm.js +845 -0
- package/dist/whatsapp-client.js +1 -0
- package/docs/control-panel.md +147 -0
- package/docs/imessage-direct-provider.md +31 -7
- package/docs/messaging-automation.md +103 -0
- package/package.json +66 -7
- package/skills/ghostget/SKILL.md +3 -1
- package/skills/ghostget/references/control-panel.md +43 -0
- package/skills/ghostget/references/install.md +5 -5
- package/skills/ghostget/references/linkedin-adapter.md +222 -12
- package/skills/ghostget/references/platform-patterns.md +1 -1
- package/src/args.ts +18 -5
- package/src/assets/adapters/imessage/wrench-web-adapter.json +126 -0
- package/src/assets/adapters/linkedin/wrench-web-adapter.json +1 -1
- package/src/assets/adapters/whatsapp/wrench-web-adapter.json +144 -0
- package/src/assets/messaging-runtime/NOTICE.txt +2580 -0
- package/src/assets/messaging-runtime/imsg-darwin-arm64.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-info.plist.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-metadata.json.gz +0 -0
- package/src/assets/messaging-runtime/phone-number-privacy.plist.gz +0 -0
- package/src/assets/messaging-runtime/wacli-darwin-arm64.gz +0 -0
- package/src/auth.ts +35 -1
- package/src/beeper-client-types.ts +1 -1
- package/src/cli.ts +18 -0
- package/src/confirmed-write-platform.ts +16 -1
- package/src/control/account-revision.ts +16 -0
- package/src/control/activity.ts +104 -0
- package/src/control/approval-broker.ts +59 -0
- package/src/control/approval-client.ts +49 -0
- package/src/control/bundled-interfaces.ts +20 -0
- package/src/control/cli.ts +20 -0
- package/src/control/connections.ts +87 -0
- package/src/control/credential-helper.ts +152 -0
- package/src/control/helper.ts +79 -0
- package/src/control/interface-cli.ts +22 -0
- package/src/control/interface-json.ts +94 -0
- package/src/control/interface-schema.ts +120 -0
- package/src/control/interfaces.ts +438 -0
- package/src/control/protocol.ts +182 -0
- package/src/control/service.ts +104 -0
- package/src/control/validation.ts +103 -0
- package/src/control/vault.ts +105 -0
- package/src/control/web-gateway.ts +62 -0
- package/src/control/web-policy.ts +56 -0
- package/src/ghostget.ts +18 -4
- package/src/messaging-automation-api.ts +12 -0
- package/src/messaging-automation-descriptors.ts +29 -0
- package/src/messaging-automation-factory.ts +148 -0
- package/src/messaging-automation-server.ts +183 -0
- package/src/messaging-automation-types.ts +155 -0
- package/src/messaging-automation-validation.ts +110 -0
- package/src/messaging-automation.ts +356 -0
- package/src/messaging-runtime.ts +3 -0
- package/src/oauth-google.ts +11 -5
- package/src/omni-runtime.ts +18 -3
- package/src/operation-permission-store.ts +92 -0
- package/src/operation-permission.ts +308 -0
- package/src/pinned-https.ts +5 -0
- package/src/plugins/imessage-direct/plugin.ts +12 -1
- package/src/plugins/imessage-direct/vendor/0003-feat-rpc-add-no-fetch-rich-cards.patch +276 -0
- package/src/plugins/imessage-direct/vendor/provenance.json +84 -10
- package/src/plugins/linkedin-web/plugin.ts +1 -1
- package/src/plugins/whatsapp-linked-device/plugin.ts +7 -2
- package/src/plugins/whatsapp-linked-device/vendor/0001-ghostget-private-messaging.patch +1093 -0
- package/src/plugins/whatsapp-linked-device/vendor/README.md +39 -0
- package/src/plugins/whatsapp-linked-device/vendor/provenance.json +45 -0
- package/src/provider-http.ts +11 -3
- package/src/provider-plugin-contract-identity.ts +2 -2
- package/src/provider-plugin-import-analysis.ts +52 -0
- package/src/provider-plugin-module-analysis.ts +21 -1
- package/src/provider-plugin-registry.ts +4 -8
- package/src/provider-plugin.ts +18 -8
- package/src/providers/imessage-automation.ts +250 -0
- package/src/providers/imessage-direct-install.ts +7 -0
- package/src/providers/imessage-direct-runtime.ts +32 -2
- package/src/providers/imessage-direct.ts +13 -3
- package/src/providers/linkedin-contact-failure.ts +21 -0
- package/src/providers/linkedin-contact-platform.ts +8 -1
- package/src/providers/linkedin-contact-program.ts +34 -5
- package/src/providers/linkedin-web-contact.ts +1223 -35
- package/src/providers/linkedin-web-profile-browser.ts +659 -45
- package/src/providers/linkedin-web-runtime.ts +1 -0
- package/src/providers/linkedin-web.ts +46 -2
- package/src/providers/messaging-native-artifacts.ts +38 -0
- package/src/providers/messaging-native-install.ts +75 -0
- package/src/providers/whatsapp-automation-runtime.ts +240 -0
- package/src/providers/whatsapp-automation.ts +153 -0
- package/src/read-client.ts +12 -2
- package/src/runtime.ts +81 -9
- package/src/state-helper.ts +2 -0
- package/src/storage.ts +71 -1
- package/src/usage.ts +9 -1
- package/src/version.ts +1 -1
- package/src/web-session-contract-definitions.ts +1 -1
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createAuth, loadAuthSnapshotIfPresent, normalizeOAuthScopes, replaceAuthIfUnchanged, saveAuth } from "../auth";
|
|
4
|
+
import { canonicalJson } from "../canonical-json";
|
|
5
|
+
import { OperationDeadline } from "../operation-deadline";
|
|
6
|
+
import { createPinnedHttpsFetchScope, type PinnedHttpsFetch } from "../pinned-https";
|
|
7
|
+
import { bearerHeaders, loadOAuthCredential, loadOAuthToken, ProviderHttpClient, type OAuthTokenAuth } from "../provider-http";
|
|
8
|
+
import { isXAccountSubject } from "../provider-subject";
|
|
9
|
+
import { withReadProjectionAuthAdmission } from "../read-projections";
|
|
10
|
+
import { createPrivateJsonIfAbsent, ghostgetStateHome, removePrivateStateFileIfUnchanged } from "../storage";
|
|
11
|
+
import { GHOSTGET_VERSION } from "../version";
|
|
12
|
+
import type { ControlRequest } from "./protocol";
|
|
13
|
+
import { readInterfaceJson } from "./interface-json";
|
|
14
|
+
import { parseControlRequest } from "./validation";
|
|
15
|
+
import { connectionAccountRevision } from "./account-revision";
|
|
16
|
+
|
|
17
|
+
export type VaultImportRequest = Extract<ControlRequest, { action: "vault.import" }>;
|
|
18
|
+
export type VaultImportCode = "INVALID_IMPORT" | "ACCOUNT_CHANGED" | "VAULT_UNAVAILABLE" | "TOKEN_UNVERIFIED" | "IMPORT_CANCELLED" | "IMPORT_FAILED" | "IMPORT_UNCERTAIN";
|
|
19
|
+
export type VaultImportResult = { readonly ok: true } | { readonly ok: false; readonly code: VaultImportCode };
|
|
20
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
21
|
+
export const VAULT_IMPORT_TIMEOUT_MS = 120_000;
|
|
22
|
+
const X_IMPORT_SCOPES = new Set(["tweet.read", "tweet.write", "users.read", "dm.read", "dm.write", "bookmark.write"]);
|
|
23
|
+
|
|
24
|
+
/** The import metadata is declared by the human; only the exact user subject is proved. */
|
|
25
|
+
export function parseVaultImport(value: unknown, now = Date.now()): VaultImportRequest {
|
|
26
|
+
const request = parseControlRequest(value);
|
|
27
|
+
if (request.action !== "vault.import" || !/^[a-z][a-z0-9-]{0,47}$/u.test(request.id)
|
|
28
|
+
|| !isXAccountSubject(request.expectedSubject)
|
|
29
|
+
|| request.account.trim() !== request.account || /[\u0000-\u001f\u007f]/u.test(request.account)
|
|
30
|
+
|| !/^op:\/\/[^/\u0000-\u001f\u007f?#\\]+\/[^/\u0000-\u001f\u007f?#\\]+\/(?:[^/\u0000-\u001f\u007f?#\\]+\/)?[^/\u0000-\u001f\u007f?#\\]+$/u.test(request.reference)
|
|
31
|
+
|| /%(?:2f|5c|00|0a|0d)/iu.test(request.reference)) throw new Error("invalid token import");
|
|
32
|
+
const scopes = normalizeOAuthScopes(request.scopes);
|
|
33
|
+
if (!scopes.includes("tweet.read") || !scopes.includes("users.read") || scopes.some(scope => !X_IMPORT_SCOPES.has(scope))) throw new Error("unsupported token scopes");
|
|
34
|
+
if (request.expiresAt !== null && (!Number.isFinite(Date.parse(request.expiresAt)) || new Date(request.expiresAt).toISOString() !== request.expiresAt || Date.parse(request.expiresAt) <= now + 30_000)) throw new Error("invalid declared expiry");
|
|
35
|
+
return { ...request, scopes };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Trusted fixed sink: no registry, supplied URL, dynamic executor, redirect or retry. */
|
|
39
|
+
export async function probeImportedXToken(auth: OAuthTokenAuth, signal: AbortSignal, transport?: PinnedHttpsFetch): Promise<string> {
|
|
40
|
+
const deadline = new OperationDeadline(15_000, { signal });
|
|
41
|
+
const scope = transport === undefined ? createPinnedHttpsFetchScope("https://api.x.com") : null;
|
|
42
|
+
const fetch = transport ?? scope!.fetch;
|
|
43
|
+
try {
|
|
44
|
+
const token = loadOAuthToken(auth);
|
|
45
|
+
const http = new ProviderHttpClient((input, init) => fetch(new URL(String(input)), init ?? {}, 15_000), deadline, 16_384);
|
|
46
|
+
const response = await http.request("https://api.x.com/2/users/me", { method: "GET", headers: bearerHeaders(token.accessToken, { Accept: "application/json" }), credentials: "omit" }, [200], ["api.x.com"], 16_384, "application/json");
|
|
47
|
+
const body = response.body as { data?: { id?: unknown }; errors?: unknown } | null;
|
|
48
|
+
const subject = body?.data?.id;
|
|
49
|
+
if (typeof subject !== "string" || !isXAccountSubject(subject) || body?.errors !== undefined) throw new Error("unverified account");
|
|
50
|
+
deadline.throwIfUnavailable("account verification");
|
|
51
|
+
return subject;
|
|
52
|
+
} finally { deadline.dispose(); scope?.close(); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function resolveDesktopSecret(account: string, reference: string): Promise<string> {
|
|
56
|
+
const sdk = await import("@1password/sdk");
|
|
57
|
+
const client = await sdk.createClient({ auth: new sdk.DesktopAuth(account), integrationName: "Ghostget", integrationVersion: GHOSTGET_VERSION });
|
|
58
|
+
return client.secrets.resolve(reference);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type CredentialImportDependencies = {
|
|
62
|
+
readonly resolve?: (account: string, reference: string) => Promise<string>;
|
|
63
|
+
readonly probe?: (auth: OAuthTokenAuth, signal: AbortSignal) => Promise<string>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Runs only in the credential process. Never returns secret values or raw errors. */
|
|
67
|
+
export async function runCredentialImport(value: unknown, environment: Environment, signal: AbortSignal, dependencies: CredentialImportDependencies = {}): Promise<VaultImportResult> {
|
|
68
|
+
let request: VaultImportRequest;
|
|
69
|
+
try { request = parseVaultImport(value); } catch { return { ok: false, code: "INVALID_IMPORT" }; }
|
|
70
|
+
const deadline = new OperationDeadline(VAULT_IMPORT_TIMEOUT_MS, { signal });
|
|
71
|
+
let stage: { path: string; contentSha256: string | null } | null = null;
|
|
72
|
+
let failure: VaultImportCode = "IMPORT_FAILED";
|
|
73
|
+
try {
|
|
74
|
+
deadline.throwIfUnavailable("token import");
|
|
75
|
+
failure = "ACCOUNT_CHANGED";
|
|
76
|
+
const current = withReadProjectionAuthAdmission(request.id, environment, () => {
|
|
77
|
+
const snapshot = loadAuthSnapshotIfPresent(request.id, environment);
|
|
78
|
+
if ((snapshot === null ? null : connectionAccountRevision(snapshot, environment)) !== request.expectedRevision) throw new Error();
|
|
79
|
+
return snapshot;
|
|
80
|
+
});
|
|
81
|
+
failure = "VAULT_UNAVAILABLE";
|
|
82
|
+
const accessToken = await deadline.run(() => (dependencies.resolve ?? resolveDesktopSecret)(request.account, request.reference), "vault access");
|
|
83
|
+
if (typeof accessToken !== "string" || accessToken.length < 8 || Buffer.byteLength(accessToken) > 16_384 || !/^[A-Za-z0-9._~+/-]+=*$/u.test(accessToken)) return { ok: false, code: "TOKEN_UNVERIFIED" };
|
|
84
|
+
deadline.throwIfUnavailable("token import");
|
|
85
|
+
failure = "IMPORT_FAILED";
|
|
86
|
+
const path = join(ghostgetStateHome(environment), "auth", "oauth-tokens", `${request.id}-${randomUUID()}.json`);
|
|
87
|
+
const credential = { schemaVersion: 1, provider: "x", subject: request.expectedSubject, scopes: request.scopes, accessToken, expiresAt: request.expiresAt };
|
|
88
|
+
const expectedContent = `${canonicalJson(credential)}\n`;
|
|
89
|
+
stage = { path, contentSha256: null };
|
|
90
|
+
if (!createPrivateJsonIfAbsent(path, credential, { privateParent: true, environment }).created) { stage = null; throw new Error(); }
|
|
91
|
+
const auth = createAuth(request.id, { oauthProvider: "x", tokenFile: path, subject: request.expectedSubject, scopes: request.scopes, ownedImport: true });
|
|
92
|
+
if (auth.kind !== "oauth-token-file") throw new Error();
|
|
93
|
+
const { contentSha256 } = loadOAuthCredential(auth, { expectedContent });
|
|
94
|
+
stage = { path, contentSha256 };
|
|
95
|
+
failure = "TOKEN_UNVERIFIED";
|
|
96
|
+
const subject = await deadline.run(activeSignal => (dependencies.probe ?? probeImportedXToken)(auth, activeSignal), "account verification");
|
|
97
|
+
if (subject !== request.expectedSubject) throw new Error();
|
|
98
|
+
failure = "ACCOUNT_CHANGED";
|
|
99
|
+
withReadProjectionAuthAdmission(request.id, environment, () => {
|
|
100
|
+
deadline.throwIfUnavailable("token import");
|
|
101
|
+
// Validation is repeated after both external ceremonies and before the only commit.
|
|
102
|
+
parseVaultImport(request);
|
|
103
|
+
const observed = loadAuthSnapshotIfPresent(request.id, environment);
|
|
104
|
+
if ((observed === null ? null : connectionAccountRevision(observed, environment)) !== request.expectedRevision) throw new Error();
|
|
105
|
+
if (loadOAuthCredential(auth, { expectedContent }).contentSha256 !== contentSha256) throw new Error();
|
|
106
|
+
if (current === null) saveAuth(auth, environment);
|
|
107
|
+
else if (!replaceAuthIfUnchanged(current, auth, environment).replaced) throw new Error();
|
|
108
|
+
});
|
|
109
|
+
stage = null;
|
|
110
|
+
return { ok: true };
|
|
111
|
+
} catch {
|
|
112
|
+
if (stage !== null) {
|
|
113
|
+
try {
|
|
114
|
+
const observed = loadAuthSnapshotIfPresent(request.id, environment)?.auth;
|
|
115
|
+
// A cleanup failure after locator publication must never erase its live token.
|
|
116
|
+
if (stage.contentSha256 === null || observed?.kind === "oauth-token-file" && observed.path === stage.path) return { ok: false, code: "IMPORT_UNCERTAIN" };
|
|
117
|
+
if (!removePrivateStateFileIfUnchanged(stage.path, { expectedCurrentContentSha256: stage.contentSha256 }, environment)) return { ok: false, code: "IMPORT_UNCERTAIN" };
|
|
118
|
+
} catch { return { ok: false, code: "IMPORT_UNCERTAIN" }; }
|
|
119
|
+
}
|
|
120
|
+
return { ok: false, code: deadline.signal.aborted ? "IMPORT_CANCELLED" : failure };
|
|
121
|
+
} finally { deadline.dispose(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function main(): Promise<void> {
|
|
125
|
+
// Install before the lazy SDK import, including any SDK module-loader output.
|
|
126
|
+
// Native library stderr is discarded by the parent; malformed stdout is never
|
|
127
|
+
// surfaced and cannot be accepted as a successful credential receipt.
|
|
128
|
+
for (const name of Object.getOwnPropertyNames(console)) {
|
|
129
|
+
const value: unknown = Reflect.get(console, name);
|
|
130
|
+
if (typeof value === "function") Reflect.set(console, name, () => undefined);
|
|
131
|
+
}
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const abort = () => controller.abort();
|
|
134
|
+
process.on("SIGTERM", abort); process.on("SIGINT", abort);
|
|
135
|
+
const timer = setTimeout(abort, VAULT_IMPORT_TIMEOUT_MS);
|
|
136
|
+
let result: VaultImportResult = { ok: false, code: "INVALID_IMPORT" };
|
|
137
|
+
try {
|
|
138
|
+
const chunks: Buffer[] = []; let size = 0;
|
|
139
|
+
for await (const chunk of process.stdin) {
|
|
140
|
+
const bytes = Buffer.from(chunk); size += bytes.byteLength;
|
|
141
|
+
if (size > 16_384 || controller.signal.aborted) throw new Error();
|
|
142
|
+
chunks.push(bytes);
|
|
143
|
+
}
|
|
144
|
+
const value = readInterfaceJson(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)), 16_384);
|
|
145
|
+
result = await runCredentialImport(value, process.env, controller.signal);
|
|
146
|
+
} catch { /* Categorical output only, including SDK and filesystem failures. */ }
|
|
147
|
+
finally { clearTimeout(timer); process.off("SIGTERM", abort); process.off("SIGINT", abort); }
|
|
148
|
+
await Bun.write(Bun.stdout, `${JSON.stringify(result)}\n`);
|
|
149
|
+
process.exit(result.ok ? 0 : 1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (import.meta.main) await main();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createServer, connect, type Socket } from "node:net";
|
|
2
|
+
import { chmodSync, lstatSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { canonicalJson, sha256 } from "../canonical-json";
|
|
6
|
+
import { captureProcessOwnerIdentity, processOwnerStatus } from "../process-identity";
|
|
7
|
+
import { createPrivateJsonIfAbsent, ensurePrivateStateDirectory, ghostgetStateHome, readPrivateStateFileIfPresent, removePrivateStateFileIfUnchanged, snapshotPrivateStateDirectory, writePrivateJsonIfUnchanged } from "../storage";
|
|
8
|
+
import { controlSocketPath } from "./approval-client";
|
|
9
|
+
import { CONTROL_PROTOCOL, type ApprovalTarget, type JsonValue } from "./protocol";
|
|
10
|
+
import { controlFailure, ControlService } from "./service";
|
|
11
|
+
import { ControlError, controlResponseLine, digest, identifier, integer, keys, nullable, oneOf, parseControlRequest, record, string } from "./validation";
|
|
12
|
+
import type { ControlEnvironment } from "./web-policy";
|
|
13
|
+
|
|
14
|
+
function parseTarget(value:unknown):ApprovalTarget {
|
|
15
|
+
const v=record(value);
|
|
16
|
+
if(v.kind==="web"){keys(v,["kind","method","url"]);return {kind:"web",method:oneOf(v.method,["GET","HEAD"]),url:string(v.url,8192)};}
|
|
17
|
+
keys(v,["kind","adapterId","operationId","authId","input","planDigest"]);if(v.kind!=="provider")throw new Error("invalid target");
|
|
18
|
+
// The kernel's operation input parser is authoritative; bound recursive JSON before calling it.
|
|
19
|
+
const inspect=(item:unknown,depth:number):void=>{if(depth>24)throw new Error("deep input");if(item===null||typeof item==="boolean"||typeof item==="string"||typeof item==="number"&&Number.isFinite(item))return;if(Array.isArray(item)){if(item.length>1024)throw new Error("large input");for(const child of item)inspect(child,depth+1);return;}const object=record(item);if(Object.keys(object).length>256)throw new Error("large input");for(const child of Object.values(object))inspect(child,depth+1);};inspect(v.input,0);
|
|
20
|
+
return {kind:"provider",adapterId:identifier(v.adapterId),operationId:identifier(v.operationId),authId:nullable(v.authId,128),input:v.input as JsonValue,planDigest:v.planDigest===null?null:digest(v.planDigest)};
|
|
21
|
+
}
|
|
22
|
+
async function handleAgent(value:unknown,service:ControlService,signal:AbortSignal):Promise<unknown> {
|
|
23
|
+
const v=record(value);
|
|
24
|
+
if(v.protocol==="ghostget.web/1") {keys(v,["protocol","action","method","url"]);if(v.action!=="request")throw new Error("invalid action");return await service.gateway.run(oneOf(v.method,["GET","HEAD"]),string(v.url,8192),signal);}
|
|
25
|
+
if(v.protocol!=="ghostget.approval/1")throw new Error("invalid protocol");
|
|
26
|
+
if(v.action==="request"){keys(v,["protocol","action","id","target","expectedDigest"]);return await service.approvals.request(identifier(v.id),parseTarget(v.target),digest(v.expectedDigest));}
|
|
27
|
+
keys(v,["protocol","action","id","digest"]);const id=identifier(v.id);const hash=digest(v.digest);
|
|
28
|
+
if(v.action==="check")return await service.approvals.check(id,hash);
|
|
29
|
+
if(v.action==="cancel")return service.approvals.cancel(id,hash);
|
|
30
|
+
throw new Error("invalid action");
|
|
31
|
+
}
|
|
32
|
+
function socketLive(path:string):Promise<boolean> {
|
|
33
|
+
return new Promise((resolve,reject)=>{const socket=connect({path});const timer=setTimeout(()=>{socket.destroy();reject(new Error("socket state unknown"));},1000);socket.once("connect",()=>{clearTimeout(timer);socket.destroy();resolve(true);});socket.once("error",error=>{clearTimeout(timer);socket.destroy();if((error as NodeJS.ErrnoException).code==="ECONNREFUSED")resolve(false);else reject(new Error("socket state unknown"));});});
|
|
34
|
+
}
|
|
35
|
+
function acquireOwner(environment:ControlEnvironment):()=>void {
|
|
36
|
+
const directory=join(ghostgetStateHome(environment),"control");ensurePrivateStateDirectory(directory,environment);
|
|
37
|
+
const path=join(directory,"owner.json");const previous=readPrivateStateFileIfPresent(path,2048,"control owner",environment);
|
|
38
|
+
if(previous!==null){const v=record(JSON.parse(previous));keys(v,["schema","pid","bootId","processStartId","generation"]);if(v.schema!==1)throw new Error("invalid owner");const owner={pid:integer(v.pid,1,2**31-1),bootId:digest(v.bootId),processStartId:digest(v.processStartId)};identifier(v.generation);if(processOwnerStatus(owner)!=="different-or-dead")throw new ControlError("CONTROL_ALREADY_RUNNING","Ghostget is already open for this state home, or its previous owner cannot be verified.");}
|
|
39
|
+
const next={schema:1,...captureProcessOwnerIdentity(process.pid),generation:randomUUID()};
|
|
40
|
+
const acquired=previous===null?createPrivateJsonIfAbsent(path,next,{environment}).created:writePrivateJsonIfUnchanged(path,next,{expectedCurrentContentSha256:sha256(previous)});
|
|
41
|
+
if(!acquired)throw new ControlError("CONTROL_ALREADY_RUNNING","Another Ghostget app opened this state home.");
|
|
42
|
+
const expectedCurrentContentSha256=sha256(`${canonicalJson(next)}\n`);
|
|
43
|
+
return ()=>{removePrivateStateFileIfUnchanged(path,{expectedCurrentContentSha256},environment);};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Private native stdio is the only administrative transport. No TCP listener is created. */
|
|
47
|
+
export async function runControlHelper(environment:ControlEnvironment=process.env):Promise<void> {
|
|
48
|
+
process.umask(0o077);
|
|
49
|
+
const releaseOwner=acquireOwner(environment);const socketPath=controlSocketPath(environment);const clients=new Set<Socket>();const active=new Set<Promise<void>>();
|
|
50
|
+
let service:ControlService|undefined;let ownedSocket:{dev:number;ino:number}|undefined;let closing=false;let controlActive=0;
|
|
51
|
+
const directory=join(ghostgetStateHome(environment),"control");const directoryIdentity=ensurePrivateStateDirectory(directory,environment);
|
|
52
|
+
const server=createServer(socket=>{
|
|
53
|
+
if(closing||clients.size>=16||service===undefined){socket.destroy();return;}
|
|
54
|
+
clients.add(socket);const controller=new AbortController();let buffer=Buffer.alloc(0);let started=false;
|
|
55
|
+
socket.setTimeout(150_000,()=>socket.destroy());
|
|
56
|
+
socket.on("close",()=>{controller.abort();clients.delete(socket);});socket.on("error",()=>controller.abort());
|
|
57
|
+
socket.on("data",chunk=>{
|
|
58
|
+
if(started){socket.destroy();return;}buffer=Buffer.concat([buffer,typeof chunk==="string"?Buffer.from(chunk):chunk]);if(buffer.length>262144){socket.destroy();return;}const newline=buffer.indexOf(10);if(newline<0)return;
|
|
59
|
+
started=true;const work=(async()=>{let response:unknown;try{if(newline!==buffer.length-1)throw new Error();response=await handleAgent(JSON.parse(buffer.subarray(0,newline).toString("utf8")),service!,controller.signal);}catch(error){response=controlFailure(error);}if(!socket.destroyed)socket.end(`${JSON.stringify(response)}\n`);})();active.add(work);void work.then(()=>active.delete(work),()=>{active.delete(work);process.exitCode=1;process.stdin.destroy();});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
const shutdown=async()=>{if(closing)return;closing=true;for(const socket of clients)socket.destroy();service?.beginShutdown();await Promise.allSettled(active);service?.close();if(server.listening)await new Promise<void>(resolve=>server.close(()=>resolve()));if(ownedSocket!==undefined){snapshotPrivateStateDirectory(directory,environment,directoryIdentity);try{const stat=lstatSync(socketPath);if(stat.isSocket()&&stat.dev===ownedSocket.dev&&stat.ino===ownedSocket.ino)unlinkSync(socketPath);}catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}}releaseOwner();};
|
|
63
|
+
const termination=()=>{process.stdin.destroy();};process.once("SIGTERM",termination);process.once("SIGINT",termination);
|
|
64
|
+
try {
|
|
65
|
+
if(Buffer.byteLength(socketPath)>100)throw new ControlError("CONTROL_PATH_TOO_LONG","Choose a shorter Ghostget state-home path for the native app.");
|
|
66
|
+
let stale:ReturnType<typeof lstatSync>|undefined;try{stale=lstatSync(socketPath);}catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;}
|
|
67
|
+
if(stale!==undefined){if(!stale.isSocket()||stale.uid!==process.getuid?.()||(Number(stale.mode)&0o777)!==0o600||await socketLive(socketPath))throw new ControlError("CONTROL_ALREADY_RUNNING","The control socket is already in use or unsafe.");snapshotPrivateStateDirectory(directory,environment,directoryIdentity);const current=lstatSync(socketPath);if(current.dev!==stale.dev||current.ino!==stale.ino)throw new Error("socket changed");unlinkSync(socketPath);}
|
|
68
|
+
await new Promise<void>((resolve,reject)=>{server.once("error",reject);server.listen(socketPath,()=>{server.off("error",reject);resolve();});});chmodSync(socketPath,0o600);const socketStat=lstatSync(socketPath);ownedSocket={dev:socketStat.dev,ino:socketStat.ino};
|
|
69
|
+
service=new ControlService(environment);
|
|
70
|
+
let buffer=Buffer.alloc(0);
|
|
71
|
+
for await(const chunk of process.stdin){if(closing)break;buffer=Buffer.concat([buffer,typeof chunk==="string"?Buffer.from(chunk):chunk]);if(buffer.length>4_194_304)throw new Error("control frame too large");let newline:number;
|
|
72
|
+
while((newline=buffer.indexOf(10))>=0){const frame=buffer.subarray(0,newline);buffer=buffer.subarray(newline+1);if(controlActive>=8)throw new Error("too many control requests");controlActive++;
|
|
73
|
+
const work=(async()=>{let id="invalid";let response;try{const v=record(JSON.parse(frame.toString("utf8")));keys(v,["id","protocol","request"]);id=identifier(v.id);if(v.protocol!==CONTROL_PROTOCOL)throw new Error("wrong protocol");response=await service!.request(parseControlRequest(v.request));}catch(error){response=controlFailure(error);}const output=controlResponseLine(id,response);if(!closing)process.stdout.write(output);})().finally(()=>{controlActive--;});active.add(work);void work.then(()=>active.delete(work),()=>{active.delete(work);process.exitCode=1;process.stdin.destroy();});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if(buffer.length!==0)throw new Error("partial control frame");
|
|
77
|
+
} finally {process.off("SIGTERM",termination);process.off("SIGINT",termination);await shutdown();}
|
|
78
|
+
}
|
|
79
|
+
if(import.meta.main){try{await runControlHelper();}catch{process.stderr.write("Ghostget control helper stopped safely.\n");process.exitCode=1;}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { providerPluginRegistry } from "../provider-plugins";
|
|
3
|
+
import { createPortableProviderPluginCatalog } from "../provider-plugin-portable-catalog";
|
|
4
|
+
import { readRegularFile } from "../storage";
|
|
5
|
+
import { exportInterfaces, listInterfaces, saveInterface } from "./interfaces";
|
|
6
|
+
import { ControlError, identifier } from "./validation";
|
|
7
|
+
import type { ControlEnvironment } from "./web-policy";
|
|
8
|
+
|
|
9
|
+
/** Agent editing ends at an inert draft; activation is an explicit native review. */
|
|
10
|
+
export function runInterfaceCommand(args:readonly string[],environment:ControlEnvironment,output:{stdout:(value:string)=>unknown;stderr:(value:string)=>unknown}):number {
|
|
11
|
+
try {
|
|
12
|
+
if(args.length===1||args[1]==="--help"){output.stdout("Usage: ghostget interface list\n ghostget interface export [adapter]\n ghostget interface import <openapi.json> [--expected-digest <sha256>]\nImport saves an inert draft. Review and activate it in the native app.\n");return 0;}
|
|
13
|
+
const context={environment,registry:createPortableProviderPluginCatalog(providerPluginRegistry,environment).registry};
|
|
14
|
+
if(args[1]==="list"&&args.length===2){output.stdout(`${JSON.stringify({interfaces:listInterfaces(context)})}\n`);return 0;}
|
|
15
|
+
if(args[1]==="export"&&(args.length===2||args.length===3)){output.stdout(exportInterfaces({adapterId:args[2]===undefined?null:identifier(args[2]),...context}).text);return 0;}
|
|
16
|
+
if(args[1]==="import"&&(args.length===3||args.length===5&&args[3]==="--expected-digest")){
|
|
17
|
+
const document=readRegularFile(resolve(args[2]!),524288,"OpenAPI interface");
|
|
18
|
+
const draft=saveInterface({document,source:"user",expectedDigest:args[4]??null,...context});output.stdout(`${JSON.stringify({draft})}\n`);return 0;
|
|
19
|
+
}
|
|
20
|
+
throw new ControlError("INVALID_REQUEST","Run ghostget interface --help for the supported draft commands.");
|
|
21
|
+
} catch(error){output.stderr(`${JSON.stringify({ok:false,code:error instanceof ControlError?error.code:"INTERFACE_INVALID",message:error instanceof ControlError?error.message:"The OpenAPI document, expected revision, or installed semantic binding is invalid. No interface was activated."})}\n`);return 1;}
|
|
22
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** A bounded JSON reader that also rejects duplicate keys before they can disappear. */
|
|
2
|
+
export function readInterfaceJson(text: string, maximumBytes: number): unknown {
|
|
3
|
+
if (typeof text !== "string" || Buffer.byteLength(text) > maximumBytes) {
|
|
4
|
+
throw new Error("interface JSON exceeds its byte limit");
|
|
5
|
+
}
|
|
6
|
+
let cursor = 0;
|
|
7
|
+
let nodes = 0;
|
|
8
|
+
const whitespace = () => { while (/^[\t\n\r ]$/u.test(text[cursor] ?? "")) cursor++; };
|
|
9
|
+
const fail = (): never => { throw new Error("interface JSON is malformed"); };
|
|
10
|
+
const string = (): string => {
|
|
11
|
+
if (text[cursor++] !== '"') return fail();
|
|
12
|
+
const start = cursor - 1;
|
|
13
|
+
while (cursor < text.length) {
|
|
14
|
+
const character = text[cursor++];
|
|
15
|
+
if (character === "\\") { cursor++; continue; }
|
|
16
|
+
if (character === '"') {
|
|
17
|
+
try { return JSON.parse(text.slice(start, cursor)) as string; } catch { return fail(); }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return fail();
|
|
21
|
+
};
|
|
22
|
+
const value = (depth: number): unknown => {
|
|
23
|
+
if (++nodes > 50_000 || depth > 24) throw new Error("interface JSON exceeds its structural limit");
|
|
24
|
+
whitespace();
|
|
25
|
+
if (text[cursor] === '"') return string();
|
|
26
|
+
if (text[cursor] === "{") {
|
|
27
|
+
cursor++;
|
|
28
|
+
const result: Record<string, unknown> = {};
|
|
29
|
+
const keys = new Set<string>();
|
|
30
|
+
whitespace();
|
|
31
|
+
if (text[cursor] === "}") { cursor++; return result; }
|
|
32
|
+
for (;;) {
|
|
33
|
+
whitespace();
|
|
34
|
+
const key = string();
|
|
35
|
+
if (keys.has(key)) throw new Error("interface JSON repeats an object key");
|
|
36
|
+
if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("interface JSON contains a reserved object key");
|
|
37
|
+
if (["$ref", "$dynamicRef", "$id", "$schema"].includes(key)) throw new Error("interface JSON references and schema dialect overrides are unsupported");
|
|
38
|
+
keys.add(key);
|
|
39
|
+
whitespace();
|
|
40
|
+
if (text[cursor++] !== ":") return fail();
|
|
41
|
+
result[key] = value(depth + 1);
|
|
42
|
+
whitespace();
|
|
43
|
+
if (text[cursor] === "}") { cursor++; return result; }
|
|
44
|
+
if (text[cursor++] !== ",") return fail();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (text[cursor] === "[") {
|
|
48
|
+
cursor++;
|
|
49
|
+
const result: unknown[] = [];
|
|
50
|
+
whitespace();
|
|
51
|
+
if (text[cursor] === "]") { cursor++; return result; }
|
|
52
|
+
for (;;) {
|
|
53
|
+
result.push(value(depth + 1));
|
|
54
|
+
whitespace();
|
|
55
|
+
if (text[cursor] === "]") { cursor++; return result; }
|
|
56
|
+
if (text[cursor++] !== ",") return fail();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const token = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/u.exec(text.slice(cursor))?.[0];
|
|
60
|
+
if (token === undefined) return fail();
|
|
61
|
+
cursor += token.length;
|
|
62
|
+
const parsed: unknown = JSON.parse(token);
|
|
63
|
+
if (typeof parsed === "number" && !Number.isFinite(parsed)) return fail();
|
|
64
|
+
return parsed;
|
|
65
|
+
};
|
|
66
|
+
const parsed = value(0);
|
|
67
|
+
whitespace();
|
|
68
|
+
if (cursor !== text.length) return fail();
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function interfaceRecord(value: unknown, label: string): Record<string, unknown> {
|
|
73
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
|
|
74
|
+
return value as Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function interfaceKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[], label: string): void {
|
|
78
|
+
if (required.some((key) => !Object.hasOwn(value, key)) || Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))) {
|
|
79
|
+
throw new Error(`${label} contains missing or unsupported fields`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function interfaceText(value: unknown, label: string, maximum = 512): string {
|
|
84
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value)) {
|
|
85
|
+
throw new Error(`${label} must be bounded text`);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function interfaceId(value: unknown): string {
|
|
91
|
+
const id = interfaceText(value, "interface identifier", 48);
|
|
92
|
+
if (!/^[a-z][a-z0-9-]*$/u.test(id)) throw new Error("interface identifier must be lowercase kebab-case");
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { canonicalJson, type InputField, type InputSchema } from "../model";
|
|
2
|
+
import { interfaceKeys, interfaceRecord, interfaceText } from "./interface-json";
|
|
3
|
+
|
|
4
|
+
type Schema = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
function fieldSchema(field: InputField): Schema {
|
|
7
|
+
if (field.type === "file") return {
|
|
8
|
+
type: "object", description: field.description, additionalProperties: false,
|
|
9
|
+
properties: { kind: { type: "string", const: "file" }, reference: { type: "string", minLength: 1 } },
|
|
10
|
+
required: ["kind", "reference"],
|
|
11
|
+
"x-ghostget-file": { maxBytes: field.maxBytes, ...(field.mediaTypes === undefined ? {} : { mediaTypes: field.mediaTypes }) },
|
|
12
|
+
};
|
|
13
|
+
if (field.type === "array") return {
|
|
14
|
+
type: "array", description: field.description, items: fieldSchema(field.items), minItems: field.minItems, maxItems: field.maxItems,
|
|
15
|
+
};
|
|
16
|
+
const { format, urlPathPrefixes, ...scalar } = field;
|
|
17
|
+
return {
|
|
18
|
+
...scalar,
|
|
19
|
+
...(format === undefined ? {} : { format: format === "url" ? "uri" : "path-segment" }),
|
|
20
|
+
...(urlPathPrefixes === undefined ? {} : { "x-ghostget-url-path-prefixes": urlPathPrefixes }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function interfaceInputSchema(input: InputSchema): Schema {
|
|
25
|
+
return {
|
|
26
|
+
type: "object", additionalProperties: false,
|
|
27
|
+
properties: Object.fromEntries(Object.entries(input.properties).sort(([a], [b]) => a.localeCompare(b)).map(([key, field]) => [key, fieldSchema(field)])),
|
|
28
|
+
required: [...input.required],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function number(value: unknown, label: string, integer = false): number {
|
|
33
|
+
if (typeof value !== "number" || !Number.isFinite(value) || (integer && (!Number.isSafeInteger(value) || value < 0))) throw new Error(`${label} must be a bounded number`);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fieldFromSchema(value: unknown, nested = false): InputField {
|
|
38
|
+
const field = interfaceRecord(value, "input field schema");
|
|
39
|
+
const description = interfaceText(field.description, "input field description", 4_096);
|
|
40
|
+
if (field.type === "object") {
|
|
41
|
+
interfaceKeys(field, ["type", "description", "additionalProperties", "properties", "required", "x-ghostget-file"], [], "file reference schema");
|
|
42
|
+
const binding = interfaceRecord(field["x-ghostget-file"], "file reference binding");
|
|
43
|
+
interfaceKeys(binding, ["maxBytes"], ["mediaTypes"], "file reference binding");
|
|
44
|
+
const maxBytes = number(binding.maxBytes, "file byte limit", true);
|
|
45
|
+
let mediaTypes: string[] | undefined;
|
|
46
|
+
if (binding.mediaTypes !== undefined) {
|
|
47
|
+
if (!Array.isArray(binding.mediaTypes) || binding.mediaTypes.length > 32) throw new Error("file media types exceed their bound");
|
|
48
|
+
mediaTypes = binding.mediaTypes.map((entry) => interfaceText(entry, "file media type", 128));
|
|
49
|
+
}
|
|
50
|
+
const result: InputField = { type: "file", description, maxBytes, ...(mediaTypes === undefined ? {} : { mediaTypes }) };
|
|
51
|
+
if (canonicalJson(fieldSchema(result)) !== canonicalJson(field)) throw new Error("file reference schema changed its opaque input contract");
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
if (field.type === "array") {
|
|
55
|
+
if (nested) throw new Error("nested input arrays are unsupported");
|
|
56
|
+
interfaceKeys(field, ["type", "description", "items", "minItems", "maxItems"], [], "array input schema");
|
|
57
|
+
const items = fieldFromSchema(field.items, true);
|
|
58
|
+
if (items.type === "array") throw new Error("nested input arrays are unsupported");
|
|
59
|
+
const minItems = number(field.minItems, "minimum items", true);
|
|
60
|
+
const maxItems = number(field.maxItems, "maximum items", true);
|
|
61
|
+
if (minItems > maxItems || maxItems > 10_000) throw new Error("array bounds are invalid");
|
|
62
|
+
return { type: "array", description, items, minItems, maxItems };
|
|
63
|
+
}
|
|
64
|
+
interfaceKeys(field, ["type", "description"], ["minLength", "maxLength", "minimum", "maximum", "enum", "format", "x-ghostget-url-path-prefixes"], "scalar input schema");
|
|
65
|
+
if (field.type !== "string" && field.type !== "number" && field.type !== "boolean") throw new Error("input field type is unsupported");
|
|
66
|
+
const result: Record<string, unknown> = { type: field.type, description };
|
|
67
|
+
for (const key of ["minLength", "maxLength", "minimum", "maximum"] as const) {
|
|
68
|
+
if (field[key] !== undefined) result[key] = number(field[key], key, key.endsWith("Length"));
|
|
69
|
+
}
|
|
70
|
+
if (field.enum !== undefined) {
|
|
71
|
+
if (!Array.isArray(field.enum) || field.enum.length < 1 || field.enum.length > 256 || field.enum.some((entry) => typeof entry !== field.type)) throw new Error("input enum is invalid");
|
|
72
|
+
result.enum = field.enum;
|
|
73
|
+
}
|
|
74
|
+
if (field.format !== undefined) {
|
|
75
|
+
if (field.type !== "string" || (field.format !== "uri" && field.format !== "path-segment")) throw new Error("input format is unsupported");
|
|
76
|
+
result.format = field.format === "uri" ? "url" : "path-segment";
|
|
77
|
+
}
|
|
78
|
+
if (field["x-ghostget-url-path-prefixes"] !== undefined) {
|
|
79
|
+
const prefixes = field["x-ghostget-url-path-prefixes"];
|
|
80
|
+
if (field.format !== "uri" || !Array.isArray(prefixes) || prefixes.length > 64) throw new Error("URL path prefixes are invalid");
|
|
81
|
+
result.urlPathPrefixes = prefixes.map((entry) => interfaceText(entry, "URL path prefix", 2_048));
|
|
82
|
+
}
|
|
83
|
+
return result as InputField;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function inputFromInterfaceSchema(value: unknown): InputSchema {
|
|
87
|
+
const schema = interfaceRecord(value, "operation input schema");
|
|
88
|
+
interfaceKeys(schema, ["type", "properties", "required", "additionalProperties"], [], "operation input schema");
|
|
89
|
+
if (schema.type !== "object" || schema.additionalProperties !== false) throw new Error("operation input requires a closed object schema");
|
|
90
|
+
const properties = interfaceRecord(schema.properties, "input properties");
|
|
91
|
+
if (Object.keys(properties).length > 128) throw new Error("input properties exceed their bound");
|
|
92
|
+
if (!Array.isArray(schema.required) || schema.required.length > 128) throw new Error("required inputs exceed their bound");
|
|
93
|
+
const required = schema.required.map((entry) => interfaceText(entry, "required input", 128));
|
|
94
|
+
if (new Set(required).size !== required.length || required.some((key) => !Object.hasOwn(properties, key))) throw new Error("required inputs are duplicate or missing");
|
|
95
|
+
return {
|
|
96
|
+
properties: Object.fromEntries(Object.entries(properties).map(([key, field]) => {
|
|
97
|
+
if (!/^[a-z][a-z0-9_]{0,127}$/u.test(key)) throw new Error("input field name is invalid");
|
|
98
|
+
return [key, fieldFromSchema(field)];
|
|
99
|
+
})),
|
|
100
|
+
required,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Generic inert schemas are validated, never resolved or compiled into code. */
|
|
105
|
+
export function validateInertInterfaceSchema(value: unknown): void {
|
|
106
|
+
const schema = interfaceRecord(value, "OpenAPI schema");
|
|
107
|
+
interfaceKeys(schema, [], ["type", "description", "properties", "required", "additionalProperties", "items", "enum", "const", "format", "minLength", "maxLength", "minimum", "maximum", "minItems", "maxItems", "x-ghostget-file", "x-ghostget-url-path-prefixes"], "OpenAPI schema");
|
|
108
|
+
if (schema.type !== undefined && !["object", "array", "string", "number", "integer", "boolean", "null"].includes(String(schema.type))) throw new Error("OpenAPI schema type is unsupported");
|
|
109
|
+
if (schema.description !== undefined) interfaceText(schema.description, "schema description", 4_096);
|
|
110
|
+
if (schema.properties !== undefined) {
|
|
111
|
+
const properties = interfaceRecord(schema.properties, "schema properties");
|
|
112
|
+
if (Object.keys(properties).length > 128) throw new Error("schema properties exceed their bound");
|
|
113
|
+
Object.values(properties).forEach(validateInertInterfaceSchema);
|
|
114
|
+
}
|
|
115
|
+
if (schema.items !== undefined) validateInertInterfaceSchema(schema.items);
|
|
116
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") throw new Error("schema additionalProperties must be boolean");
|
|
117
|
+
for (const key of ["minLength", "maxLength", "minimum", "maximum", "minItems", "maxItems"] as const) if (schema[key] !== undefined) number(schema[key], key, !["minimum", "maximum"].includes(key));
|
|
118
|
+
if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.length > 128 || schema.required.some((entry) => typeof entry !== "string"))) throw new Error("schema required is invalid");
|
|
119
|
+
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length > 256)) throw new Error("schema enum is invalid");
|
|
120
|
+
}
|