@forgezero/agent 0.1.40 → 0.1.42
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/README.md +573 -424
- package/dist/agent-heartbeat.js +6 -3
- package/dist/agent-update-helper.js +5 -2
- package/dist/agent-update.js +5 -2
- package/dist/bootstrap.d.ts +17 -8
- package/dist/bootstrap.js +1504 -512
- package/dist/cli/agent-install.d.ts +6 -5
- package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
- package/dist/cli/maintenance.d.ts +23 -0
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/session-store.d.ts +5 -0
- package/dist/cloudflare-bootstrap.d.ts +73 -35
- package/dist/cloudflare-bootstrap.js +587 -90
- package/dist/cloudflare-edge.d.ts +64 -12
- package/dist/cloudflare-edge.js +103 -8
- package/dist/community-rehearsal-host.d.ts +51 -0
- package/dist/community-rehearsal-host.js +272 -0
- package/dist/credential-schema.d.ts +54 -0
- package/dist/credential-schema.js +47 -0
- package/dist/definition.d.ts +31 -5
- package/dist/definition.js +271 -44
- package/dist/deploy-file.js +294 -68
- package/dist/deployment-runner.js +18 -5
- package/dist/deployment.d.ts +13 -1
- package/dist/fz-agent.js +9162 -7564
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +5679 -5077
- package/dist/git-ssh.d.ts +5 -0
- package/dist/guest-enrolment.d.ts +2 -0
- package/dist/guest-enrolment.js +1 -0
- package/dist/host-maintenance.d.ts +39 -0
- package/dist/host-maintenance.js +135 -0
- package/dist/index.d.ts +4 -2
- package/dist/mesh-connector.d.ts +16 -0
- package/dist/mesh-connector.js +46 -0
- package/dist/metal-bootstrap.js +145 -7
- package/dist/metal-helper-socket.js +61 -31
- package/dist/metal-provision.d.ts +2 -2
- package/dist/metal-provision.js +62 -32
- package/dist/operator-bootstrap.d.ts +90 -0
- package/dist/operator-bootstrap.js +5704 -0
- package/dist/otel-collector.d.ts +18 -0
- package/dist/pipeline.d.ts +3 -2
- package/dist/pipeline.js +1 -1
- package/dist/platform-bootstrap-runtime.d.ts +39 -21
- package/dist/platform-bootstrap-runtime.js +182 -59
- package/dist/platform-fleet-verification.d.ts +19 -0
- package/dist/platform-fleet-verification.js +3873 -0
- package/dist/platform-genesis-config.d.ts +7 -0
- package/dist/platform-genesis.d.ts +17 -0
- package/dist/provision.d.ts +76 -3
- package/dist/provision.js +1061 -229
- package/dist/recovery-host.d.ts +7 -0
- package/dist/recovery-host.js +124 -0
- package/dist/service-supervisor.d.ts +42 -0
- package/dist/software-helper.d.ts +4 -0
- package/dist/software-helper.js +865 -63
- package/dist/software.d.ts +14 -3
- package/dist/software.js +163 -37
- package/dist/ssh-bootstrap.d.ts +97 -0
- package/dist/supervised-app.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/package.json +175 -164
- package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
package/dist/deploy-file.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
// src/software.ts
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
copyFileSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
3
18
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
4
|
-
var
|
|
19
|
+
var BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f";
|
|
5
20
|
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
6
21
|
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
7
22
|
var OS_CATALOG = [
|
|
@@ -12,41 +27,151 @@ var SOFTWARE_CATALOG = [
|
|
|
12
27
|
{ id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
13
28
|
{ id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
14
29
|
{ id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
30
|
+
{ id: "cloudflare-warp", version: "2026.6.822.0-min", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
15
31
|
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
16
32
|
{ id: "openssh-client", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
17
33
|
];
|
|
18
34
|
var UBUNTU_2604_X64 = [
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
|
|
26
|
-
check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
|
|
27
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
requirement: { id: "arangodb", version: "3.11.14" },
|
|
31
|
-
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14' && ` + `! systemctl is-active --quiet arangodb3.service && ` + `! systemctl is-enabled --quiet arangodb3.service`,
|
|
32
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install; ` + `systemctl disable --now arangodb3.service`
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
36
|
-
check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
|
|
37
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64' -o "$tmp/cloudflared"; ` + `echo "${CLOUDFLARED_SHA256} $tmp/cloudflared" | sha256sum -c -; ` + `install -m 0755 "$tmp/cloudflared" /usr/local/bin/cloudflared`
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
requirement: { id: "ufw", version: "ubuntu-26.04" },
|
|
41
|
-
check: "command -v ufw >/dev/null",
|
|
42
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
requirement: { id: "openssh-client", version: "ubuntu-26.04" },
|
|
46
|
-
check: "command -v ssh >/dev/null && command -v scp >/dev/null && command -v ssh-keyscan >/dev/null && command -v ssh-keygen >/dev/null",
|
|
47
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y openssh-client"
|
|
48
|
-
}
|
|
35
|
+
{ requirement: { id: "bun", version: "1.3.14" } },
|
|
36
|
+
{ requirement: { id: "nginx", version: "ubuntu-26.04" } },
|
|
37
|
+
{ requirement: { id: "arangodb", version: "3.11.14" } },
|
|
38
|
+
{ requirement: { id: "cloudflared", version: "2026.7.3" } },
|
|
39
|
+
{ requirement: { id: "cloudflare-warp", version: "2026.6.822.0-min" } },
|
|
40
|
+
{ requirement: { id: "ufw", version: "ubuntu-26.04" } },
|
|
41
|
+
{ requirement: { id: "openssh-client", version: "ubuntu-26.04" } }
|
|
49
42
|
];
|
|
43
|
+
var path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
44
|
+
var run = async (argv, env = {}) => {
|
|
45
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: { PATH: path, LANG: "C", LC_ALL: "C", ...env } });
|
|
46
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
47
|
+
new Response(child.stdout).text(),
|
|
48
|
+
new Response(child.stderr).text(),
|
|
49
|
+
child.exited
|
|
50
|
+
]);
|
|
51
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
52
|
+
};
|
|
53
|
+
var download = async (url, destination, sha256) => {
|
|
54
|
+
const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120000) });
|
|
55
|
+
if (!response.ok)
|
|
56
|
+
throw new Error(`download failed with HTTP ${response.status}`);
|
|
57
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
58
|
+
if (createHash("sha256").update(bytes).digest("hex") !== sha256)
|
|
59
|
+
throw new Error("download checksum mismatch");
|
|
60
|
+
writeFileSync(destination, bytes, { mode: 384, flag: "wx" });
|
|
61
|
+
};
|
|
62
|
+
var successful = (result, pattern) => result.exitCode === 0 && (!pattern || pattern.test(result.output));
|
|
63
|
+
var aptInstall = async (name) => {
|
|
64
|
+
const environment = { DEBIAN_FRONTEND: "noninteractive" };
|
|
65
|
+
const update = await run(["/usr/bin/apt-get", "update", "-qq"], environment);
|
|
66
|
+
return update.exitCode === 0 ? run(["/usr/bin/apt-get", "install", "-y", name], environment) : update;
|
|
67
|
+
};
|
|
68
|
+
async function executeSoftwareOperation(operation) {
|
|
69
|
+
const { software, version } = operation;
|
|
70
|
+
if (!UBUNTU_2604_X64.some(({ requirement }) => requirement.id === software && requirement.version === version)) {
|
|
71
|
+
return { exitCode: 2, output: "unsupported software operation" };
|
|
72
|
+
}
|
|
73
|
+
if (operation.kind === "check") {
|
|
74
|
+
if (software === "bun")
|
|
75
|
+
return run(["/usr/local/bin/bun", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /^1\.3\.14\s*$/m) ? 0 : 1 }));
|
|
76
|
+
if (software === "nginx") {
|
|
77
|
+
const binary = await run(["/usr/sbin/nginx", "-v"]);
|
|
78
|
+
return binary.exitCode === 0 ? run(["/usr/bin/systemctl", "is-active", "--quiet", "nginx.service"]) : binary;
|
|
79
|
+
}
|
|
80
|
+
if (software === "arangodb") {
|
|
81
|
+
const binary = await run(["/usr/bin/arangod", "--version"]);
|
|
82
|
+
if (!successful(binary, /3\.11\.14/))
|
|
83
|
+
return { ...binary, exitCode: 1 };
|
|
84
|
+
const [active, enabled] = await Promise.all([
|
|
85
|
+
run(["/usr/bin/systemctl", "is-active", "--quiet", "arangodb3.service"]),
|
|
86
|
+
run(["/usr/bin/systemctl", "is-enabled", "--quiet", "arangodb3.service"])
|
|
87
|
+
]);
|
|
88
|
+
return active.exitCode !== 0 && enabled.exitCode !== 0 ? { exitCode: 0, output: binary.output } : { exitCode: 1, output: "vendor standalone unit remains active or enabled" };
|
|
89
|
+
}
|
|
90
|
+
if (software === "cloudflared")
|
|
91
|
+
return run(["/usr/local/bin/cloudflared", "--version"]).then((r) => ({ ...r, exitCode: successful(r, /2026\.7\.3/) ? 0 : 1 }));
|
|
92
|
+
if (software === "cloudflare-warp")
|
|
93
|
+
return run(["/usr/bin/warp-cli", "--version"]).then((result) => {
|
|
94
|
+
const match = result.output.match(/(\d{4})\.(\d+)\.(\d+)\.(\d+)/);
|
|
95
|
+
const observed = match?.slice(1).map(Number);
|
|
96
|
+
const minimum = [2026, 6, 822, 0];
|
|
97
|
+
const supported = observed && observed.some((part, index) => part > minimum[index] && observed.slice(0, index).every((prior, priorIndex) => prior === minimum[priorIndex])) || observed?.every((part, index) => part === minimum[index]);
|
|
98
|
+
return { ...result, exitCode: result.exitCode === 0 && supported ? 0 : 1 };
|
|
99
|
+
});
|
|
100
|
+
const binaries = software === "ufw" ? ["/usr/sbin/ufw"] : ["/usr/bin/ssh", "/usr/bin/scp", "/usr/bin/ssh-keyscan", "/usr/bin/ssh-keygen"];
|
|
101
|
+
try {
|
|
102
|
+
binaries.forEach((binary) => accessSync(binary));
|
|
103
|
+
return { exitCode: 0, output: "" };
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
return { exitCode: 1, output: cause instanceof Error ? cause.message : String(cause) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (software === "nginx" || software === "ufw" || software === "openssh-client") {
|
|
109
|
+
const installed = await aptInstall(software === "openssh-client" ? "openssh-client" : software);
|
|
110
|
+
if (installed.exitCode !== 0 || software !== "nginx")
|
|
111
|
+
return installed;
|
|
112
|
+
return run(["/usr/bin/systemctl", "enable", "--now", "nginx.service"]);
|
|
113
|
+
}
|
|
114
|
+
const directory = mkdtempSync(join(tmpdir(), "forgezero-software-"));
|
|
115
|
+
try {
|
|
116
|
+
if (software === "cloudflare-warp") {
|
|
117
|
+
const key = join(directory, "cloudflare-warp-key.gpg");
|
|
118
|
+
await download("https://pkg.cloudflareclient.com/pubkey.gpg", key, "0f37fc298c98e88ee3c0ee68c95b69f1dba9eb477abe3167e13982105911264d");
|
|
119
|
+
mkdirSync("/usr/share/keyrings", { recursive: true, mode: 493 });
|
|
120
|
+
const dearmored = await run([
|
|
121
|
+
"/usr/bin/gpg",
|
|
122
|
+
"--batch",
|
|
123
|
+
"--yes",
|
|
124
|
+
"--dearmor",
|
|
125
|
+
"-o",
|
|
126
|
+
"/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg",
|
|
127
|
+
key
|
|
128
|
+
]);
|
|
129
|
+
if (dearmored.exitCode !== 0)
|
|
130
|
+
return dearmored;
|
|
131
|
+
mkdirSync("/etc/apt/sources.list.d", { recursive: true, mode: 493 });
|
|
132
|
+
writeFileSync("/etc/apt/sources.list.d/cloudflare-client.list", `deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ resolute main
|
|
133
|
+
`, { mode: 420 });
|
|
134
|
+
return aptInstall("cloudflare-warp");
|
|
135
|
+
}
|
|
136
|
+
if (software === "bun") {
|
|
137
|
+
const archive = join(directory, "bun.zip");
|
|
138
|
+
await download("https://github.com/oven-sh/bun/releases/download/bun-v1.3.14/bun-linux-x64.zip", archive, BUN_RELEASE_SHA256);
|
|
139
|
+
const unpacked = join(directory, "unpacked");
|
|
140
|
+
mkdirSync(unpacked, { mode: 448 });
|
|
141
|
+
const unzipped = await run(["/usr/bin/unzip", "-q", archive, "-d", unpacked]);
|
|
142
|
+
if (unzipped.exitCode !== 0)
|
|
143
|
+
return unzipped;
|
|
144
|
+
mkdirSync("/usr/local/lib/forgezero/runtime", { recursive: true, mode: 493 });
|
|
145
|
+
copyFileSync(join(unpacked, "bun-linux-x64", "bun"), "/usr/local/lib/forgezero/runtime/bun.next");
|
|
146
|
+
chmodSync("/usr/local/lib/forgezero/runtime/bun.next", 493);
|
|
147
|
+
renameSync("/usr/local/lib/forgezero/runtime/bun.next", "/usr/local/lib/forgezero/runtime/bun");
|
|
148
|
+
try {
|
|
149
|
+
unlinkSync("/usr/local/bin/bun");
|
|
150
|
+
} catch {}
|
|
151
|
+
symlinkSync("/usr/local/lib/forgezero/runtime/bun", "/usr/local/bin/bun");
|
|
152
|
+
return { exitCode: 0, output: "" };
|
|
153
|
+
}
|
|
154
|
+
if (software === "cloudflared") {
|
|
155
|
+
const binary = join(directory, "cloudflared");
|
|
156
|
+
await download("https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64", binary, CLOUDFLARED_SHA256);
|
|
157
|
+
chmodSync(binary, 493);
|
|
158
|
+
copyFileSync(binary, "/usr/local/bin/cloudflared");
|
|
159
|
+
chmodSync("/usr/local/bin/cloudflared", 493);
|
|
160
|
+
return { exitCode: 0, output: "" };
|
|
161
|
+
}
|
|
162
|
+
const deb = join(directory, "arangodb.deb");
|
|
163
|
+
await download("https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb", deb, ARANGO_SHA256);
|
|
164
|
+
let installed = await run(["/usr/bin/dpkg", "-i", deb], { DEBIAN_FRONTEND: "noninteractive" });
|
|
165
|
+
if (installed.exitCode !== 0)
|
|
166
|
+
installed = await run(["/usr/bin/apt-get", "-y", "-f", "install"], { DEBIAN_FRONTEND: "noninteractive" });
|
|
167
|
+
if (installed.exitCode !== 0)
|
|
168
|
+
return installed;
|
|
169
|
+
await run(["/usr/bin/systemctl", "disable", "--now", "arangodb3.service"]);
|
|
170
|
+
return { exitCode: 0, output: "" };
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(directory, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
50
175
|
function observeSoftwareHost(osRelease = readFileSync("/etc/os-release", "utf8"), architecture = process.arch) {
|
|
51
176
|
const values = Object.fromEntries(osRelease.split(`
|
|
52
177
|
`).flatMap((line) => {
|
|
@@ -69,7 +194,7 @@ function validateSoftwareRequirements(value, _options = {}) {
|
|
|
69
194
|
if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
|
|
70
195
|
throw new Error("software requirement contains an unknown field");
|
|
71
196
|
}
|
|
72
|
-
if (!["bun", "nginx", "arangodb", "cloudflared", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
197
|
+
if (!["bun", "nginx", "arangodb", "cloudflared", "cloudflare-warp", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
73
198
|
throw new Error("software requirement coordinate is invalid");
|
|
74
199
|
}
|
|
75
200
|
const requirement = { id: row.id, version: row.version };
|
|
@@ -95,15 +220,15 @@ async function ensureSoftwareRequirements(requirementsInput, options) {
|
|
|
95
220
|
const strategy = UBUNTU_2604_X64.find(({ requirement: candidate }) => candidate.id === requirement.id && candidate.version === requirement.version);
|
|
96
221
|
if (!strategy)
|
|
97
222
|
throw new Error(`unsupported software requirement: ${requirement.id}@${requirement.version}`);
|
|
98
|
-
const before = await options.exec(
|
|
223
|
+
const before = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
|
|
99
224
|
if (before.exitCode === 0) {
|
|
100
225
|
results.push({ ...requirement, changed: false });
|
|
101
226
|
continue;
|
|
102
227
|
}
|
|
103
|
-
const installed = await options.exec(
|
|
228
|
+
const installed = await options.exec({ kind: "install", software: requirement.id, version: requirement.version });
|
|
104
229
|
if (installed.exitCode !== 0)
|
|
105
230
|
throw new Error(`could not install ${requirement.id}@${requirement.version}: ${installed.output.trim()}`);
|
|
106
|
-
const after = await options.exec(
|
|
231
|
+
const after = await options.exec({ kind: "check", software: requirement.id, version: requirement.version });
|
|
107
232
|
if (after.exitCode !== 0)
|
|
108
233
|
throw new Error(`${requirement.id}@${requirement.version} did not pass its post-install check`);
|
|
109
234
|
results.push({ ...requirement, changed: true });
|
|
@@ -227,8 +352,8 @@ async function calibrateHttpConcurrency(options, fetcher = fetch) {
|
|
|
227
352
|
}
|
|
228
353
|
|
|
229
354
|
// src/definition.ts
|
|
230
|
-
var PIPELINE_VERSION =
|
|
231
|
-
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-
|
|
355
|
+
var PIPELINE_VERSION = 3;
|
|
356
|
+
var DEPLOY_SCHEMA_URL = "https://www.forgezero.net/schemas/deploy-v3.json";
|
|
232
357
|
|
|
233
358
|
class DefinitionError extends Error {
|
|
234
359
|
constructor(message) {
|
|
@@ -248,6 +373,29 @@ var text = (value, where) => {
|
|
|
248
373
|
}
|
|
249
374
|
return value;
|
|
250
375
|
};
|
|
376
|
+
var argv = (value, where) => {
|
|
377
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 256) {
|
|
378
|
+
throw new DefinitionError(`${where} must contain from 1 to 256 arguments.`);
|
|
379
|
+
}
|
|
380
|
+
let bytes = 0;
|
|
381
|
+
const parsed = value.map((argument, index) => {
|
|
382
|
+
if (typeof argument !== "string" || argument.length === 0 || argument.length > 16384 || argument.includes("\x00")) {
|
|
383
|
+
throw new DefinitionError(`${where}[${index}] must be a non-empty bounded string without NUL.`);
|
|
384
|
+
}
|
|
385
|
+
if (argument.includes("${") && !/^\$\{FZ_[A-Z0-9_]+\}$/.test(argument)) {
|
|
386
|
+
throw new DefinitionError(`${where}[${index}] contains unsupported interpolation; only one exact FZ coordinate is allowed.`);
|
|
387
|
+
}
|
|
388
|
+
bytes += Buffer.byteLength(argument);
|
|
389
|
+
if (bytes > 64 * 1024)
|
|
390
|
+
throw new DefinitionError(`${where} is larger than 64 KiB.`);
|
|
391
|
+
return argument;
|
|
392
|
+
});
|
|
393
|
+
const executable = parsed[0].split("/").at(-1).toLowerCase();
|
|
394
|
+
if (new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "busybox", "env"]).has(executable)) {
|
|
395
|
+
throw new DefinitionError(`${where}[0] may not invoke a shell or command dispatcher.`);
|
|
396
|
+
}
|
|
397
|
+
return parsed;
|
|
398
|
+
};
|
|
251
399
|
var exactKeys = (value, allowed, where) => {
|
|
252
400
|
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
253
401
|
if (unknown.length > 0)
|
|
@@ -313,6 +461,76 @@ function capacityCalibration(value, where) {
|
|
|
313
461
|
}
|
|
314
462
|
return parsed;
|
|
315
463
|
}
|
|
464
|
+
function validateDeploymentService(value, where = "service") {
|
|
465
|
+
const service = record(value, where);
|
|
466
|
+
exactKeys(service, [
|
|
467
|
+
"strategy",
|
|
468
|
+
"publicPort",
|
|
469
|
+
"applicationPorts",
|
|
470
|
+
"command",
|
|
471
|
+
"healthPath",
|
|
472
|
+
"maxConnections",
|
|
473
|
+
"websocket",
|
|
474
|
+
"drainMs"
|
|
475
|
+
], where);
|
|
476
|
+
if (service.strategy !== "direct" && service.strategy !== "blue-green") {
|
|
477
|
+
throw new DefinitionError(`${where}.strategy must be direct or blue-green.`);
|
|
478
|
+
}
|
|
479
|
+
const publicPort = Number(service.publicPort);
|
|
480
|
+
if (!Number.isSafeInteger(publicPort) || publicPort < 1024 || publicPort > 65535) {
|
|
481
|
+
throw new DefinitionError(`${where}.publicPort must be an unprivileged TCP port.`);
|
|
482
|
+
}
|
|
483
|
+
const allocation = record(service.applicationPorts, `${where}.applicationPorts`);
|
|
484
|
+
let applicationPorts;
|
|
485
|
+
const required = service.strategy === "blue-green" ? 2 : 1;
|
|
486
|
+
if (allocation.mode === "fixed") {
|
|
487
|
+
exactKeys(allocation, ["mode", "ports"], `${where}.applicationPorts`);
|
|
488
|
+
if (!Array.isArray(allocation.ports) || allocation.ports.length !== required || allocation.ports.some((port) => !Number.isSafeInteger(port) || Number(port) < 1024 || Number(port) > 65535) || new Set(allocation.ports).size !== allocation.ports.length || allocation.ports.includes(publicPort)) {
|
|
489
|
+
throw new DefinitionError(`${where}.applicationPorts needs ${required} distinct unprivileged port(s), disjoint from publicPort.`);
|
|
490
|
+
}
|
|
491
|
+
applicationPorts = { mode: "fixed", ports: allocation.ports };
|
|
492
|
+
} else if (allocation.mode === "dynamic") {
|
|
493
|
+
exactKeys(allocation, ["mode", "from", "to"], `${where}.applicationPorts`);
|
|
494
|
+
const from = Number(allocation.from);
|
|
495
|
+
const to = Number(allocation.to);
|
|
496
|
+
if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 1024 || to > 65535 || to - from + 1 < required || publicPort >= from && publicPort <= to) {
|
|
497
|
+
throw new DefinitionError(`${where}.applicationPorts dynamic range is invalid or includes publicPort.`);
|
|
498
|
+
}
|
|
499
|
+
applicationPorts = { mode: "dynamic", from, to };
|
|
500
|
+
} else
|
|
501
|
+
throw new DefinitionError(`${where}.applicationPorts.mode must be fixed or dynamic.`);
|
|
502
|
+
const command = argv(service.command, `${where}.command`);
|
|
503
|
+
for (const argument of command) {
|
|
504
|
+
const coordinate = argument.match(/^\$\{(FZ_[A-Z0-9_]+)\}$/)?.[1];
|
|
505
|
+
if (coordinate && !["FZ_APP_PORT", "FZ_RELEASE"].includes(coordinate)) {
|
|
506
|
+
throw new DefinitionError(`${where}.command uses unsupported coordinate ${coordinate}.`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (typeof service.healthPath !== "string" || !/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{0,255}$/.test(service.healthPath) || service.healthPath.includes("..") || service.healthPath.includes("//")) {
|
|
510
|
+
throw new DefinitionError(`${where}.healthPath must be one bounded absolute path.`);
|
|
511
|
+
}
|
|
512
|
+
if (service.websocket !== undefined && typeof service.websocket !== "boolean") {
|
|
513
|
+
throw new DefinitionError(`${where}.websocket must be a boolean.`);
|
|
514
|
+
}
|
|
515
|
+
const maxConnections = service.maxConnections === undefined ? undefined : Number(service.maxConnections);
|
|
516
|
+
if (maxConnections !== undefined && (!Number.isSafeInteger(maxConnections) || maxConnections < 1 || maxConnections > 1e6)) {
|
|
517
|
+
throw new DefinitionError(`${where}.maxConnections must be from 1 to 1000000.`);
|
|
518
|
+
}
|
|
519
|
+
const drainMs = service.drainMs === undefined ? 30000 : Number(service.drainMs);
|
|
520
|
+
if (!Number.isSafeInteger(drainMs) || drainMs < 0 || drainMs > 300000) {
|
|
521
|
+
throw new DefinitionError(`${where}.drainMs must be from 0 to 300000.`);
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
strategy: service.strategy,
|
|
525
|
+
publicPort,
|
|
526
|
+
applicationPorts,
|
|
527
|
+
command,
|
|
528
|
+
healthPath: service.healthPath,
|
|
529
|
+
...maxConnections === undefined ? {} : { maxConnections },
|
|
530
|
+
websocket: service.websocket === true,
|
|
531
|
+
drainMs
|
|
532
|
+
};
|
|
533
|
+
}
|
|
316
534
|
function parseDeployDefinition(value, options = {}) {
|
|
317
535
|
const root = record(value, "pipeline");
|
|
318
536
|
exactKeys(root, ["$schema", "version", "name", "requireAttestation", "profiles", "steps"], "pipeline");
|
|
@@ -330,20 +548,28 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
330
548
|
if (profileEntries.length === 0 || profileEntries.length > 32) {
|
|
331
549
|
throw new DefinitionError("pipeline.profiles must contain from 1 to 32 named profiles.");
|
|
332
550
|
}
|
|
333
|
-
if (!Array.isArray(root.steps) || root.steps.length === 0) {
|
|
334
|
-
throw new DefinitionError("pipeline.steps must contain
|
|
551
|
+
if (!Array.isArray(root.steps) || root.steps.length === 0 || root.steps.length > 256) {
|
|
552
|
+
throw new DefinitionError("pipeline.steps must contain from 1 to 256 steps.");
|
|
335
553
|
}
|
|
336
554
|
const profiles = {};
|
|
337
555
|
for (const [name2, raw] of profileEntries) {
|
|
338
556
|
if (!NAME.test(name2))
|
|
339
557
|
throw new DefinitionError(`pipeline profile name is invalid: ${name2}.`);
|
|
340
558
|
const profile = record(raw, `profiles.${name2}`);
|
|
341
|
-
exactKeys(profile, ["software", "capacityCalibration"], `profiles.${name2}`);
|
|
559
|
+
exactKeys(profile, ["software", "service", "capacityCalibration"], `profiles.${name2}`);
|
|
342
560
|
if (!Array.isArray(profile.software)) {
|
|
343
561
|
throw new DefinitionError(`profiles.${name2}.software must be an array.`);
|
|
344
562
|
}
|
|
563
|
+
const software = validateSoftwareRequirements(profile.software, options);
|
|
564
|
+
const service = profile.service === undefined ? undefined : validateDeploymentService(profile.service, `profiles.${name2}.service`);
|
|
565
|
+
if (service && !software.some(({ id }) => id === "nginx")) {
|
|
566
|
+
throw new DefinitionError(`profiles.${name2}.service requires the reviewed nginx software strategy.`);
|
|
567
|
+
}
|
|
345
568
|
profiles[name2] = {
|
|
346
|
-
software
|
|
569
|
+
software,
|
|
570
|
+
...profile.service === undefined ? {} : {
|
|
571
|
+
service
|
|
572
|
+
},
|
|
347
573
|
...profile.capacityCalibration === undefined ? {} : {
|
|
348
574
|
capacityCalibration: capacityCalibration(profile.capacityCalibration, `profiles.${name2}.capacityCalibration`)
|
|
349
575
|
}
|
|
@@ -352,7 +578,7 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
352
578
|
const phases = new Set(["build", "release", "migrate", "health"]);
|
|
353
579
|
const steps = root.steps.map((raw, index) => {
|
|
354
580
|
const step = record(raw, `steps[${index}]`);
|
|
355
|
-
exactKeys(step, ["name", "
|
|
581
|
+
exactKeys(step, ["name", "exec", "phase", "scope", "profiles", "secrets", "always", "timeoutMs", "when"], `steps[${index}]`);
|
|
356
582
|
const phase = text(step.phase, `steps[${index}].phase`);
|
|
357
583
|
if (!phases.has(phase))
|
|
358
584
|
throw new DefinitionError(`steps[${index}].phase is not supported.`);
|
|
@@ -400,7 +626,7 @@ function parseDeployDefinition(value, options = {}) {
|
|
|
400
626
|
}
|
|
401
627
|
return {
|
|
402
628
|
name: text(step.name, `steps[${index}].name`),
|
|
403
|
-
|
|
629
|
+
exec: argv(step.exec, `steps[${index}].exec`),
|
|
404
630
|
phase,
|
|
405
631
|
scope: step.scope,
|
|
406
632
|
profiles: selectedProfiles,
|
|
@@ -441,9 +667,9 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
|
|
|
441
667
|
}
|
|
442
668
|
|
|
443
669
|
// src/deploy-file.ts
|
|
444
|
-
import { createHash } from "node:crypto";
|
|
445
|
-
import { existsSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
446
|
-
import { basename, join } from "node:path";
|
|
670
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
671
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
672
|
+
import { basename, join as join2 } from "node:path";
|
|
447
673
|
var DEPLOY_FILE = ".fz/deploy.json";
|
|
448
674
|
var DEPLOY_TODO_PREFIX = "ForgeZero pipeline TODO:";
|
|
449
675
|
var stable = (value) => {
|
|
@@ -455,28 +681,28 @@ var stable = (value) => {
|
|
|
455
681
|
return JSON.stringify(value);
|
|
456
682
|
};
|
|
457
683
|
function deployDefinitionDigest(definition) {
|
|
458
|
-
return `sha256:${
|
|
684
|
+
return `sha256:${createHash2("sha256").update(stable(definition)).digest("hex")}`;
|
|
459
685
|
}
|
|
460
686
|
var safeName = (value) => {
|
|
461
687
|
const normalized = value.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
462
688
|
return /^[a-z]/.test(normalized) ? normalized : `app-${normalized || "service"}`.slice(0, 63);
|
|
463
689
|
};
|
|
464
690
|
function packageHints(root) {
|
|
465
|
-
const packagePath =
|
|
691
|
+
const packagePath = join2(root, "package.json");
|
|
466
692
|
if (!existsSync(packagePath))
|
|
467
|
-
return { bun: existsSync(
|
|
693
|
+
return { bun: existsSync(join2(root, "bun.lock")) };
|
|
468
694
|
try {
|
|
469
695
|
const manifest = JSON.parse(readFileSync2(packagePath, "utf8"));
|
|
470
696
|
return {
|
|
471
697
|
name: typeof manifest.name === "string" ? manifest.name : undefined,
|
|
472
|
-
build: typeof manifest.scripts?.build === "string" ? "bun run build" : undefined,
|
|
473
|
-
bun: existsSync(
|
|
698
|
+
build: typeof manifest.scripts?.build === "string" ? ["bun", "run", "build"] : undefined,
|
|
699
|
+
bun: existsSync(join2(root, "bun.lock")) || existsSync(join2(root, "bun.lockb"))
|
|
474
700
|
};
|
|
475
701
|
} catch {
|
|
476
|
-
return { bun: existsSync(
|
|
702
|
+
return { bun: existsSync(join2(root, "bun.lock")) };
|
|
477
703
|
}
|
|
478
704
|
}
|
|
479
|
-
var blocker = (instruction) =>
|
|
705
|
+
var blocker = (instruction) => ["fz-agent", "pipeline-todo", `${DEPLOY_TODO_PREFIX} ${instruction}`];
|
|
480
706
|
function defaultDeployFile(root, options = {}) {
|
|
481
707
|
const hints = packageHints(root);
|
|
482
708
|
const software = options.software ?? (hints.bun ? SOFTWARE_CATALOG.filter((entry) => entry.id === "bun" && entry.status === "active").map(({ id, version }) => ({ id, version })) : []);
|
|
@@ -485,42 +711,42 @@ function defaultDeployFile(root, options = {}) {
|
|
|
485
711
|
const build = hints.build ?? blocker("replace the build step with the project build command");
|
|
486
712
|
return {
|
|
487
713
|
$schema: DEPLOY_SCHEMA_URL,
|
|
488
|
-
version:
|
|
714
|
+
version: 3,
|
|
489
715
|
name: safeName(options.name ?? hints.name ?? basename(root)),
|
|
490
716
|
...options.requireAttestation ? { requireAttestation: true } : {},
|
|
491
717
|
profiles: { [profile]: { software } },
|
|
492
718
|
steps: [
|
|
493
|
-
{ name: "build", phase: "build", scope: "target",
|
|
719
|
+
{ name: "build", phase: "build", scope: "target", exec: build, timeoutMs: 600000 },
|
|
494
720
|
{
|
|
495
721
|
name: "promote release",
|
|
496
722
|
phase: "release",
|
|
497
723
|
scope: "target",
|
|
498
|
-
|
|
724
|
+
exec: blocker("replace the release step with an atomic promotion command"),
|
|
499
725
|
timeoutMs: 120000
|
|
500
726
|
},
|
|
501
727
|
{
|
|
502
728
|
name: "health check",
|
|
503
729
|
phase: "health",
|
|
504
730
|
scope: "target",
|
|
505
|
-
|
|
731
|
+
exec: blocker("replace the health step with a bounded local health check"),
|
|
506
732
|
timeoutMs: 30000
|
|
507
733
|
}
|
|
508
734
|
]
|
|
509
735
|
};
|
|
510
736
|
}
|
|
511
737
|
function inspectDeployFile(root, options = {}) {
|
|
512
|
-
const
|
|
513
|
-
if (!existsSync(
|
|
738
|
+
const path2 = join2(root, DEPLOY_FILE);
|
|
739
|
+
if (!existsSync(path2))
|
|
514
740
|
throw new Error(`${DEPLOY_FILE} does not exist; run \`fz deploy init\`.`);
|
|
515
|
-
const raw = JSON.parse(readFileSync2(
|
|
741
|
+
const raw = JSON.parse(readFileSync2(path2, "utf8"));
|
|
516
742
|
const definition = parseDeployDefinition(raw, options);
|
|
517
|
-
const problems = definition.steps.filter((step) => step.
|
|
743
|
+
const problems = definition.steps.filter((step) => step.exec.some((argument) => argument.includes(DEPLOY_TODO_PREFIX))).map((step) => `${step.name} still contains the safe initialization blocker`);
|
|
518
744
|
const profiles = Object.keys(definition.profiles).sort();
|
|
519
745
|
return {
|
|
520
|
-
path,
|
|
746
|
+
path: path2,
|
|
521
747
|
definition,
|
|
522
748
|
summary: {
|
|
523
|
-
path,
|
|
749
|
+
path: path2,
|
|
524
750
|
digest: deployDefinitionDigest(definition),
|
|
525
751
|
version: definition.version,
|
|
526
752
|
name: definition.name,
|
|
@@ -535,14 +761,14 @@ function inspectDeployFile(root, options = {}) {
|
|
|
535
761
|
};
|
|
536
762
|
}
|
|
537
763
|
function initializeDeployFile(root, options = {}) {
|
|
538
|
-
const
|
|
539
|
-
if (existsSync(
|
|
764
|
+
const path2 = join2(root, DEPLOY_FILE);
|
|
765
|
+
if (existsSync(path2) && !options.force) {
|
|
540
766
|
throw new Error(`${DEPLOY_FILE} already exists; use --force only when replacing it deliberately.`);
|
|
541
767
|
}
|
|
542
768
|
const raw = defaultDeployFile(root, options);
|
|
543
769
|
parseDeployDefinition(raw, { channel: options.channel });
|
|
544
|
-
|
|
545
|
-
|
|
770
|
+
mkdirSync2(join2(root, ".fz"), { recursive: true });
|
|
771
|
+
writeFileSync2(path2, `${JSON.stringify(raw, null, 2)}
|
|
546
772
|
`, { mode: 420 });
|
|
547
773
|
return inspectDeployFile(root, { channel: options.channel });
|
|
548
774
|
}
|
|
@@ -12,8 +12,21 @@ function within(root, candidate) {
|
|
|
12
12
|
return candidate === root || candidate.startsWith(`${root}${sep}`);
|
|
13
13
|
}
|
|
14
14
|
function validate(root, input) {
|
|
15
|
-
if (!input ||
|
|
16
|
-
throw new Error("invalid deployment
|
|
15
|
+
if (!input || !Array.isArray(input.argv) || input.argv.length < 1 || input.argv.length > 256) {
|
|
16
|
+
throw new Error("invalid deployment argv");
|
|
17
|
+
}
|
|
18
|
+
let argvBytes = 0;
|
|
19
|
+
const argv = input.argv.map((argument) => {
|
|
20
|
+
if (typeof argument !== "string" || argument.length < 1 || argument.length > 16384 || argument.includes("\x00")) {
|
|
21
|
+
throw new Error("invalid deployment argument");
|
|
22
|
+
}
|
|
23
|
+
argvBytes += Buffer.byteLength(argument);
|
|
24
|
+
if (argvBytes > 64 * 1024)
|
|
25
|
+
throw new Error("deployment argv is too large");
|
|
26
|
+
return argument;
|
|
27
|
+
});
|
|
28
|
+
if (argv[0].includes("/../") || argv[0].endsWith("/..")) {
|
|
29
|
+
throw new Error("deployment executable path is invalid");
|
|
17
30
|
}
|
|
18
31
|
if (!input.cwd || !isAbsolute(input.cwd))
|
|
19
32
|
throw new Error("deployment command needs an absolute working directory");
|
|
@@ -35,7 +48,7 @@ function validate(root, input) {
|
|
|
35
48
|
throw new Error("deployment environment is too large");
|
|
36
49
|
env[name] = value;
|
|
37
50
|
}
|
|
38
|
-
return {
|
|
51
|
+
return { argv, cwd: realCwd, env, timeoutMs };
|
|
39
52
|
}
|
|
40
53
|
async function limited(stream) {
|
|
41
54
|
const reader = stream.getReader();
|
|
@@ -57,7 +70,7 @@ async function limited(stream) {
|
|
|
57
70
|
[output truncated at ${MAX_OUTPUT_BYTES} bytes]` : output;
|
|
58
71
|
}
|
|
59
72
|
async function execute(input, home) {
|
|
60
|
-
const child = Bun.spawn([
|
|
73
|
+
const child = Bun.spawn([...input.argv], {
|
|
61
74
|
cwd: input.cwd,
|
|
62
75
|
detached: true,
|
|
63
76
|
stdout: "pipe",
|
|
@@ -96,7 +109,7 @@ async function execute(input, home) {
|
|
|
96
109
|
return {
|
|
97
110
|
exitCode: timedOut ? 124 : exitCode,
|
|
98
111
|
output: `${stdout}${stderr}${timedOut ? `
|
|
99
|
-
|
|
112
|
+
process exceeded ${input.timeoutMs}ms` : ""}`
|
|
100
113
|
};
|
|
101
114
|
}
|
|
102
115
|
function startDeploymentRunner(options) {
|
package/dist/deployment.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type DrainReport, type QueueTask } from '@forgezero/runtime/queue';
|
|
2
2
|
import { type GitHostResolver } from './git-egress';
|
|
3
3
|
import type { SoftwareRequirement } from './software';
|
|
4
|
+
import type { DeploymentService } from './definition';
|
|
5
|
+
import type { ServiceActivationState } from './service-supervisor';
|
|
4
6
|
import { type RunResult } from './pipeline';
|
|
5
7
|
import type { SecretCache } from './cache';
|
|
6
8
|
import type { AttestationSource } from './socket';
|
|
7
9
|
import { type CapacityCalibration, type CapacityCalibrationOptions } from './capacity-calibration';
|
|
8
10
|
export interface CommandInput {
|
|
9
|
-
|
|
11
|
+
argv: readonly string[];
|
|
10
12
|
cwd?: string;
|
|
11
13
|
env?: Record<string, string>;
|
|
12
14
|
timeoutMs?: number;
|
|
@@ -63,6 +65,8 @@ export interface DeploymentOptions {
|
|
|
63
65
|
publicApiUrl?: string;
|
|
64
66
|
sourceAuth?: GitSourceAuth;
|
|
65
67
|
gitCredentialPath?: string;
|
|
68
|
+
/** Fixed typed SSH adapter installed beside fz-agent; Git invokes it directly. */
|
|
69
|
+
gitSshPath?: string;
|
|
66
70
|
knownHostsPath?: string;
|
|
67
71
|
/** Server-owned, operator-pinned host keys for a dynamically assigned source. */
|
|
68
72
|
knownHostsContent?: string;
|
|
@@ -77,6 +81,14 @@ export interface DeploymentOptions {
|
|
|
77
81
|
projectExec?: (input: CommandInput) => Promise<CommandResult>;
|
|
78
82
|
/** Root-owned fixed strategy helper; repository data contains coordinates only. */
|
|
79
83
|
ensureSoftware?: (requirements: readonly SoftwareRequirement[]) => Promise<unknown>;
|
|
84
|
+
/** Fixed root helper that owns systemd/nginx promotion; repository code never receives it. */
|
|
85
|
+
activateService?: (request: {
|
|
86
|
+
key: string;
|
|
87
|
+
revision: string;
|
|
88
|
+
release: string;
|
|
89
|
+
root: string;
|
|
90
|
+
service: DeploymentService;
|
|
91
|
+
}) => Promise<ServiceActivationState>;
|
|
80
92
|
/** Agent-owned directory; definitions cannot choose or overwrite this path. */
|
|
81
93
|
capacityEvidenceDirectory?: string;
|
|
82
94
|
/** Test/embedding seam. Production runs the bounded loopback GET calibrator. */
|