@fargowealth/fargo-ai-cli 1.2.1 → 1.3.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 +8 -9
- package/README.md +40 -20
- package/package.json +1 -1
- package/src/cli.mjs +9 -9
- package/src/constants.mjs +1 -1
- package/src/website-browser-profile.mjs +346 -0
- package/src/website-cli.mjs +290 -0
- package/src/website-connector-definition.d.mts +58 -0
- package/src/website-connector-definition.mjs +339 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
policy stored on the prepared deployment.
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
- Add `fargo website connect` and `fargo website sync` with an isolated local
|
|
6
|
+
Chrome Profile and allowlisted CDP capture.
|
|
7
|
+
- Negotiate one-time Profile import capabilities through Website Bridge MCP,
|
|
8
|
+
then upload browser state directly to the Website Bridge data endpoint.
|
|
9
|
+
- Keep raw Cookie/localStorage and upload secrets out of MCP parameters,
|
|
10
|
+
command output, and the Fargo credential store.
|
|
12
11
|
|
|
13
12
|
## 1.2.0
|
|
14
13
|
|
package/README.md
CHANGED
|
@@ -47,6 +47,44 @@ fargo mcp call lego_app_read --args '{"action":"get","appId":"app_xxx"}'
|
|
|
47
47
|
fargo mcp call lego_app_read --args-file ./arguments.json
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## Website Connectors
|
|
51
|
+
|
|
52
|
+
`fargo website` is the authoring and control surface for cloud-hosted Website
|
|
53
|
+
Connectors. Connector definitions and immutable releases are stored by Fargo
|
|
54
|
+
Website Bridge; per-user Cookie/localStorage remains encrypted in BrowserGateway
|
|
55
|
+
and is never written to the CLI credential store or emitted as a JSON file. The
|
|
56
|
+
CLI keeps a dedicated local Chrome Profile so the user can log in locally and
|
|
57
|
+
synchronize later; that Profile is isolated from the user's normal Chrome data.
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
fargo website validate --file connector.json
|
|
61
|
+
fargo website release --file connector.json --expected-revision 0 --confirm
|
|
62
|
+
fargo website list
|
|
63
|
+
fargo website operations --connector earningshub
|
|
64
|
+
fargo website connect --connector earningshub
|
|
65
|
+
fargo website sync --connector earningshub
|
|
66
|
+
fargo website sync --connector earningshub --no-prompt --settle-ms 5000
|
|
67
|
+
fargo website connection-status --connector earningshub
|
|
68
|
+
fargo website call --connector earningshub --operation get_symbol --args '{"symbol":"AMZN"}'
|
|
69
|
+
fargo website binding
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`connect` opens the Connector login URL in a Fargo-owned local Chrome Profile.
|
|
73
|
+
After login, the CLI captures only the Connector-declared Cookie and
|
|
74
|
+
localStorage selectors. MCP negotiates a five-minute, single-use upload
|
|
75
|
+
capability using SHA-256 digests; the Profile body then goes directly to
|
|
76
|
+
`https://website-bridge.fargowealth.app` and never crosses the central MCP.
|
|
77
|
+
`sync` reopens the same local Profile and uses optimistic Profile-version
|
|
78
|
+
protection before replacing the encrypted cloud state. `--no-prompt` is for a
|
|
79
|
+
Profile that is already authenticated.
|
|
80
|
+
|
|
81
|
+
Connector operations remain brokered through App MCP `website-bridge`, so the
|
|
82
|
+
same operations are available to Codex, WorkBuddy, other Agents, and Teammates.
|
|
83
|
+
V1 releases are owner-private. A Teammate binding delegates the invoking user's
|
|
84
|
+
identity and contains neither a `connectionId` nor website credentials. The old
|
|
85
|
+
`authorize` / `authorize-complete` Cloud Live View commands remain an optional
|
|
86
|
+
fallback, not the primary connection path.
|
|
87
|
+
|
|
50
88
|
## LEGO source snapshots
|
|
51
89
|
|
|
52
90
|
```sh
|
|
@@ -78,7 +116,7 @@ fargo lego deploy \
|
|
|
78
116
|
--entrypoint index.html \
|
|
79
117
|
--version 1.4.0 \
|
|
80
118
|
--name "Navigation refresh" \
|
|
81
|
-
--notes-file
|
|
119
|
+
--notes-file CHANGELOG.md
|
|
82
120
|
```
|
|
83
121
|
|
|
84
122
|
For Workers use `--target worker`; the default entrypoint becomes `index.js`.
|
|
@@ -104,15 +142,9 @@ fargo lego deploy \
|
|
|
104
142
|
--login-required yes \
|
|
105
143
|
--version 1.4.0 \
|
|
106
144
|
--name "Full-stack release" \
|
|
107
|
-
--notes-file
|
|
145
|
+
--notes-file CHANGELOG.md
|
|
108
146
|
```
|
|
109
147
|
|
|
110
|
-
Release notes are limited to 4,000 characters by both the CLI and hosted
|
|
111
|
-
deployment service. Pass concise notes for one version with `--notes`, or keep
|
|
112
|
-
them in a per-release file such as `RELEASE_NOTES.md`. Do not pass an entire
|
|
113
|
-
multi-version changelog unless it is within the limit and intentionally belongs
|
|
114
|
-
to the new release.
|
|
115
|
-
|
|
116
148
|
Use repeated `--assets-run-worker-first-route <glob>` options instead of the
|
|
117
149
|
boolean flag when only selected routes should invoke the Worker first. The
|
|
118
150
|
hosted LEGO publisher uploads the assets and attaches the binding; Cloudflare
|
|
@@ -152,18 +184,6 @@ MCP deployment contract. The result is `ready`, never automatically published.
|
|
|
152
184
|
Use `--dry-run` to validate both local packages without authentication or
|
|
153
185
|
network writes.
|
|
154
186
|
|
|
155
|
-
After reviewing the returned deployment and receiving explicit approval,
|
|
156
|
-
publish that exact deployment through Fargo Pass MCP:
|
|
157
|
-
|
|
158
|
-
```sh
|
|
159
|
-
fargo mcp call lego_deployment_release --args \
|
|
160
|
-
'{"action":"publish","appId":"app_xxx","deploymentId":"dep_xxx"}'
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
Publication is a separate production write. The prepared deployment already
|
|
164
|
-
contains its security-checked access policy, so callers normally do not need to
|
|
165
|
-
repeat `visibility`, `loginRequired`, or `accessTargets` at publish time.
|
|
166
|
-
|
|
167
187
|
Deployment artifacts preserve binary bytes and include
|
|
168
188
|
`.fargo-deploy/manifest.json`. Default limits are 1,000 files, 25 MB total,
|
|
169
189
|
20 MB per file, and a 25 MB ZIP.
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { applyLegoMigrations, planLegoMigrations } from "./lego-migrations.mjs";
|
|
|
9
9
|
import { callMcpTool, listMcpTools } from "./mcp-client.mjs";
|
|
10
10
|
import { runMcpProxy } from "./mcp-proxy.mjs";
|
|
11
11
|
import { installAgentMcp } from "./agent-install.mjs";
|
|
12
|
+
import { runWebsiteCommand } from "./website-cli.mjs";
|
|
12
13
|
|
|
13
14
|
export async function runCli(args, options = {}) {
|
|
14
15
|
const stdout = options.stdout ?? process.stdout;
|
|
@@ -61,6 +62,10 @@ export async function runCli(args, options = {}) {
|
|
|
61
62
|
stdout.write(`${JSON.stringify(await callMcpTool(store, toolName, toolArgs, options))}\n`);
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
65
|
+
if (args[0] === "website") {
|
|
66
|
+
await runWebsiteCommand(args, stdout, store, options);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
64
69
|
if (args[0] === "lego" && args[1] === "snapshot") {
|
|
65
70
|
await runLegoSnapshotCommand(args, stdout, store, options);
|
|
66
71
|
return;
|
|
@@ -126,7 +131,7 @@ export async function runCli(args, options = {}) {
|
|
|
126
131
|
await installAgentConnection(args, "workbuddy", stdout, store, options);
|
|
127
132
|
return;
|
|
128
133
|
}
|
|
129
|
-
throw new Error("Usage: fargo auth login|device|status|logout | fargo agent install --agent <agent> | fargo mcp serve|tools|call | fargo lego snapshot upload|list|download | fargo lego deploy | fargo lego d1 migrate plan|apply | fargo workbuddy install | fargo version");
|
|
134
|
+
throw new Error("Usage: fargo auth login|device|status|logout | fargo agent install --agent <agent> | fargo mcp serve|tools|call | fargo website validate|release|list|operations|call|connect|sync|connection-status|authorize|authorize-complete|binding | fargo lego snapshot upload|list|download | fargo lego deploy | fargo lego d1 migrate plan|apply | fargo workbuddy install | fargo version");
|
|
130
135
|
} catch (error) {
|
|
131
136
|
stderr.write(`fargo: ${error instanceof Error ? error.message : "unknown error"}\n`);
|
|
132
137
|
if (options.throwOnError) throw error;
|
|
@@ -140,6 +145,7 @@ Commands:
|
|
|
140
145
|
auth login|device|status|logout
|
|
141
146
|
agent install --agent <codex|claude-code|cursor|workbuddy>
|
|
142
147
|
mcp serve|tools|call
|
|
148
|
+
website validate|release|list|operations|call|connect|sync|connection-status|authorize|authorize-complete|binding
|
|
143
149
|
lego snapshot upload|list|download
|
|
144
150
|
lego deploy
|
|
145
151
|
lego d1 migrate plan|apply
|
|
@@ -157,8 +163,7 @@ Required:
|
|
|
157
163
|
--target <pages|worker> Deployment target
|
|
158
164
|
--version <version> Release version
|
|
159
165
|
--name <name> Release name
|
|
160
|
-
--notes <text>
|
|
161
|
-
--notes-file <path> Read one release's notes from a file; same limit
|
|
166
|
+
--notes <text> Release notes (or --notes-file <path>)
|
|
162
167
|
|
|
163
168
|
Source and execution:
|
|
164
169
|
--source <directory> Source snapshot root (default: .)
|
|
@@ -189,18 +194,13 @@ Worker Runtime Topology:
|
|
|
189
194
|
--queue-max-concurrency <n> Optional maximum concurrent consumers
|
|
190
195
|
--queue-retry-delay <s> Optional retry delay in seconds
|
|
191
196
|
|
|
192
|
-
Result and publication:
|
|
193
|
-
This command creates a ready, unpublished deployment. Production traffic is
|
|
194
|
-
unchanged. After review and explicit approval, publish the returned deployment
|
|
195
|
-
with Fargo Pass MCP lego_deployment_release(action=publish).
|
|
196
|
-
|
|
197
197
|
Example:
|
|
198
198
|
fargo lego deploy --app app_xxx --source . \\
|
|
199
199
|
--deploy-dir .build/worker --target worker --entrypoint index.js \\
|
|
200
200
|
--assets-dir dist --assets-binding ASSETS \\
|
|
201
201
|
--assets-not-found-handling single-page-application \\
|
|
202
202
|
--assets-run-worker-first --version 1.4.0 --name "Full-stack release" \\
|
|
203
|
-
--notes-file
|
|
203
|
+
--notes-file CHANGELOG.md
|
|
204
204
|
|
|
205
205
|
.fargo-assets/ is an internal archive directory reserved for Fargo CLI.
|
|
206
206
|
Do not create or copy it manually; always pass browser output with --assets-dir.
|
package/src/constants.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.
|
|
1
|
+
export const CLI_VERSION = "1.3.0";
|
|
2
2
|
export const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
3
3
|
export const DEFAULT_AUTH_URL = "https://auth.fargowealth.app";
|
|
4
4
|
export const DEFAULT_API_URL = "https://mcp.fargowealth.app";
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, chmod, mkdir, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { delimiter, join, resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
|
|
7
|
+
export const WEBSITE_BROWSER_PROFILE_SCHEMA_VERSION = "fargo.browser-profile/v1";
|
|
8
|
+
|
|
9
|
+
export async function captureLocalWebsiteProfile(spec, options = {}) {
|
|
10
|
+
const captureSpec = parseCaptureSpec(spec);
|
|
11
|
+
const profileDirectory = resolve(options.profileDirectory ?? defaultLocalWebsiteProfileDirectory(captureSpec.connectorId, {
|
|
12
|
+
...options,
|
|
13
|
+
profileKey: captureSpec.localProfileKey
|
|
14
|
+
}));
|
|
15
|
+
await mkdir(profileDirectory, { recursive: true, mode: 0o700 });
|
|
16
|
+
if (platform() !== "win32") await chmod(profileDirectory, 0o700);
|
|
17
|
+
const chromePath = await findChromeExecutable(options.chromePath, options);
|
|
18
|
+
const activePortPath = join(profileDirectory, "DevToolsActivePort");
|
|
19
|
+
await unlink(activePortPath).catch((error) => {
|
|
20
|
+
if (error?.code !== "ENOENT") throw error;
|
|
21
|
+
});
|
|
22
|
+
const child = spawn(chromePath, [
|
|
23
|
+
"--remote-debugging-address=127.0.0.1",
|
|
24
|
+
"--remote-debugging-port=0",
|
|
25
|
+
`--user-data-dir=${profileDirectory}`,
|
|
26
|
+
...(options.headless ? ["--headless=new"] : []),
|
|
27
|
+
"--no-first-run",
|
|
28
|
+
"--no-default-browser-check",
|
|
29
|
+
"--new-window",
|
|
30
|
+
captureSpec.loginUrl
|
|
31
|
+
], { stdio: "ignore" });
|
|
32
|
+
let client;
|
|
33
|
+
try {
|
|
34
|
+
const endpoint = await waitForDevToolsEndpoint(activePortPath, child, options);
|
|
35
|
+
client = await CdpClient.connect(endpoint);
|
|
36
|
+
const sessionId = await pageSession(client, captureSpec);
|
|
37
|
+
if (!options.noPrompt) await waitForLoginConfirmation(captureSpec, options);
|
|
38
|
+
else await delay(boundedInteger(options.settleMs, 3_000, 0, 60_000));
|
|
39
|
+
const cookies = await browserCookies(client);
|
|
40
|
+
const origins = await originStorage(client, sessionId, captureSpec);
|
|
41
|
+
return buildWebsiteBrowserProfile(captureSpec, { cookies, origins }, options.now?.() ?? new Date());
|
|
42
|
+
} finally {
|
|
43
|
+
if (client) await client.call("Browser.close").catch(() => {});
|
|
44
|
+
await waitForExit(child, 5_000).catch(() => child.kill("SIGTERM"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildWebsiteBrowserProfile(spec, state, capturedAt = new Date()) {
|
|
49
|
+
const captureSpec = parseCaptureSpec(spec);
|
|
50
|
+
const allowedHosts = captureSpec.allowedOrigins.map((origin) => new URL(origin).hostname.toLowerCase());
|
|
51
|
+
const cookies = captureSpec.auth.sendCookies
|
|
52
|
+
? array(state.cookies).filter((cookie) => cookieAllowed(cookie.domain, allowedHosts)).map(normalizeCookie)
|
|
53
|
+
: [];
|
|
54
|
+
const suffixesByOrigin = new Map();
|
|
55
|
+
for (const source of captureSpec.auth.headerSources) {
|
|
56
|
+
const suffixes = suffixesByOrigin.get(source.origin) ?? new Set();
|
|
57
|
+
suffixes.add(source.keySuffix);
|
|
58
|
+
suffixesByOrigin.set(source.origin, suffixes);
|
|
59
|
+
}
|
|
60
|
+
const origins = array(state.origins).flatMap((entry) => {
|
|
61
|
+
const origin = String(entry.origin || "");
|
|
62
|
+
const suffixes = suffixesByOrigin.get(origin);
|
|
63
|
+
if (!suffixes) return [];
|
|
64
|
+
const localStorage = array(entry.localStorage).filter((item) => (
|
|
65
|
+
[...suffixes].some((suffix) => String(item.name || "").endsWith(suffix))
|
|
66
|
+
)).map((item) => ({ name: String(item.name), value: String(item.value) }));
|
|
67
|
+
return localStorage.length > 0 ? [{ localStorage, origin }] : [];
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
capturedAt: new Date(capturedAt).toISOString(),
|
|
71
|
+
connectorId: captureSpec.connectorId,
|
|
72
|
+
cookies,
|
|
73
|
+
origins,
|
|
74
|
+
schemaVersion: WEBSITE_BROWSER_PROFILE_SCHEMA_VERSION
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function defaultLocalWebsiteProfileDirectory(connectorId, options = {}) {
|
|
79
|
+
const safeId = requiredConnectorId(connectorId);
|
|
80
|
+
const profileKey = typeof options.profileKey === "string" && /^bgc_[A-Za-z0-9_-]{43}$/.test(options.profileKey)
|
|
81
|
+
? options.profileKey
|
|
82
|
+
: "default";
|
|
83
|
+
const currentPlatform = options.platform ?? platform();
|
|
84
|
+
const homeDirectory = options.home ?? homedir();
|
|
85
|
+
if (currentPlatform === "darwin") {
|
|
86
|
+
return join(homeDirectory, "Library", "Application Support", "Fargo AI", "Website Profiles", safeId, profileKey);
|
|
87
|
+
}
|
|
88
|
+
if (currentPlatform === "win32") {
|
|
89
|
+
return join(options.appData ?? process.env.APPDATA ?? join(homeDirectory, "AppData", "Roaming"), "Fargo AI", "Website Profiles", safeId, profileKey);
|
|
90
|
+
}
|
|
91
|
+
return join(options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? join(homeDirectory, ".config"), "fargo", "website-profiles", safeId, profileKey);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseCaptureSpec(value) {
|
|
95
|
+
const source = record(value, "Website Profile capture spec is invalid");
|
|
96
|
+
if (source.profileSchemaVersion !== WEBSITE_BROWSER_PROFILE_SCHEMA_VERSION) throw new Error("Website Profile schema is not supported");
|
|
97
|
+
const connectorId = requiredConnectorId(source.connectorId);
|
|
98
|
+
const allowedOrigins = array(source.allowedOrigins).map(exactHttpsOrigin);
|
|
99
|
+
if (allowedOrigins.length < 1 || allowedOrigins.length > 12 || new Set(allowedOrigins).size !== allowedOrigins.length) {
|
|
100
|
+
throw new Error("Website Profile allowed origins are invalid");
|
|
101
|
+
}
|
|
102
|
+
const loginUrl = new URL(String(source.loginUrl || ""));
|
|
103
|
+
if (loginUrl.protocol !== "https:" || !allowedOrigins.includes(loginUrl.origin) || loginUrl.username || loginUrl.password) {
|
|
104
|
+
throw new Error("Website Profile login URL is invalid");
|
|
105
|
+
}
|
|
106
|
+
const auth = record(source.auth, "Website Profile authentication recipe is invalid");
|
|
107
|
+
const connection = record(source.connection ?? {}, "Website Connection descriptor is invalid");
|
|
108
|
+
const localProfileKey = typeof connection.connectionId === "string" && /^bgc_[A-Za-z0-9_-]{43}$/.test(connection.connectionId)
|
|
109
|
+
? connection.connectionId
|
|
110
|
+
: undefined;
|
|
111
|
+
const headerSources = array(auth.headerSources).map((item) => {
|
|
112
|
+
const binding = record(item, "Website Profile storage selector is invalid");
|
|
113
|
+
const origin = exactHttpsOrigin(binding.origin);
|
|
114
|
+
const keySuffix = String(binding.keySuffix || "");
|
|
115
|
+
if (!allowedOrigins.includes(origin) || !keySuffix || keySuffix.length > 240) {
|
|
116
|
+
throw new Error("Website Profile storage selector is invalid");
|
|
117
|
+
}
|
|
118
|
+
return { keySuffix, origin };
|
|
119
|
+
});
|
|
120
|
+
if (headerSources.length > 12) throw new Error("Website Profile storage selector is invalid");
|
|
121
|
+
if (auth.sendCookies !== true && headerSources.length === 0) throw new Error("Website Profile authentication recipe is empty");
|
|
122
|
+
return {
|
|
123
|
+
allowedOrigins,
|
|
124
|
+
auth: { headerSources, sendCookies: auth.sendCookies === true },
|
|
125
|
+
connectorId,
|
|
126
|
+
...(localProfileKey ? { localProfileKey } : {}),
|
|
127
|
+
loginUrl: loginUrl.toString(),
|
|
128
|
+
profileSchemaVersion: WEBSITE_BROWSER_PROFILE_SCHEMA_VERSION
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function originStorage(client, sessionId, spec) {
|
|
133
|
+
await client.call("Page.enable", {}, sessionId);
|
|
134
|
+
await client.call("Runtime.enable", {}, sessionId);
|
|
135
|
+
const origins = [...new Set(spec.auth.headerSources.map((source) => source.origin))];
|
|
136
|
+
const result = [];
|
|
137
|
+
for (const origin of origins) {
|
|
138
|
+
await client.call("Page.navigate", { url: `${origin}/` }, sessionId);
|
|
139
|
+
await waitForDocument(client, sessionId, origin);
|
|
140
|
+
const evaluated = await client.call("Runtime.evaluate", {
|
|
141
|
+
expression: "Object.entries(window.localStorage).map(([name,value])=>({name,value}))",
|
|
142
|
+
returnByValue: true
|
|
143
|
+
}, sessionId);
|
|
144
|
+
result.push({ localStorage: array(evaluated?.result?.value), origin });
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function browserCookies(client) {
|
|
150
|
+
const result = await client.call("Storage.getCookies");
|
|
151
|
+
return array(result.cookies);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function pageSession(client, spec) {
|
|
155
|
+
const targets = await client.call("Target.getTargets");
|
|
156
|
+
const pages = array(targets.targetInfos).filter((target) => target.type === "page");
|
|
157
|
+
const page = pages.find((target) => {
|
|
158
|
+
try {
|
|
159
|
+
return spec.allowedOrigins.includes(new URL(target.url).origin);
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}) ?? pages[0];
|
|
164
|
+
if (!page?.targetId) throw new Error("Chrome did not create a page for Website Profile capture");
|
|
165
|
+
const attached = await client.call("Target.attachToTarget", { flatten: true, targetId: page.targetId });
|
|
166
|
+
if (!attached.sessionId) throw new Error("Could not attach to the local Chrome page");
|
|
167
|
+
return attached.sessionId;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function waitForDocument(client, sessionId, expectedOrigin) {
|
|
171
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
172
|
+
const result = await client.call("Runtime.evaluate", {
|
|
173
|
+
expression: "({readyState:document.readyState,url:location.href})",
|
|
174
|
+
returnByValue: true
|
|
175
|
+
}, sessionId).catch(() => ({}));
|
|
176
|
+
const value = result?.result?.value;
|
|
177
|
+
if (value?.readyState === "complete" && new URL(value.url).origin === expectedOrigin) return;
|
|
178
|
+
await delay(100);
|
|
179
|
+
}
|
|
180
|
+
throw new Error(`Timed out while reading local storage for ${expectedOrigin}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function waitForLoginConfirmation(spec, options) {
|
|
184
|
+
if (options.waitForLogin) return options.waitForLogin(spec);
|
|
185
|
+
const input = options.stdin ?? process.stdin;
|
|
186
|
+
const output = options.stderr ?? process.stderr;
|
|
187
|
+
if (!input.isTTY) throw new Error("Interactive login requires a TTY; use --no-prompt only for an already authenticated local Profile");
|
|
188
|
+
output.write(`Fargo opened an isolated local Chrome Profile for ${spec.connectorId}.\nComplete login, then press Enter here to upload only the allowlisted session state.\n`);
|
|
189
|
+
const prompt = createInterface({ input, output });
|
|
190
|
+
try {
|
|
191
|
+
await prompt.question("");
|
|
192
|
+
} finally {
|
|
193
|
+
prompt.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function findChromeExecutable(explicitPath, options) {
|
|
198
|
+
if (explicitPath) {
|
|
199
|
+
await access(explicitPath);
|
|
200
|
+
return resolve(explicitPath);
|
|
201
|
+
}
|
|
202
|
+
const currentPlatform = options.platform ?? platform();
|
|
203
|
+
const candidates = currentPlatform === "darwin"
|
|
204
|
+
? [
|
|
205
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
206
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
207
|
+
]
|
|
208
|
+
: currentPlatform === "win32"
|
|
209
|
+
? [
|
|
210
|
+
join(process.env.PROGRAMFILES ?? "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"),
|
|
211
|
+
join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe")
|
|
212
|
+
]
|
|
213
|
+
: executableCandidates(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]);
|
|
214
|
+
for (const candidate of candidates) {
|
|
215
|
+
try {
|
|
216
|
+
await access(candidate);
|
|
217
|
+
return candidate;
|
|
218
|
+
} catch { /* Try the next supported browser path. */ }
|
|
219
|
+
}
|
|
220
|
+
throw new Error("Google Chrome or Chromium was not found; pass --chrome-path <path>");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function executableCandidates(names) {
|
|
224
|
+
return String(process.env.PATH || "").split(delimiter).flatMap((directory) => names.map((name) => join(directory, name)));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function waitForDevToolsEndpoint(path, child, options) {
|
|
228
|
+
const timeoutMs = boundedInteger(options.launchTimeoutMs, 15_000, 1_000, 60_000);
|
|
229
|
+
const startedAt = Date.now();
|
|
230
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
231
|
+
if (child.exitCode !== null) throw new Error("Chrome exited before Website Profile capture started");
|
|
232
|
+
try {
|
|
233
|
+
const [port, browserPath] = (await readFile(path, "utf8")).trim().split(/\r?\n/);
|
|
234
|
+
if (/^\d+$/.test(port) && browserPath?.startsWith("/devtools/browser/")) {
|
|
235
|
+
return `ws://127.0.0.1:${port}${browserPath}`;
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error?.code !== "ENOENT") throw error;
|
|
239
|
+
}
|
|
240
|
+
await delay(100);
|
|
241
|
+
}
|
|
242
|
+
throw new Error("Timed out waiting for Chrome DevTools");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function waitForExit(child, timeoutMs) {
|
|
246
|
+
if (child.exitCode !== null) return Promise.resolve();
|
|
247
|
+
return new Promise((resolvePromise, reject) => {
|
|
248
|
+
const timeout = setTimeout(() => reject(new Error("Chrome did not close")), timeoutMs);
|
|
249
|
+
child.once("exit", () => {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
resolvePromise();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
class CdpClient {
|
|
257
|
+
constructor(socket) {
|
|
258
|
+
this.socket = socket;
|
|
259
|
+
this.nextId = 1;
|
|
260
|
+
this.pending = new Map();
|
|
261
|
+
socket.addEventListener("message", (event) => {
|
|
262
|
+
const message = JSON.parse(String(event.data));
|
|
263
|
+
if (!message.id) return;
|
|
264
|
+
const pending = this.pending.get(message.id);
|
|
265
|
+
if (!pending) return;
|
|
266
|
+
this.pending.delete(message.id);
|
|
267
|
+
if (message.error) pending.reject(new Error(message.error.message || "Chrome DevTools request failed"));
|
|
268
|
+
else pending.resolve(message.result ?? {});
|
|
269
|
+
});
|
|
270
|
+
socket.addEventListener("close", () => this.rejectPending(new Error("Chrome DevTools connection closed")));
|
|
271
|
+
socket.addEventListener("error", () => this.rejectPending(new Error("Chrome DevTools connection failed")));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
static async connect(endpoint) {
|
|
275
|
+
const socket = new WebSocket(endpoint);
|
|
276
|
+
await new Promise((resolvePromise, reject) => {
|
|
277
|
+
socket.addEventListener("open", resolvePromise, { once: true });
|
|
278
|
+
socket.addEventListener("error", () => reject(new Error("Could not connect to local Chrome DevTools")), { once: true });
|
|
279
|
+
});
|
|
280
|
+
return new CdpClient(socket);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
call(method, params = {}, sessionId) {
|
|
284
|
+
const id = this.nextId++;
|
|
285
|
+
return new Promise((resolvePromise, reject) => {
|
|
286
|
+
this.pending.set(id, { reject, resolve: resolvePromise });
|
|
287
|
+
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
rejectPending(error) {
|
|
292
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
293
|
+
this.pending.clear();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeCookie(cookie) {
|
|
298
|
+
const sameSite = ["Strict", "Lax", "None"].includes(cookie.sameSite) ? cookie.sameSite : undefined;
|
|
299
|
+
const expires = Number(cookie.expires);
|
|
300
|
+
return {
|
|
301
|
+
domain: String(cookie.domain),
|
|
302
|
+
...(Number.isFinite(expires) && expires > 0 ? { expires } : {}),
|
|
303
|
+
httpOnly: cookie.httpOnly === true,
|
|
304
|
+
name: String(cookie.name),
|
|
305
|
+
path: String(cookie.path || "/"),
|
|
306
|
+
...(sameSite ? { sameSite } : {}),
|
|
307
|
+
secure: cookie.secure === true,
|
|
308
|
+
value: String(cookie.value)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function cookieAllowed(domain, allowedHosts) {
|
|
313
|
+
const host = String(domain || "").toLowerCase().replace(/^\./, "");
|
|
314
|
+
return allowedHosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function exactHttpsOrigin(value) {
|
|
318
|
+
const url = new URL(String(value || ""));
|
|
319
|
+
if (url.protocol !== "https:" || url.origin !== value || url.username || url.password) throw new Error("Website Profile origin is invalid");
|
|
320
|
+
return url.origin;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function requiredConnectorId(value) {
|
|
324
|
+
const id = String(value || "");
|
|
325
|
+
if (!/^[a-z][a-z0-9-]{1,62}[a-z0-9]$/.test(id)) throw new Error("Website Connector id is invalid");
|
|
326
|
+
return id;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
330
|
+
const candidate = value === undefined ? fallback : Number(value);
|
|
331
|
+
if (!Number.isInteger(candidate) || candidate < minimum || candidate > maximum) throw new Error("Website browser timeout is invalid");
|
|
332
|
+
return candidate;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function array(value) {
|
|
336
|
+
return Array.isArray(value) ? value : [];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function record(value, message) {
|
|
340
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(message);
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function delay(milliseconds) {
|
|
345
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
|
|
346
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { callMcpTool } from "./mcp-client.mjs";
|
|
5
|
+
import { mcpToolPayload } from "./mcp-result.mjs";
|
|
6
|
+
import { captureLocalWebsiteProfile } from "./website-browser-profile.mjs";
|
|
7
|
+
import { parseWebsiteConnectorDefinition } from "./website-connector-definition.mjs";
|
|
8
|
+
|
|
9
|
+
const WEBSITE_BRIDGE_APP_ID = "website-bridge";
|
|
10
|
+
const WEBSITE_BRIDGE_URL = "https://website-bridge.fargowealth.app";
|
|
11
|
+
|
|
12
|
+
export async function runWebsiteCommand(args, stdout, store, options = {}) {
|
|
13
|
+
const action = args[1];
|
|
14
|
+
if (action === "--help" || action === "-h" || action === "help") {
|
|
15
|
+
stdout.write(WEBSITE_HELP);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (action === "validate") {
|
|
19
|
+
const definition = await readDefinition(args);
|
|
20
|
+
stdout.write(`${JSON.stringify(validationSummary(definition))}\n`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (action === "release") {
|
|
24
|
+
const definition = await readDefinition(args);
|
|
25
|
+
const expectedRevision = optionalInteger(args, "--expected-revision");
|
|
26
|
+
const result = await callWebsiteBridge(store, "website_connector_release", {
|
|
27
|
+
definition,
|
|
28
|
+
...(expectedRevision === undefined ? {} : { expectedRevision })
|
|
29
|
+
}, { ...options, confirmation: args.includes("--confirm") });
|
|
30
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (action === "list") {
|
|
34
|
+
stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connector_list", {}, options))}\n`);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (action === "operations") {
|
|
38
|
+
stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connector_operations", {
|
|
39
|
+
connectorId: requiredOption(args, "--connector")
|
|
40
|
+
}, options))}\n`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (action === "call") {
|
|
44
|
+
const write = args.includes("--write");
|
|
45
|
+
const result = await callWebsiteBridge(store, write ? "website_connector_execute" : "website_connector_read", {
|
|
46
|
+
connectorId: requiredOption(args, "--connector"),
|
|
47
|
+
operationId: requiredOption(args, "--operation"),
|
|
48
|
+
arguments: await readArguments(args)
|
|
49
|
+
}, { ...options, confirmation: args.includes("--confirm") });
|
|
50
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (action === "connection-status") {
|
|
54
|
+
stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connection_status", {
|
|
55
|
+
connectorId: requiredOption(args, "--connector")
|
|
56
|
+
}, options))}\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (action === "connect" || action === "sync") {
|
|
60
|
+
const connectorId = requiredOption(args, "--connector");
|
|
61
|
+
const spec = await callWebsiteBridge(store, "website_connection_profile_spec", { connectorId }, options);
|
|
62
|
+
const capture = options.captureWebsiteProfile ?? captureLocalWebsiteProfile;
|
|
63
|
+
const profile = await capture(spec, {
|
|
64
|
+
chromePath: optionValue(args, "--chrome-path"),
|
|
65
|
+
noPrompt: args.includes("--no-prompt"),
|
|
66
|
+
profileDirectory: optionValue(args, "--profile-dir"),
|
|
67
|
+
settleMs: optionalInteger(args, "--settle-ms"),
|
|
68
|
+
stderr: options.stderr,
|
|
69
|
+
stdin: options.stdin
|
|
70
|
+
});
|
|
71
|
+
const result = await uploadWebsiteProfile(store, spec, profile, {
|
|
72
|
+
...options,
|
|
73
|
+
connectorId,
|
|
74
|
+
mode: action === "sync" ? "sync" : "establish"
|
|
75
|
+
});
|
|
76
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (action === "authorize") {
|
|
80
|
+
stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connection_authorize", {
|
|
81
|
+
connectorId: requiredOption(args, "--connector")
|
|
82
|
+
}, options))}\n`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (action === "authorize-complete") {
|
|
86
|
+
stdout.write(`${JSON.stringify(await callWebsiteBridge(store, "website_connection_authorize_complete", {
|
|
87
|
+
connectorId: requiredOption(args, "--connector")
|
|
88
|
+
}, options))}\n`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (action === "binding") {
|
|
92
|
+
stdout.write(`${JSON.stringify({
|
|
93
|
+
appId: WEBSITE_BRIDGE_APP_ID,
|
|
94
|
+
manifestVersion: "fargo.app-mcp/v1",
|
|
95
|
+
requiresCurrentUserDelegation: true,
|
|
96
|
+
authority: { mode: "invoking_user" },
|
|
97
|
+
allowedToolNames: [
|
|
98
|
+
"website_connector_list",
|
|
99
|
+
"website_connector_operations",
|
|
100
|
+
"website_connector_read",
|
|
101
|
+
"website_connector_execute",
|
|
102
|
+
"website_connection_status"
|
|
103
|
+
]
|
|
104
|
+
}, null, 2)}\n`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
throw new Error(WEBSITE_USAGE);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function callWebsiteBridge(store, toolName, toolArguments, options) {
|
|
111
|
+
return mcpToolPayload(await callMcpTool(store, "app_mcp_tool_call", {
|
|
112
|
+
appId: WEBSITE_BRIDGE_APP_ID,
|
|
113
|
+
toolName,
|
|
114
|
+
arguments: toolArguments,
|
|
115
|
+
...(options.confirmation === true ? { confirmation: true } : {})
|
|
116
|
+
}, options));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readDefinition(args) {
|
|
120
|
+
const path = resolve(requiredOption(args, "--file"));
|
|
121
|
+
let value;
|
|
122
|
+
try {
|
|
123
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof SyntaxError) throw new Error("Website Connector definition must be valid JSON");
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
return parseWebsiteConnectorDefinition(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readArguments(args) {
|
|
132
|
+
const inline = optionValue(args, "--args");
|
|
133
|
+
const file = optionValue(args, "--args-file");
|
|
134
|
+
if (inline !== undefined && file !== undefined) throw new Error("Use only one of --args or --args-file");
|
|
135
|
+
const text = file === undefined ? inline ?? "{}" : await readFile(resolve(file), "utf8");
|
|
136
|
+
try {
|
|
137
|
+
const value = JSON.parse(text);
|
|
138
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
|
|
139
|
+
return value;
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error("Website operation arguments must be a JSON object");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validationSummary(definition) {
|
|
146
|
+
const canonical = JSON.stringify(definition);
|
|
147
|
+
return {
|
|
148
|
+
valid: true,
|
|
149
|
+
schemaVersion: definition.schemaVersion,
|
|
150
|
+
connectorId: definition.connectorId,
|
|
151
|
+
displayName: definition.displayName,
|
|
152
|
+
operationCount: definition.operations.length,
|
|
153
|
+
requiresConnection: definition.operations.some((operation) => operation.requiresConnection),
|
|
154
|
+
sha256: createHash("sha256").update(canonical).digest("hex")
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function optionalInteger(args, name) {
|
|
159
|
+
const raw = optionValue(args, name);
|
|
160
|
+
if (raw === undefined) return undefined;
|
|
161
|
+
const value = Number(raw);
|
|
162
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function requiredOption(args, name) {
|
|
167
|
+
const value = optionValue(args, name)?.trim();
|
|
168
|
+
if (!value || value.startsWith("--")) throw new Error(`Missing required option ${name}`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function optionValue(args, name) {
|
|
173
|
+
const inline = args.find((value) => value.startsWith(`${name}=`));
|
|
174
|
+
if (inline) return inline.slice(name.length + 1);
|
|
175
|
+
const index = args.indexOf(name);
|
|
176
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function uploadWebsiteProfile(store, spec, profile, options) {
|
|
180
|
+
assertWebsiteProfile(profile, options.connectorId);
|
|
181
|
+
const body = JSON.stringify(profile);
|
|
182
|
+
const bodyBytes = Buffer.byteLength(body, "utf8");
|
|
183
|
+
if (bodyBytes > 512_000) throw new Error("Website Profile exceeds the 512 KB upload limit");
|
|
184
|
+
const uploadSecret = randomBytes(32).toString("base64url");
|
|
185
|
+
const mode = options.mode === "sync" ? "sync" : "establish";
|
|
186
|
+
const currentVersion = Number(spec?.connection?.profileVersion ?? 0);
|
|
187
|
+
if (
|
|
188
|
+
mode === "sync"
|
|
189
|
+
&& (
|
|
190
|
+
!Number.isInteger(currentVersion)
|
|
191
|
+
|| currentVersion < 1
|
|
192
|
+
|| !["active", "needs_reauth"].includes(spec?.connection?.state)
|
|
193
|
+
)
|
|
194
|
+
) {
|
|
195
|
+
throw new Error("Website Connection must already exist before it can be synchronized");
|
|
196
|
+
}
|
|
197
|
+
const capability = await callWebsiteBridge(store, "website_connection_import_begin", {
|
|
198
|
+
connectorId: options.connectorId,
|
|
199
|
+
mode,
|
|
200
|
+
profileSha256: createHash("sha256").update(body).digest("hex"),
|
|
201
|
+
uploadSecretSha256: createHash("sha256").update(uploadSecret).digest("hex"),
|
|
202
|
+
...(mode === "sync" ? { expectedProfileVersion: currentVersion } : {})
|
|
203
|
+
}, options);
|
|
204
|
+
if (
|
|
205
|
+
capability?.method !== "POST"
|
|
206
|
+
|| !Number.isInteger(capability?.maxBytes)
|
|
207
|
+
|| capability.maxBytes < bodyBytes
|
|
208
|
+
|| capability.maxBytes > 512_000
|
|
209
|
+
|| !Number.isFinite(Date.parse(capability?.expiresAt))
|
|
210
|
+
|| Date.parse(capability.expiresAt) <= Date.now()
|
|
211
|
+
) throw new Error("Website Profile upload capability is invalid");
|
|
212
|
+
const uploadUrl = validatedUploadUrl(capability?.uploadUrl, options);
|
|
213
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
214
|
+
const response = await fetchImpl(uploadUrl, {
|
|
215
|
+
body,
|
|
216
|
+
headers: {
|
|
217
|
+
Accept: "application/json",
|
|
218
|
+
"Content-Type": "application/json",
|
|
219
|
+
"X-Fargo-Profile-Upload-Secret": uploadSecret
|
|
220
|
+
},
|
|
221
|
+
method: "POST",
|
|
222
|
+
redirect: "error"
|
|
223
|
+
});
|
|
224
|
+
let payload;
|
|
225
|
+
try {
|
|
226
|
+
payload = await response.json();
|
|
227
|
+
} catch {
|
|
228
|
+
throw new Error(`Website Profile upload failed (${response.status})`);
|
|
229
|
+
}
|
|
230
|
+
if (!response.ok) throw new Error(payload?.error || `Website Profile upload failed (${response.status})`);
|
|
231
|
+
return payload;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function assertWebsiteProfile(profile, connectorId) {
|
|
235
|
+
if (
|
|
236
|
+
!profile
|
|
237
|
+
|| typeof profile !== "object"
|
|
238
|
+
|| Array.isArray(profile)
|
|
239
|
+
|| profile.schemaVersion !== "fargo.browser-profile/v1"
|
|
240
|
+
|| profile.connectorId !== connectorId
|
|
241
|
+
|| !Number.isFinite(Date.parse(profile.capturedAt))
|
|
242
|
+
|| !Array.isArray(profile.cookies)
|
|
243
|
+
|| !Array.isArray(profile.origins)
|
|
244
|
+
) throw new Error("Local Website Profile is invalid");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function validatedUploadUrl(value, options) {
|
|
248
|
+
const expectedOrigin = new URL(options.websiteBridgeUrl ?? process.env.FARGO_WEBSITE_BRIDGE_URL ?? WEBSITE_BRIDGE_URL).origin;
|
|
249
|
+
let url;
|
|
250
|
+
try {
|
|
251
|
+
url = new URL(String(value || ""));
|
|
252
|
+
} catch {
|
|
253
|
+
throw new Error("Website Profile upload capability is invalid");
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
url.protocol !== "https:"
|
|
257
|
+
|| url.origin !== expectedOrigin
|
|
258
|
+
|| !/^\/v1\/website\/profile-imports\/wpi_[0-9a-f-]{36}$/.test(url.pathname)
|
|
259
|
+
|| url.search
|
|
260
|
+
|| url.hash
|
|
261
|
+
|| url.username
|
|
262
|
+
|| url.password
|
|
263
|
+
) throw new Error("Website Profile upload capability is invalid");
|
|
264
|
+
return url.toString();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const WEBSITE_USAGE = "Usage: fargo website validate|release|list|operations|call|connect|sync|connection-status|authorize|authorize-complete|binding";
|
|
268
|
+
|
|
269
|
+
export const WEBSITE_HELP = `Usage: fargo website <command> [options]
|
|
270
|
+
|
|
271
|
+
Connector authoring:
|
|
272
|
+
validate --file <path>
|
|
273
|
+
release --file <path> [--expected-revision <n>] --confirm
|
|
274
|
+
list
|
|
275
|
+
operations --connector <id>
|
|
276
|
+
|
|
277
|
+
Connection and execution:
|
|
278
|
+
connect --connector <id> [--chrome-path <path>] [--profile-dir <path>]
|
|
279
|
+
sync --connector <id> [--no-prompt] [--settle-ms <n>]
|
|
280
|
+
connection-status --connector <id>
|
|
281
|
+
call --connector <id> --operation <id> [--args <json> | --args-file <path>]
|
|
282
|
+
call --connector <id> --operation <id> --write [--confirm]
|
|
283
|
+
|
|
284
|
+
Cloud login fallback:
|
|
285
|
+
authorize --connector <id>
|
|
286
|
+
authorize-complete --connector <id>
|
|
287
|
+
|
|
288
|
+
Teammate:
|
|
289
|
+
binding
|
|
290
|
+
`;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type WebsiteConnectorInputSchema = {
|
|
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
|
+
};
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
const CONNECTOR_ID = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
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
|
+
}
|