@fargowealth/fargo-ai-cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +9 -0
- package/node_modules/@fargowealth/website-connector-runtime/README.md +8 -0
- package/node_modules/@fargowealth/website-connector-runtime/package.json +24 -0
- package/node_modules/@fargowealth/website-connector-runtime/src/index.d.mts +116 -0
- package/node_modules/@fargowealth/website-connector-runtime/src/index.mjs +614 -0
- package/package.json +5 -1
- package/src/constants.mjs +1 -1
- package/src/website-browser-profile.mjs +34 -3
- package/src/website-cli.mjs +79 -5
- package/src/website-connector-definition.d.mts +1 -58
- package/src/website-connector-definition.mjs +1 -339
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { access, chmod, mkdir, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { access, chmod, mkdir, readFile, readdir, unlink } from "node:fs/promises";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { delimiter, join, resolve } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
@@ -91,6 +91,34 @@ export function defaultLocalWebsiteProfileDirectory(connectorId, options = {}) {
|
|
|
91
91
|
return join(options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? join(homeDirectory, ".config"), "fargo", "website-profiles", safeId, profileKey);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
export async function resolveLocalWebsiteProfileDirectory(connectorId, options = {}) {
|
|
95
|
+
if (options.profileDirectory) return resolve(options.profileDirectory);
|
|
96
|
+
const fallback = defaultLocalWebsiteProfileDirectory(connectorId, options);
|
|
97
|
+
const connectorDirectory = resolve(fallback, "..");
|
|
98
|
+
let entries;
|
|
99
|
+
try {
|
|
100
|
+
entries = await readdir(connectorDirectory, { withFileTypes: true });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.code === "ENOENT") return fallback;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const connectionProfiles = entries
|
|
106
|
+
.filter((entry) => entry.isDirectory() && /^bgc_[A-Za-z0-9_-]{43}$/.test(entry.name))
|
|
107
|
+
.map((entry) => join(connectorDirectory, entry.name));
|
|
108
|
+
if (connectionProfiles.length === 0) return fallback;
|
|
109
|
+
if (connectionProfiles.length === 1) return connectionProfiles[0];
|
|
110
|
+
throw new Error(`Multiple local Profiles exist for ${connectorId}; pass --profile-dir <path>`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function documentIsReadyAtOrigin(value, expectedOrigin) {
|
|
114
|
+
if (!["interactive", "complete"].includes(value?.readyState)) return false;
|
|
115
|
+
try {
|
|
116
|
+
return new URL(value.url).origin === expectedOrigin;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
export function parseCaptureSpec(value) {
|
|
95
123
|
const source = record(value, "Website Profile capture spec is invalid");
|
|
96
124
|
if (source.profileSchemaVersion !== WEBSITE_BROWSER_PROFILE_SCHEMA_VERSION) throw new Error("Website Profile schema is not supported");
|
|
@@ -174,7 +202,7 @@ async function waitForDocument(client, sessionId, expectedOrigin) {
|
|
|
174
202
|
returnByValue: true
|
|
175
203
|
}, sessionId).catch(() => ({}));
|
|
176
204
|
const value = result?.result?.value;
|
|
177
|
-
if (
|
|
205
|
+
if (documentIsReadyAtOrigin(value, expectedOrigin)) return;
|
|
178
206
|
await delay(100);
|
|
179
207
|
}
|
|
180
208
|
throw new Error(`Timed out while reading local storage for ${expectedOrigin}`);
|
|
@@ -185,7 +213,10 @@ async function waitForLoginConfirmation(spec, options) {
|
|
|
185
213
|
const input = options.stdin ?? process.stdin;
|
|
186
214
|
const output = options.stderr ?? process.stderr;
|
|
187
215
|
if (!input.isTTY) throw new Error("Interactive login requires a TTY; use --no-prompt only for an already authenticated local Profile");
|
|
188
|
-
|
|
216
|
+
const nextStep = options.purpose === "local-execution"
|
|
217
|
+
? "Complete login, then press Enter here to run locally; nothing will be uploaded."
|
|
218
|
+
: "Complete login, then press Enter here to upload only the allowlisted session state.";
|
|
219
|
+
output.write(`Fargo opened an isolated local Chrome Profile for ${spec.connectorId}.\n${nextStep}\n`);
|
|
189
220
|
const prompt = createInterface({ input, output });
|
|
190
221
|
try {
|
|
191
222
|
await prompt.question("");
|
package/src/website-cli.mjs
CHANGED
|
@@ -3,8 +3,13 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { callMcpTool } from "./mcp-client.mjs";
|
|
5
5
|
import { mcpToolPayload } from "./mcp-result.mjs";
|
|
6
|
-
import { captureLocalWebsiteProfile } from "./website-browser-profile.mjs";
|
|
7
|
-
import {
|
|
6
|
+
import { captureLocalWebsiteProfile, resolveLocalWebsiteProfileDirectory } from "./website-browser-profile.mjs";
|
|
7
|
+
import {
|
|
8
|
+
assertWebsiteConnectorRuntimeCompatibility,
|
|
9
|
+
executeWebsiteConnectorOperation,
|
|
10
|
+
parseWebsiteConnectorDefinition,
|
|
11
|
+
websiteConnectorRuntimeDescriptor
|
|
12
|
+
} from "./website-connector-definition.mjs";
|
|
8
13
|
|
|
9
14
|
const WEBSITE_BRIDGE_APP_ID = "website-bridge";
|
|
10
15
|
const WEBSITE_BRIDGE_URL = "https://website-bridge.fargowealth.app";
|
|
@@ -17,9 +22,14 @@ export async function runWebsiteCommand(args, stdout, store, options = {}) {
|
|
|
17
22
|
}
|
|
18
23
|
if (action === "validate") {
|
|
19
24
|
const definition = await readDefinition(args);
|
|
25
|
+
assertWebsiteConnectorRuntimeCompatibility(definition);
|
|
20
26
|
stdout.write(`${JSON.stringify(validationSummary(definition))}\n`);
|
|
21
27
|
return;
|
|
22
28
|
}
|
|
29
|
+
if (action === "runtime") {
|
|
30
|
+
stdout.write(`${JSON.stringify(websiteConnectorRuntimeDescriptor())}\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
23
33
|
if (action === "release") {
|
|
24
34
|
const definition = await readDefinition(args);
|
|
25
35
|
const expectedRevision = optionalInteger(args, "--expected-revision");
|
|
@@ -41,13 +51,50 @@ export async function runWebsiteCommand(args, stdout, store, options = {}) {
|
|
|
41
51
|
return;
|
|
42
52
|
}
|
|
43
53
|
if (action === "call") {
|
|
54
|
+
const mode = websiteCallMode(args);
|
|
44
55
|
const write = args.includes("--write");
|
|
56
|
+
if (mode === "local") {
|
|
57
|
+
const definition = await readDefinition(args);
|
|
58
|
+
const connectorId = optionValue(args, "--connector")?.trim();
|
|
59
|
+
if (connectorId && connectorId !== definition.connectorId) {
|
|
60
|
+
throw new Error("--connector must match the Connector definition in --file");
|
|
61
|
+
}
|
|
62
|
+
const operationId = requiredOption(args, "--operation");
|
|
63
|
+
const operation = definition.operations.find((candidate) => candidate.operationId === operationId);
|
|
64
|
+
if (!operation) throw new Error("website_operation_not_found");
|
|
65
|
+
if (!operation.readOnly && (!write || !args.includes("--confirm"))) {
|
|
66
|
+
throw new Error("Local write operations require --write --confirm");
|
|
67
|
+
}
|
|
68
|
+
let storageState;
|
|
69
|
+
if (operation.requiresConnection) {
|
|
70
|
+
const capture = options.captureWebsiteProfile ?? captureLocalWebsiteProfile;
|
|
71
|
+
storageState = await capture(localProfileSpec(definition), {
|
|
72
|
+
chromePath: optionValue(args, "--chrome-path"),
|
|
73
|
+
noPrompt: args.includes("--no-prompt"),
|
|
74
|
+
profileDirectory: await resolveLocalWebsiteProfileDirectory(definition.connectorId, {
|
|
75
|
+
profileDirectory: optionValue(args, "--profile-dir")
|
|
76
|
+
}),
|
|
77
|
+
purpose: "local-execution",
|
|
78
|
+
settleMs: optionalInteger(args, "--settle-ms"),
|
|
79
|
+
stderr: options.stderr,
|
|
80
|
+
stdin: options.stdin
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const execute = options.executeWebsiteOperation ?? executeWebsiteConnectorOperation;
|
|
84
|
+
const result = await execute(definition, operationId, await readArguments(args), {
|
|
85
|
+
fetchImpl: options.fetchImpl,
|
|
86
|
+
readOnlyOnly: !write,
|
|
87
|
+
...(storageState ? { storageState } : {})
|
|
88
|
+
});
|
|
89
|
+
stdout.write(`${JSON.stringify({ ...result, executionMode: "local" })}\n`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
45
92
|
const result = await callWebsiteBridge(store, write ? "website_connector_execute" : "website_connector_read", {
|
|
46
93
|
connectorId: requiredOption(args, "--connector"),
|
|
47
94
|
operationId: requiredOption(args, "--operation"),
|
|
48
95
|
arguments: await readArguments(args)
|
|
49
96
|
}, { ...options, confirmation: args.includes("--confirm") });
|
|
50
|
-
stdout.write(`${JSON.stringify(result)}\n`);
|
|
97
|
+
stdout.write(`${JSON.stringify({ ...result, executionMode: "remote" })}\n`);
|
|
51
98
|
return;
|
|
52
99
|
}
|
|
53
100
|
if (action === "connection-status") {
|
|
@@ -151,10 +198,35 @@ function validationSummary(definition) {
|
|
|
151
198
|
displayName: definition.displayName,
|
|
152
199
|
operationCount: definition.operations.length,
|
|
153
200
|
requiresConnection: definition.operations.some((operation) => operation.requiresConnection),
|
|
201
|
+
runtime: websiteConnectorRuntimeDescriptor(),
|
|
154
202
|
sha256: createHash("sha256").update(canonical).digest("hex")
|
|
155
203
|
};
|
|
156
204
|
}
|
|
157
205
|
|
|
206
|
+
function websiteCallMode(args) {
|
|
207
|
+
if (args.includes("--local")) return "local";
|
|
208
|
+
const mode = optionValue(args, "--mode") ?? "remote";
|
|
209
|
+
if (!["auto", "local", "remote"].includes(mode)) throw new Error("--mode must be auto, local, or remote");
|
|
210
|
+
return mode === "auto" ? (optionValue(args, "--file") ? "local" : "remote") : mode;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function localProfileSpec(definition) {
|
|
214
|
+
if (definition.auth.kind !== "browser_storage" || !definition.loginUrl) {
|
|
215
|
+
throw new Error("Website Connector does not define a local browser Profile");
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
allowedOrigins: definition.allowedOrigins,
|
|
219
|
+
auth: {
|
|
220
|
+
headerSources: definition.auth.headerSources.map(({ keySuffix, origin }) => ({ keySuffix, origin })),
|
|
221
|
+
sendCookies: definition.auth.sendCookies
|
|
222
|
+
},
|
|
223
|
+
connection: {},
|
|
224
|
+
connectorId: definition.connectorId,
|
|
225
|
+
loginUrl: definition.loginUrl,
|
|
226
|
+
profileSchemaVersion: "fargo.browser-profile/v1"
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
158
230
|
function optionalInteger(args, name) {
|
|
159
231
|
const raw = optionValue(args, name);
|
|
160
232
|
if (raw === undefined) return undefined;
|
|
@@ -264,12 +336,13 @@ function validatedUploadUrl(value, options) {
|
|
|
264
336
|
return url.toString();
|
|
265
337
|
}
|
|
266
338
|
|
|
267
|
-
export const WEBSITE_USAGE = "Usage: fargo website validate|release|list|operations|call|connect|sync|connection-status|authorize|authorize-complete|binding";
|
|
339
|
+
export const WEBSITE_USAGE = "Usage: fargo website validate|runtime|release|list|operations|call|connect|sync|connection-status|authorize|authorize-complete|binding";
|
|
268
340
|
|
|
269
341
|
export const WEBSITE_HELP = `Usage: fargo website <command> [options]
|
|
270
342
|
|
|
271
343
|
Connector authoring:
|
|
272
344
|
validate --file <path>
|
|
345
|
+
runtime
|
|
273
346
|
release --file <path> [--expected-revision <n>] --confirm
|
|
274
347
|
list
|
|
275
348
|
operations --connector <id>
|
|
@@ -278,7 +351,8 @@ Connection and execution:
|
|
|
278
351
|
connect --connector <id> [--chrome-path <path>] [--profile-dir <path>]
|
|
279
352
|
sync --connector <id> [--no-prompt] [--settle-ms <n>]
|
|
280
353
|
connection-status --connector <id>
|
|
281
|
-
call --connector <id> --operation <id> [--args <json> | --args-file <path>]
|
|
354
|
+
call --connector <id> --operation <id> [--args <json> | --args-file <path>] [--mode auto|remote]
|
|
355
|
+
call --file <path> --operation <id> --mode local [--profile-dir <path>] [--no-prompt]
|
|
282
356
|
call --connector <id> --operation <id> --write [--confirm]
|
|
283
357
|
|
|
284
358
|
Cloud login fallback:
|
|
@@ -1,58 +1 @@
|
|
|
1
|
-
export
|
|
2
|
-
additionalProperties: false;
|
|
3
|
-
properties: Record<string, {
|
|
4
|
-
enum?: Array<string | number | boolean>;
|
|
5
|
-
maxLength?: number;
|
|
6
|
-
type: "string" | "number" | "integer" | "boolean";
|
|
7
|
-
}>;
|
|
8
|
-
required: string[];
|
|
9
|
-
type: "object";
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export type WebsiteConnectorAuth =
|
|
13
|
-
| { kind: "none" }
|
|
14
|
-
| {
|
|
15
|
-
kind: "browser_storage";
|
|
16
|
-
sendCookies: boolean;
|
|
17
|
-
headerSources: Array<{ keySuffix: string; name: string; origin: string; prefix?: string }>;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type WebsiteConnectorOperation = {
|
|
21
|
-
operationId: string;
|
|
22
|
-
description: string;
|
|
23
|
-
readOnly: boolean;
|
|
24
|
-
requiresConnection: boolean;
|
|
25
|
-
riskLevel: "low" | "medium" | "high" | "critical";
|
|
26
|
-
inputSchema: WebsiteConnectorInputSchema;
|
|
27
|
-
request: {
|
|
28
|
-
origin: string;
|
|
29
|
-
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
30
|
-
path: string;
|
|
31
|
-
query: Record<string, string>;
|
|
32
|
-
headers: Record<string, string>;
|
|
33
|
-
body?: unknown;
|
|
34
|
-
};
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export type WebsiteConnectorDefinition = {
|
|
38
|
-
schemaVersion: "fargo.website-connector/v1";
|
|
39
|
-
connectorId: string;
|
|
40
|
-
displayName: string;
|
|
41
|
-
allowedOrigins: string[];
|
|
42
|
-
loginUrl?: string;
|
|
43
|
-
auth: WebsiteConnectorAuth;
|
|
44
|
-
operations: WebsiteConnectorOperation[];
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export const WEBSITE_CONNECTOR_SCHEMA_VERSION: "fargo.website-connector/v1";
|
|
48
|
-
export class WebsiteConnectorDefinitionError extends Error {
|
|
49
|
-
code: string;
|
|
50
|
-
path: string;
|
|
51
|
-
}
|
|
52
|
-
export function parseWebsiteConnectorDefinition(value: unknown): WebsiteConnectorDefinition;
|
|
53
|
-
export function connectorToolName(connectorId: string, operationId: string): string;
|
|
54
|
-
export function renderWebsiteOperation(definition: unknown, operationId: string, rawArguments: unknown): {
|
|
55
|
-
definition: WebsiteConnectorDefinition;
|
|
56
|
-
operation: WebsiteConnectorOperation;
|
|
57
|
-
request: { body?: unknown; headers: Record<string, string>; method: string; url: string };
|
|
58
|
-
};
|
|
1
|
+
export * from "@fargowealth/website-connector-runtime";
|
|
@@ -1,339 +1 @@
|
|
|
1
|
-
|
|
2
|
-
const OPERATION_ID = /^[a-z][a-z0-9_]{1,62}[a-z0-9]$/;
|
|
3
|
-
const TEMPLATE_ARGUMENT = /\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g;
|
|
4
|
-
const FORBIDDEN_HEADERS = new Set([
|
|
5
|
-
"connection",
|
|
6
|
-
"content-length",
|
|
7
|
-
"cookie",
|
|
8
|
-
"host",
|
|
9
|
-
"proxy-authorization",
|
|
10
|
-
"transfer-encoding",
|
|
11
|
-
"upgrade"
|
|
12
|
-
]);
|
|
13
|
-
|
|
14
|
-
export const WEBSITE_CONNECTOR_SCHEMA_VERSION = "fargo.website-connector/v1";
|
|
15
|
-
|
|
16
|
-
export class WebsiteConnectorDefinitionError extends Error {
|
|
17
|
-
constructor(code, path = "") {
|
|
18
|
-
super(path ? `${code}:${path}` : code);
|
|
19
|
-
this.name = "WebsiteConnectorDefinitionError";
|
|
20
|
-
this.code = code;
|
|
21
|
-
this.path = path;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function parseWebsiteConnectorDefinition(value) {
|
|
26
|
-
const source = record(value, "website_connector_definition_invalid", "/");
|
|
27
|
-
equal(source.schemaVersion, WEBSITE_CONNECTOR_SCHEMA_VERSION, "website_connector_schema_version_invalid", "/schemaVersion");
|
|
28
|
-
const connectorId = requiredText(source.connectorId, 64, "website_connector_id_invalid", "/connectorId");
|
|
29
|
-
if (!CONNECTOR_ID.test(connectorId)) fail("website_connector_id_invalid", "/connectorId");
|
|
30
|
-
const displayName = requiredText(source.displayName, 120, "website_connector_display_name_invalid", "/displayName");
|
|
31
|
-
const allowedOrigins = uniqueArray(source.allowedOrigins, 1, 12, "/allowedOrigins").map((origin, index) => (
|
|
32
|
-
exactHttpsOrigin(origin, "website_connector_origin_invalid", `/allowedOrigins/${index}`)
|
|
33
|
-
));
|
|
34
|
-
const loginUrl = optionalHttpsUrl(source.loginUrl, allowedOrigins, "/loginUrl");
|
|
35
|
-
const auth = parseAuthRecipe(source.auth, allowedOrigins);
|
|
36
|
-
if (auth.kind === "browser_storage" && !loginUrl) fail("website_connector_login_url_required", "/loginUrl");
|
|
37
|
-
const operations = uniqueArray(source.operations, 1, 80, "/operations").map((operation, index) => (
|
|
38
|
-
parseOperation(operation, allowedOrigins, index)
|
|
39
|
-
));
|
|
40
|
-
const operationIds = new Set();
|
|
41
|
-
for (const operation of operations) {
|
|
42
|
-
if (operationIds.has(operation.operationId)) fail("website_operation_id_duplicate", `/operations/${operation.operationId}`);
|
|
43
|
-
if (operation.requiresConnection && auth.kind !== "browser_storage") {
|
|
44
|
-
fail("website_operation_connection_auth_required", `/operations/${operation.operationId}/requiresConnection`);
|
|
45
|
-
}
|
|
46
|
-
operationIds.add(operation.operationId);
|
|
47
|
-
}
|
|
48
|
-
return {
|
|
49
|
-
schemaVersion: WEBSITE_CONNECTOR_SCHEMA_VERSION,
|
|
50
|
-
connectorId,
|
|
51
|
-
displayName,
|
|
52
|
-
allowedOrigins,
|
|
53
|
-
...(loginUrl ? { loginUrl } : {}),
|
|
54
|
-
auth,
|
|
55
|
-
operations
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function connectorToolName(connectorId, operationId) {
|
|
60
|
-
const left = String(connectorId || "").replaceAll("-", "_");
|
|
61
|
-
return `${left}_${operationId}`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function renderWebsiteOperation(definition, operationId, rawArguments) {
|
|
65
|
-
const parsed = parseWebsiteConnectorDefinition(definition);
|
|
66
|
-
const operation = parsed.operations.find((candidate) => candidate.operationId === operationId);
|
|
67
|
-
if (!operation) fail("website_operation_not_found", "/operationId");
|
|
68
|
-
const args = record(rawArguments ?? {}, "website_operation_arguments_invalid", "/arguments");
|
|
69
|
-
validateArguments(args, operation.inputSchema);
|
|
70
|
-
const path = renderString(operation.request.path, args, "/request/path");
|
|
71
|
-
const url = new URL(path, `${operation.request.origin}/`);
|
|
72
|
-
if (url.origin !== operation.request.origin) fail("website_operation_origin_escape", "/request/path");
|
|
73
|
-
for (const [name, argumentName] of Object.entries(operation.request.query)) {
|
|
74
|
-
const value = args[argumentName];
|
|
75
|
-
if (value !== undefined && value !== null) url.searchParams.set(name, scalarText(value, `/arguments/${argumentName}`));
|
|
76
|
-
}
|
|
77
|
-
const body = operation.request.body === undefined
|
|
78
|
-
? undefined
|
|
79
|
-
: renderBody(operation.request.body, args, "/request/body");
|
|
80
|
-
return {
|
|
81
|
-
definition: parsed,
|
|
82
|
-
operation,
|
|
83
|
-
request: {
|
|
84
|
-
body,
|
|
85
|
-
headers: { ...operation.request.headers },
|
|
86
|
-
method: operation.request.method,
|
|
87
|
-
url: url.toString()
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function parseAuthRecipe(value, allowedOrigins) {
|
|
93
|
-
const source = value === undefined ? { kind: "none" } : record(value, "website_auth_recipe_invalid", "/auth");
|
|
94
|
-
if (source.kind === "none") return { kind: "none" };
|
|
95
|
-
equal(source.kind, "browser_storage", "website_auth_recipe_invalid", "/auth/kind");
|
|
96
|
-
const sendCookies = source.sendCookies === true;
|
|
97
|
-
const headerSources = source.headerSources === undefined
|
|
98
|
-
? []
|
|
99
|
-
: uniqueArray(source.headerSources, 0, 12, "/auth/headerSources").map((item, index) => {
|
|
100
|
-
const binding = record(item, "website_auth_header_source_invalid", `/auth/headerSources/${index}`);
|
|
101
|
-
const name = headerName(binding.name, `/auth/headerSources/${index}/name`, true);
|
|
102
|
-
const origin = exactHttpsOrigin(binding.origin, "website_auth_header_source_invalid", `/auth/headerSources/${index}/origin`);
|
|
103
|
-
if (!allowedOrigins.includes(origin)) fail("website_auth_origin_not_allowed", `/auth/headerSources/${index}/origin`);
|
|
104
|
-
const keySuffix = requiredText(binding.keySuffix, 240, "website_auth_header_source_invalid", `/auth/headerSources/${index}/keySuffix`);
|
|
105
|
-
const prefix = optionalHeaderPrefix(binding.prefix, `/auth/headerSources/${index}/prefix`);
|
|
106
|
-
return { keySuffix, name, origin, ...(prefix ? { prefix } : {}) };
|
|
107
|
-
});
|
|
108
|
-
if (!sendCookies && headerSources.length === 0) fail("website_auth_recipe_empty", "/auth");
|
|
109
|
-
return { kind: "browser_storage", sendCookies, headerSources };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function parseOperation(value, allowedOrigins, index) {
|
|
113
|
-
const path = `/operations/${index}`;
|
|
114
|
-
const source = record(value, "website_operation_invalid", path);
|
|
115
|
-
const operationId = requiredText(source.operationId, 64, "website_operation_id_invalid", `${path}/operationId`);
|
|
116
|
-
if (!OPERATION_ID.test(operationId)) fail("website_operation_id_invalid", `${path}/operationId`);
|
|
117
|
-
const description = requiredText(source.description, 500, "website_operation_description_invalid", `${path}/description`);
|
|
118
|
-
const readOnly = source.readOnly === true;
|
|
119
|
-
const requiresConnection = source.requiresConnection === true;
|
|
120
|
-
const riskLevel = optionalEnum(source.riskLevel, ["low", "medium", "high", "critical"], readOnly ? "low" : "medium", `${path}/riskLevel`);
|
|
121
|
-
const inputSchema = parseInputSchema(source.inputSchema, `${path}/inputSchema`);
|
|
122
|
-
const requestSource = record(source.request, "website_operation_request_invalid", `${path}/request`);
|
|
123
|
-
const origin = exactHttpsOrigin(requestSource.origin, "website_operation_origin_invalid", `${path}/request/origin`);
|
|
124
|
-
if (!allowedOrigins.includes(origin)) fail("website_operation_origin_not_allowed", `${path}/request/origin`);
|
|
125
|
-
const method = optionalEnum(requestSource.method, ["GET", "POST", "PUT", "PATCH", "DELETE"], "GET", `${path}/request/method`);
|
|
126
|
-
if (readOnly && method !== "GET") fail("website_operation_read_only_method_invalid", `${path}/request/method`);
|
|
127
|
-
const requestPath = requiredText(requestSource.path, 2_000, "website_operation_path_invalid", `${path}/request/path`);
|
|
128
|
-
if (!requestPath.startsWith("/") || requestPath.startsWith("//") || requestPath.includes("\\")) {
|
|
129
|
-
fail("website_operation_path_invalid", `${path}/request/path`);
|
|
130
|
-
}
|
|
131
|
-
const pathArguments = [...requestPath.matchAll(TEMPLATE_ARGUMENT)].map((match) => match[1]);
|
|
132
|
-
const properties = record(inputSchema.properties ?? {}, "website_operation_input_schema_invalid", `${path}/inputSchema/properties`);
|
|
133
|
-
for (const argument of pathArguments) {
|
|
134
|
-
if (!Object.hasOwn(properties, argument)) fail("website_operation_template_argument_unknown", `${path}/request/path`);
|
|
135
|
-
}
|
|
136
|
-
const query = requestSource.query === undefined
|
|
137
|
-
? {}
|
|
138
|
-
: Object.fromEntries(Object.entries(record(requestSource.query, "website_operation_query_invalid", `${path}/request/query`)).map(([name, argument]) => {
|
|
139
|
-
if (!/^[A-Za-z0-9_.~-]{1,120}$/.test(name)) fail("website_operation_query_name_invalid", `${path}/request/query/${name}`);
|
|
140
|
-
const argumentName = requiredText(argument, 120, "website_operation_query_argument_invalid", `${path}/request/query/${name}`);
|
|
141
|
-
if (!Object.hasOwn(properties, argumentName)) fail("website_operation_query_argument_unknown", `${path}/request/query/${name}`);
|
|
142
|
-
return [name, argumentName];
|
|
143
|
-
}));
|
|
144
|
-
const headers = requestSource.headers === undefined
|
|
145
|
-
? {}
|
|
146
|
-
: Object.fromEntries(Object.entries(record(requestSource.headers, "website_operation_headers_invalid", `${path}/request/headers`)).map(([name, headerValue]) => {
|
|
147
|
-
const normalized = requiredText(headerValue, 500, "website_operation_header_value_invalid", `${path}/request/headers/${name}`);
|
|
148
|
-
if (/[\r\n\0]/.test(normalized)) fail("website_operation_header_value_invalid", `${path}/request/headers/${name}`);
|
|
149
|
-
return [headerName(name, `${path}/request/headers/${name}`, false), normalized];
|
|
150
|
-
}));
|
|
151
|
-
const body = requestSource.body;
|
|
152
|
-
if (body !== undefined && method === "GET") fail("website_operation_get_body_invalid", `${path}/request/body`);
|
|
153
|
-
if (body !== undefined) validateBodyTemplate(body, properties, `${path}/request/body`);
|
|
154
|
-
return {
|
|
155
|
-
operationId,
|
|
156
|
-
description,
|
|
157
|
-
readOnly,
|
|
158
|
-
requiresConnection,
|
|
159
|
-
riskLevel,
|
|
160
|
-
inputSchema,
|
|
161
|
-
request: {
|
|
162
|
-
origin,
|
|
163
|
-
method,
|
|
164
|
-
path: requestPath,
|
|
165
|
-
query,
|
|
166
|
-
headers,
|
|
167
|
-
...(body === undefined ? {} : { body })
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function optionalHeaderPrefix(value, path) {
|
|
173
|
-
if (value === undefined || value === null || value === "") return "";
|
|
174
|
-
if (typeof value !== "string" || value.length > 80 || /[\r\n\0]/.test(value)) {
|
|
175
|
-
fail("website_auth_header_source_invalid", path);
|
|
176
|
-
}
|
|
177
|
-
return value;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function parseInputSchema(value, path) {
|
|
181
|
-
const source = value === undefined
|
|
182
|
-
? { additionalProperties: false, properties: {}, type: "object" }
|
|
183
|
-
: record(value, "website_operation_input_schema_invalid", path);
|
|
184
|
-
equal(source.type, "object", "website_operation_input_schema_invalid", `${path}/type`);
|
|
185
|
-
if (source.additionalProperties !== false) fail("website_operation_input_schema_open", `${path}/additionalProperties`);
|
|
186
|
-
const properties = record(source.properties ?? {}, "website_operation_input_schema_invalid", `${path}/properties`);
|
|
187
|
-
const normalizedProperties = {};
|
|
188
|
-
for (const [name, candidate] of Object.entries(properties)) {
|
|
189
|
-
if (!/^[A-Za-z][A-Za-z0-9_]{0,119}$/.test(name)) fail("website_operation_input_name_invalid", `${path}/properties/${name}`);
|
|
190
|
-
const property = record(candidate, "website_operation_input_property_invalid", `${path}/properties/${name}`);
|
|
191
|
-
const type = optionalEnum(property.type, ["string", "number", "integer", "boolean"], "string", `${path}/properties/${name}/type`);
|
|
192
|
-
normalizedProperties[name] = {
|
|
193
|
-
type,
|
|
194
|
-
...(typeof property.maxLength === "number" ? { maxLength: boundedInteger(property.maxLength, 1, 20_000, `${path}/properties/${name}/maxLength`) } : {}),
|
|
195
|
-
...(Array.isArray(property.enum) ? { enum: property.enum.map((item) => scalar(item, `${path}/properties/${name}/enum`)) } : {})
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
const required = source.required === undefined ? [] : uniqueArray(source.required, 0, Object.keys(normalizedProperties).length, `${path}/required`).map(String);
|
|
199
|
-
for (const name of required) if (!Object.hasOwn(normalizedProperties, name)) fail("website_operation_required_unknown", `${path}/required`);
|
|
200
|
-
return { additionalProperties: false, properties: normalizedProperties, required, type: "object" };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function validateArguments(args, schema) {
|
|
204
|
-
for (const required of schema.required) if (!Object.hasOwn(args, required)) fail("website_operation_argument_required", `/arguments/${required}`);
|
|
205
|
-
for (const [name, value] of Object.entries(args)) {
|
|
206
|
-
const property = schema.properties[name];
|
|
207
|
-
if (!property) fail("website_operation_argument_unknown", `/arguments/${name}`);
|
|
208
|
-
if (property.type === "string" && typeof value !== "string") fail("website_operation_argument_type_invalid", `/arguments/${name}`);
|
|
209
|
-
if (property.type === "number" && (typeof value !== "number" || !Number.isFinite(value))) fail("website_operation_argument_type_invalid", `/arguments/${name}`);
|
|
210
|
-
if (property.type === "integer" && !Number.isInteger(value)) fail("website_operation_argument_type_invalid", `/arguments/${name}`);
|
|
211
|
-
if (property.type === "boolean" && typeof value !== "boolean") fail("website_operation_argument_type_invalid", `/arguments/${name}`);
|
|
212
|
-
if (property.maxLength && typeof value === "string" && value.length > property.maxLength) fail("website_operation_argument_too_long", `/arguments/${name}`);
|
|
213
|
-
if (property.enum && !property.enum.some((candidate) => Object.is(candidate, value))) fail("website_operation_argument_enum_invalid", `/arguments/${name}`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function validateBodyTemplate(value, properties, path) {
|
|
218
|
-
if (typeof value === "string") {
|
|
219
|
-
if (value.startsWith("$") && !Object.hasOwn(properties, value.slice(1))) fail("website_operation_body_argument_unknown", path);
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
if (value === null || typeof value === "number" || typeof value === "boolean") return;
|
|
223
|
-
if (Array.isArray(value)) {
|
|
224
|
-
value.forEach((item, index) => validateBodyTemplate(item, properties, `${path}/${index}`));
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
const source = record(value, "website_operation_body_invalid", path);
|
|
228
|
-
for (const [name, item] of Object.entries(source)) validateBodyTemplate(item, properties, `${path}/${name}`);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function renderBody(value, args, path) {
|
|
232
|
-
if (typeof value === "string") {
|
|
233
|
-
if (value.startsWith("$")) return args[value.slice(1)];
|
|
234
|
-
return renderString(value, args, path);
|
|
235
|
-
}
|
|
236
|
-
if (Array.isArray(value)) return value.map((item, index) => renderBody(item, args, `${path}/${index}`));
|
|
237
|
-
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, renderBody(item, args, `${path}/${name}`)]));
|
|
238
|
-
return value;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function renderString(value, args, path) {
|
|
242
|
-
return String(value).replace(TEMPLATE_ARGUMENT, (_match, name) => encodeURIComponent(scalarText(args[name], `/arguments/${name || path}`)));
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function scalarText(value, path) {
|
|
246
|
-
scalar(value, path);
|
|
247
|
-
return String(value);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function scalar(value, path) {
|
|
251
|
-
if (!["string", "number", "boolean"].includes(typeof value) || (typeof value === "number" && !Number.isFinite(value))) {
|
|
252
|
-
fail("website_operation_argument_scalar_required", path);
|
|
253
|
-
}
|
|
254
|
-
return value;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function headerName(value, path, allowAuthorization) {
|
|
258
|
-
const name = requiredText(value, 100, "website_operation_header_name_invalid", path);
|
|
259
|
-
const normalized = name.toLowerCase();
|
|
260
|
-
if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(name) || FORBIDDEN_HEADERS.has(normalized) || (!allowAuthorization && normalized === "authorization")) {
|
|
261
|
-
fail("website_operation_header_name_invalid", path);
|
|
262
|
-
}
|
|
263
|
-
return name;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function exactHttpsOrigin(value, code, path) {
|
|
267
|
-
const text = requiredText(value, 500, code, path);
|
|
268
|
-
try {
|
|
269
|
-
const url = new URL(text);
|
|
270
|
-
if (url.protocol !== "https:" || url.origin !== text || url.username || url.password || !publicHostname(url.hostname)) fail(code, path);
|
|
271
|
-
return url.origin;
|
|
272
|
-
} catch (error) {
|
|
273
|
-
if (error instanceof WebsiteConnectorDefinitionError) throw error;
|
|
274
|
-
fail(code, path);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function publicHostname(value) {
|
|
279
|
-
const hostname = String(value).toLowerCase().replace(/^\[|\]$/g, "");
|
|
280
|
-
if (!hostname.includes(".") || /^[0-9.]+$/.test(hostname) || hostname.includes(":")) return false;
|
|
281
|
-
return !hostname.endsWith(".localhost")
|
|
282
|
-
&& !hostname.endsWith(".local")
|
|
283
|
-
&& !hostname.endsWith(".internal")
|
|
284
|
-
&& hostname !== "metadata.google.internal";
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function optionalHttpsUrl(value, allowedOrigins, path) {
|
|
288
|
-
if (value === undefined || value === null || value === "") return undefined;
|
|
289
|
-
try {
|
|
290
|
-
const url = new URL(requiredText(value, 2_000, "website_connector_login_url_invalid", path));
|
|
291
|
-
if (url.protocol !== "https:" || !allowedOrigins.includes(url.origin) || url.username || url.password || url.hash) fail("website_connector_login_url_invalid", path);
|
|
292
|
-
return url.toString();
|
|
293
|
-
} catch (error) {
|
|
294
|
-
if (error instanceof WebsiteConnectorDefinitionError) throw error;
|
|
295
|
-
fail("website_connector_login_url_invalid", path);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function record(value, code, path) {
|
|
300
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code, path);
|
|
301
|
-
return value;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function uniqueArray(value, min, max, path) {
|
|
305
|
-
if (!Array.isArray(value) || value.length < min || value.length > max) fail("website_connector_array_invalid", path);
|
|
306
|
-
const serialized = value.map((item) => typeof item === "string" ? item : JSON.stringify(item));
|
|
307
|
-
if (new Set(serialized).size !== serialized.length) fail("website_connector_array_duplicate", path);
|
|
308
|
-
return value;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function requiredText(value, max, code, path) {
|
|
312
|
-
const text = typeof value === "string" ? value.trim() : "";
|
|
313
|
-
if (!text || text.length > max) fail(code, path);
|
|
314
|
-
return text;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function optionalText(value, max, code, path) {
|
|
318
|
-
if (value === undefined || value === null || value === "") return undefined;
|
|
319
|
-
return requiredText(value, max, code, path);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function optionalEnum(value, allowed, fallback, path) {
|
|
323
|
-
const candidate = value === undefined ? fallback : value;
|
|
324
|
-
if (!allowed.includes(candidate)) fail("website_connector_enum_invalid", path);
|
|
325
|
-
return candidate;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function boundedInteger(value, min, max, path) {
|
|
329
|
-
if (!Number.isInteger(value) || value < min || value > max) fail("website_connector_integer_invalid", path);
|
|
330
|
-
return value;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function equal(actual, expected, code, path) {
|
|
334
|
-
if (actual !== expected) fail(code, path);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function fail(code, path) {
|
|
338
|
-
throw new WebsiteConnectorDefinitionError(code, path);
|
|
339
|
-
}
|
|
1
|
+
export * from "@fargowealth/website-connector-runtime";
|