@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.
Files changed (64) hide show
  1. package/README.md +573 -424
  2. package/dist/agent-heartbeat.js +6 -3
  3. package/dist/agent-update-helper.js +5 -2
  4. package/dist/agent-update.js +5 -2
  5. package/dist/bootstrap.d.ts +17 -8
  6. package/dist/bootstrap.js +1504 -512
  7. package/dist/cli/agent-install.d.ts +6 -5
  8. package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
  9. package/dist/cli/maintenance.d.ts +23 -0
  10. package/dist/cli/run.d.ts +3 -1
  11. package/dist/cli/session-store.d.ts +5 -0
  12. package/dist/cloudflare-bootstrap.d.ts +73 -35
  13. package/dist/cloudflare-bootstrap.js +587 -90
  14. package/dist/cloudflare-edge.d.ts +64 -12
  15. package/dist/cloudflare-edge.js +103 -8
  16. package/dist/community-rehearsal-host.d.ts +51 -0
  17. package/dist/community-rehearsal-host.js +272 -0
  18. package/dist/credential-schema.d.ts +54 -0
  19. package/dist/credential-schema.js +47 -0
  20. package/dist/definition.d.ts +31 -5
  21. package/dist/definition.js +271 -44
  22. package/dist/deploy-file.js +294 -68
  23. package/dist/deployment-runner.js +18 -5
  24. package/dist/deployment.d.ts +13 -1
  25. package/dist/fz-agent.js +9162 -7564
  26. package/dist/fz-git-ssh.js +122 -0
  27. package/dist/fz.js +5679 -5077
  28. package/dist/git-ssh.d.ts +5 -0
  29. package/dist/guest-enrolment.d.ts +2 -0
  30. package/dist/guest-enrolment.js +1 -0
  31. package/dist/host-maintenance.d.ts +39 -0
  32. package/dist/host-maintenance.js +135 -0
  33. package/dist/index.d.ts +4 -2
  34. package/dist/mesh-connector.d.ts +16 -0
  35. package/dist/mesh-connector.js +46 -0
  36. package/dist/metal-bootstrap.js +145 -7
  37. package/dist/metal-helper-socket.js +61 -31
  38. package/dist/metal-provision.d.ts +2 -2
  39. package/dist/metal-provision.js +62 -32
  40. package/dist/operator-bootstrap.d.ts +90 -0
  41. package/dist/operator-bootstrap.js +5704 -0
  42. package/dist/otel-collector.d.ts +18 -0
  43. package/dist/pipeline.d.ts +3 -2
  44. package/dist/pipeline.js +1 -1
  45. package/dist/platform-bootstrap-runtime.d.ts +39 -21
  46. package/dist/platform-bootstrap-runtime.js +182 -59
  47. package/dist/platform-fleet-verification.d.ts +19 -0
  48. package/dist/platform-fleet-verification.js +3873 -0
  49. package/dist/platform-genesis-config.d.ts +7 -0
  50. package/dist/platform-genesis.d.ts +17 -0
  51. package/dist/provision.d.ts +76 -3
  52. package/dist/provision.js +1061 -229
  53. package/dist/recovery-host.d.ts +7 -0
  54. package/dist/recovery-host.js +124 -0
  55. package/dist/service-supervisor.d.ts +42 -0
  56. package/dist/software-helper.d.ts +4 -0
  57. package/dist/software-helper.js +865 -63
  58. package/dist/software.d.ts +14 -3
  59. package/dist/software.js +163 -37
  60. package/dist/ssh-bootstrap.d.ts +97 -0
  61. package/dist/supervised-app.d.ts +2 -0
  62. package/dist/version.d.ts +1 -1
  63. package/package.json +175 -164
  64. package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
@@ -4,10 +4,8 @@ export interface CloudflareEdgeConfig {
4
4
  tunnelId: string;
5
5
  hostname: string;
6
6
  service: string;
7
+ /** The single attended management capability used for both Tunnel and DNS writes. */
7
8
  apiToken: string;
8
- /** Optional least-privilege overrides. `apiToken` remains the single-token path. */
9
- tunnelApiToken?: string;
10
- dnsApiToken?: string;
11
9
  }
12
10
  export interface CloudflarePrivateRouteConfig {
13
11
  accountId: string;
@@ -73,6 +71,21 @@ interface CloudflareSplitTunnelEntry {
73
71
  host?: string;
74
72
  description?: string;
75
73
  }
74
+ /**
75
+ * Reconcile the exact private networks that Mesh nodes must send through
76
+ * Cloudflare. Existing profile entries are preserved; only ForgeZero-owned
77
+ * descriptions are added. The caller must use a dedicated Mesh device profile.
78
+ */
79
+ export declare function ensureCloudflareWarpNetworkIncludes(config: {
80
+ accountId: string;
81
+ policyId: string;
82
+ networks: readonly string[];
83
+ descriptionPrefix: string;
84
+ apiToken: string;
85
+ }, fetcher?: typeof fetch): Promise<{
86
+ entries: readonly CloudflareSplitTunnelEntry[];
87
+ created: number;
88
+ }>;
76
89
  /** Preserve the profile's existing entries while including one exact database host route. */
77
90
  export declare function ensureCloudflareWarpDatabaseInclude(config: CloudflareWarpIncludeConfig, fetcher?: typeof fetch): Promise<{
78
91
  entries: readonly CloudflareSplitTunnelEntry[];
@@ -85,21 +98,60 @@ export declare function removeCloudflareWarpDatabaseInclude(config: Omit<Cloudfl
85
98
  }>;
86
99
  /** Reconcile one remotely-managed tunnel route and its proxied DNS record. */
87
100
  export declare function configureCloudflareEdge(config: CloudflareEdgeConfig, fetcher?: typeof fetch): Promise<void>;
88
- export interface CloudflareApiTokens {
89
- /** Backward-compatible single-token input; two scoped tokens are preferred. */
90
- apiToken?: string;
91
- /** Attended management token mapped to Tunnel and DNS operations. */
92
- tunnelApiToken?: string;
93
- dnsApiToken?: string;
94
- /** Runtime token used only for KV node-directory writes. */
95
- kvApiToken?: string;
96
- }
97
101
  export interface CloudflareTunnel {
98
102
  id: string;
99
103
  name: string;
100
104
  status?: string;
101
105
  deleted_at?: string | null;
102
106
  }
107
+ export interface CloudflareMeshConnector {
108
+ id: string;
109
+ name: string;
110
+ status?: 'inactive' | 'degraded' | 'healthy' | 'down';
111
+ tun_type?: 'warp_connector';
112
+ deleted_at?: string | null;
113
+ }
114
+ export interface CloudflareDurableObjectNamespace {
115
+ id?: string;
116
+ name?: string;
117
+ class?: string;
118
+ script?: string;
119
+ use_sqlite?: boolean;
120
+ }
121
+ /**
122
+ * Prove that the separately deployed Worker already owns at least one Durable
123
+ * Object namespace. ForgeZero does not deploy or alter the Worker here; this is
124
+ * a read-only control-plane check before installing shared application secrets.
125
+ */
126
+ export declare function verifyCloudflareWorkerDurableObjects(config: {
127
+ accountId: string;
128
+ scriptName: string;
129
+ apiToken: string;
130
+ }, fetcher?: typeof fetch): Promise<readonly CloudflareDurableObjectNamespace[]>;
131
+ /** Install only the two application secrets consumed by the existing Worker. */
132
+ export declare function configureCloudflareRealtimeSecrets(config: {
133
+ accountId: string;
134
+ scriptName: string;
135
+ publishSecret: string;
136
+ ticketSecret: string;
137
+ apiToken: string;
138
+ }, fetcher?: typeof fetch): Promise<void>;
139
+ /**
140
+ * Create or adopt one Cloudflare Mesh node (the current WARP Connector
141
+ * product name) and retrieve the node-registration token. This is the
142
+ * bidirectional L3/L4 boundary for metal-to-metal traffic; it is deliberately
143
+ * separate from the public, inbound-only cloudflared Tunnel.
144
+ */
145
+ export declare function ensureCloudflareMeshConnector(config: {
146
+ accountId: string;
147
+ name: string;
148
+ highAvailability: boolean;
149
+ apiToken: string;
150
+ }, fetcher?: typeof fetch): Promise<{
151
+ connector: CloudflareMeshConnector;
152
+ connectorToken: string;
153
+ created: boolean;
154
+ }>;
103
155
  /**
104
156
  * Create or reuse one remotely-managed Tunnel and obtain its connector token.
105
157
  * The token is returned once to the caller so it can be PQ-delivered to the
@@ -94,6 +94,39 @@ async function removeCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
94
94
  network: privateDatabaseHostRoute(config.privateAddress)
95
95
  }, fetcher);
96
96
  }
97
+ var privateNetworkCidr = (value) => {
98
+ const [address, prefixText, ...extra] = value.trim().toLowerCase().split("/");
99
+ const family = isIP(address ?? "");
100
+ const prefix = Number(prefixText);
101
+ if (extra.length || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128) || !isPrivateDatabaseAddress(address)) {
102
+ throw new Error("Cloudflare WARP include must be an explicit private IPv4 or IPv6 CIDR");
103
+ }
104
+ return `${address}/${prefix}`;
105
+ };
106
+ async function ensureCloudflareWarpNetworkIncludes(config, fetcher = fetch) {
107
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
108
+ throw new Error("Cloudflare WARP policy id is invalid");
109
+ if (!Array.isArray(config.networks) || config.networks.length < 1 || config.networks.length > 256) {
110
+ throw new Error("Cloudflare WARP networks must contain 1-256 explicit CIDRs");
111
+ }
112
+ const networks = config.networks.map(privateNetworkCidr);
113
+ if (new Set(networks).size !== networks.length)
114
+ throw new Error("Cloudflare WARP networks must be unique");
115
+ const path = `/accounts/${config.accountId}/devices/policy/${config.policyId}/include`;
116
+ const entries = await cf(config, path, {}, fetcher);
117
+ const present = new Set(entries.flatMap(({ address }) => address ? [address.toLowerCase()] : []));
118
+ const additions = networks.filter((network) => !present.has(network)).map((address) => ({
119
+ address,
120
+ description: `${config.descriptionPrefix}:${address}`.slice(0, 100)
121
+ }));
122
+ if (!additions.length)
123
+ return { entries, created: 0 };
124
+ const updated = await cf(config, path, {
125
+ method: "PUT",
126
+ body: JSON.stringify([...entries, ...additions])
127
+ }, fetcher);
128
+ return { entries: updated, created: additions.length };
129
+ }
97
130
  async function ensureCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
98
131
  if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
99
132
  throw new Error("Cloudflare WARP policy id is invalid");
@@ -127,13 +160,11 @@ async function removeCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
127
160
  return { entries: updated, removed: true };
128
161
  }
129
162
  async function configureCloudflareEdge(config, fetcher = fetch) {
130
- const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
131
- const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
132
163
  const tunnelPath = `/accounts/${config.accountId}/cfd_tunnel/${config.tunnelId}/configurations`;
133
164
  const dnsPath = `/zones/${config.zoneId}/dns_records`;
134
165
  const [current, records] = await Promise.all([
135
- cf(tunnelAuth, tunnelPath, {}, fetcher),
136
- cf(dnsAuth, `${dnsPath}?name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher)
166
+ cf(config, tunnelPath, {}, fetcher),
167
+ cf(config, `${dnsPath}?name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher)
137
168
  ]);
138
169
  if (records.length > 1) {
139
170
  throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
@@ -151,7 +182,7 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
151
182
  ...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
152
183
  ];
153
184
  if (JSON.stringify(existing) !== JSON.stringify(desiredIngress)) {
154
- await cf(tunnelAuth, tunnelPath, {
185
+ await cf(config, tunnelPath, {
155
186
  method: "PUT",
156
187
  body: JSON.stringify({ config: { ingress: desiredIngress } })
157
188
  }, fetcher);
@@ -165,12 +196,72 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
165
196
  };
166
197
  const dnsAlreadyCorrect = existingRecord?.type === record.type && existingRecord.name?.toLowerCase() === record.name.toLowerCase() && existingRecord.content?.toLowerCase() === record.content.toLowerCase() && existingRecord.proxied === true && existingRecord.ttl === 1;
167
198
  if (!dnsAlreadyCorrect) {
168
- await cf(dnsAuth, existingRecord ? `${dnsPath}/${encodeURIComponent(existingRecord.id)}` : dnsPath, {
169
- method: existingRecord ? "PUT" : "POST",
170
- body: JSON.stringify(record)
199
+ await cf(config, existingRecord ? `${dnsPath}/${encodeURIComponent(existingRecord.id)}` : dnsPath, {
200
+ method: existingRecord ? "PATCH" : "POST",
201
+ body: JSON.stringify(existingRecord ? {
202
+ ...record,
203
+ ...existingRecord.comment === undefined ? {} : { comment: existingRecord.comment },
204
+ ...existingRecord.tags === undefined ? {} : { tags: existingRecord.tags },
205
+ ...existingRecord.settings === undefined ? {} : { settings: existingRecord.settings }
206
+ } : record)
171
207
  }, fetcher);
172
208
  }
173
209
  }
210
+ async function verifyCloudflareWorkerDurableObjects(config, fetcher = fetch) {
211
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(config.scriptName)) {
212
+ throw new Error("Cloudflare Worker script name is invalid");
213
+ }
214
+ const namespaces = await cf(config, `/accounts/${config.accountId}/workers/durable_objects/namespaces?per_page=1000`, {}, fetcher);
215
+ const owned = namespaces.filter(({ script }) => script === config.scriptName);
216
+ if (!owned.length)
217
+ throw new Error(`Cloudflare Worker ${config.scriptName} has no Durable Object namespace`);
218
+ return owned;
219
+ }
220
+ async function configureCloudflareRealtimeSecrets(config, fetcher = fetch) {
221
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(config.scriptName) || ![config.publishSecret, config.ticketSecret].every((value) => /^[A-Za-z0-9_-]{64,128}$/.test(value)) || config.publishSecret === config.ticketSecret) {
222
+ throw new Error("Cloudflare realtime secret coordinates are invalid");
223
+ }
224
+ await cf(config, `/accounts/${config.accountId}/workers/scripts/${encodeURIComponent(config.scriptName)}/secrets-bulk`, {
225
+ method: "PATCH",
226
+ body: JSON.stringify({
227
+ secrets: {
228
+ REALTIME_PUBLISH_SECRET: {
229
+ name: "REALTIME_PUBLISH_SECRET",
230
+ text: config.publishSecret,
231
+ type: "secret_text"
232
+ },
233
+ REALTIME_TICKET_SECRET: {
234
+ name: "REALTIME_TICKET_SECRET",
235
+ text: config.ticketSecret,
236
+ type: "secret_text"
237
+ }
238
+ }
239
+ })
240
+ }, fetcher);
241
+ }
242
+ async function ensureCloudflareMeshConnector(config, fetcher = fetch) {
243
+ const name = config.name.trim();
244
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
245
+ throw new Error("Cloudflare Mesh connector name is invalid");
246
+ }
247
+ const path = `/accounts/${config.accountId}/warp_connector`;
248
+ const connectors = await cf(config, `${path}?is_deleted=false&name=${encodeURIComponent(name)}&per_page=1000`, {}, fetcher);
249
+ const matches = connectors.filter((connector2) => connector2.name === name && !connector2.deleted_at);
250
+ if (matches.length > 1)
251
+ throw new Error(`Cloudflare Mesh connector ${name} is ambiguous`);
252
+ const created = !matches[0];
253
+ const connector = matches[0] ?? await cf(config, path, {
254
+ method: "POST",
255
+ body: JSON.stringify({ name, ha: config.highAvailability })
256
+ }, fetcher);
257
+ if (!connector.id)
258
+ throw new Error("Cloudflare returned an invalid Mesh connector");
259
+ const connectorToken = await cf(config, `${path}/${encodeURIComponent(connector.id)}/token`, {}, fetcher);
260
+ if (!connectorToken || connectorToken.length > 16384) {
261
+ throw new Error("Cloudflare returned an invalid Mesh connector token");
262
+ }
263
+ return { connector, connectorToken, created };
264
+ }
174
265
  async function ensureCloudflareTunnel(config, fetcher = fetch) {
175
266
  const name = config.name.trim();
176
267
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
@@ -193,12 +284,16 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
193
284
  return { tunnel, connectorToken, created };
194
285
  }
195
286
  export {
287
+ verifyCloudflareWorkerDurableObjects,
196
288
  removeCloudflareWarpDatabaseInclude,
197
289
  removeCloudflarePrivateRoute,
198
290
  removeCloudflarePrivateDatabaseRoute,
291
+ ensureCloudflareWarpNetworkIncludes,
199
292
  ensureCloudflareWarpDatabaseInclude,
200
293
  ensureCloudflareTunnel,
201
294
  ensureCloudflarePrivateRoute,
202
295
  ensureCloudflarePrivateDatabaseRoute,
296
+ ensureCloudflareMeshConnector,
297
+ configureCloudflareRealtimeSecrets,
203
298
  configureCloudflareEdge
204
299
  };
@@ -0,0 +1,51 @@
1
+ export declare const COMMUNITY_REHEARSAL_VERSION = "3.11.14";
2
+ export declare const COMMUNITY_REHEARSAL_ROOT = "/opt/forgezero-rehearsals/community-cluster-api";
3
+ export declare const COMMUNITY_DATABASE_ROOT = "/var/lib/forgezero-rehearsal-cluster";
4
+ export declare const COMMUNITY_CREDENTIAL_ROOT = "/etc/forgezero-rehearsal/creds";
5
+ export type CommunityRehearsalApiOperation = 'health' | 'init' | 'write' | 'read' | 'query' | 'cluster';
6
+ export type CommunityRehearsalHostRequest = {
7
+ action: 'prepare';
8
+ node: string;
9
+ address: string;
10
+ agency: boolean;
11
+ release: string;
12
+ archivePath: string;
13
+ archiveSha256: string;
14
+ jwt: string;
15
+ } | {
16
+ action: 'database-enable' | 'api-enable' | 'database-ready' | 'starter-ready' | 'api-ready' | 'database-status' | 'api-status';
17
+ node: string;
18
+ } | {
19
+ action: 'api';
20
+ node: string;
21
+ operation: CommunityRehearsalApiOperation;
22
+ key?: string;
23
+ value?: string;
24
+ };
25
+ export type CommunityHostOperation = {
26
+ kind: 'remove-tree';
27
+ path: typeof COMMUNITY_DATABASE_ROOT;
28
+ } | {
29
+ kind: 'write';
30
+ path: string;
31
+ content: string;
32
+ mode: number;
33
+ } | {
34
+ kind: 'unlink';
35
+ path: string;
36
+ } | {
37
+ kind: 'symlink';
38
+ target: string;
39
+ path: string;
40
+ } | {
41
+ kind: 'exec';
42
+ argv: readonly string[];
43
+ stdin?: string;
44
+ accepted?: readonly number[];
45
+ };
46
+ export declare function parseCommunityRehearsalHostRequest(value: unknown): CommunityRehearsalHostRequest;
47
+ export declare function planCommunityRehearsalPrepare(request: Extract<CommunityRehearsalHostRequest, {
48
+ action: 'prepare';
49
+ }>): CommunityHostOperation[];
50
+ /** Execute only the dedicated four-node rehearsal operation selected by a typed request. */
51
+ export declare function runCommunityRehearsalHost(request: CommunityRehearsalHostRequest): Promise<Record<string, unknown>>;
@@ -0,0 +1,272 @@
1
+ // src/community-rehearsal-host.ts
2
+ import { createHash } from "node:crypto";
3
+ import { lstatSync, mkdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ var COMMUNITY_REHEARSAL_VERSION = "3.11.14";
6
+ var COMMUNITY_REHEARSAL_ROOT = "/opt/forgezero-rehearsals/community-cluster-api";
7
+ var COMMUNITY_DATABASE_ROOT = "/var/lib/forgezero-rehearsal-cluster";
8
+ var COMMUNITY_CREDENTIAL_ROOT = "/etc/forgezero-rehearsal/creds";
9
+ var NODES = new Map([
10
+ ["dev-fz-n1", { address: "10.42.0.21", agency: true }],
11
+ ["dev-fz-n2", { address: "10.42.0.22", agency: true }],
12
+ ["dev-fz-n3", { address: "10.42.0.23", agency: true }],
13
+ ["dev-fz-n4", { address: "10.42.0.24", agency: false }]
14
+ ]);
15
+ var RELEASE = /^[a-f0-9]{64}$/;
16
+ var exactObject = (value) => {
17
+ if (!value || typeof value !== "object" || Array.isArray(value))
18
+ throw new Error("community rehearsal request must be an object");
19
+ return value;
20
+ };
21
+ var exactNode = (node) => {
22
+ if (typeof node !== "string")
23
+ throw new Error("community rehearsal node is invalid");
24
+ const expected = NODES.get(node);
25
+ if (!expected)
26
+ throw new Error("community rehearsal accepts only the dedicated dev-fz-n1..n4 fleet");
27
+ return { name: node, ...expected };
28
+ };
29
+ function parseCommunityRehearsalHostRequest(value) {
30
+ const row = exactObject(value);
31
+ const node = exactNode(row.node);
32
+ const action = row.action;
33
+ if (action === "prepare") {
34
+ const allowed = ["action", "node", "address", "agency", "release", "archivePath", "archiveSha256", "jwt"];
35
+ if (Object.keys(row).some((key) => !allowed.includes(key)) || row.address !== node.address || row.agency !== node.agency || typeof row.release !== "string" || !RELEASE.test(row.release) || row.archiveSha256 !== row.release || row.archivePath !== `/tmp/forgezero-community-${row.release}.tar.gz` || typeof row.jwt !== "string" || !/^[a-f0-9]{64}$/.test(row.jwt)) {
36
+ throw new Error("community rehearsal prepare request does not match the dedicated fleet contract");
37
+ }
38
+ return row;
39
+ }
40
+ if (["database-enable", "api-enable", "database-ready", "starter-ready", "api-ready", "database-status", "api-status"].includes(String(action))) {
41
+ if (Object.keys(row).some((key) => !["action", "node"].includes(key)))
42
+ throw new Error("community rehearsal host action has unknown fields");
43
+ return { action, node: node.name };
44
+ }
45
+ if (action === "api") {
46
+ if (Object.keys(row).some((key) => !["action", "node", "operation", "key", "value"].includes(key)) || !["health", "init", "write", "read", "query", "cluster"].includes(String(row.operation)) || row.key !== undefined && (typeof row.key !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(row.key)) || row.value !== undefined && (typeof row.value !== "string" || row.value.length > 4096)) {
47
+ throw new Error("community rehearsal API request is invalid");
48
+ }
49
+ if ((row.operation === "write" || row.operation === "read") && typeof row.key !== "string")
50
+ throw new Error("community rehearsal API key is required");
51
+ if ((row.operation === "write" || row.operation === "query") && typeof row.value !== "string")
52
+ throw new Error("community rehearsal API value is required");
53
+ return row;
54
+ }
55
+ throw new Error("unsupported community rehearsal host action");
56
+ }
57
+ var databaseUnit = (node) => {
58
+ const join = node.name === "dev-fz-n1" ? "" : " --starter.join=10.42.0.21";
59
+ const role = node.agency ? "" : " --cluster.start-agent=false --cluster.start-coordinator=true --cluster.start-dbserver=true";
60
+ return `[Unit]
61
+ Description=ForgeZero isolated Community ${COMMUNITY_REHEARSAL_VERSION} rehearsal cluster
62
+ After=network-online.target
63
+ Wants=network-online.target
64
+
65
+ [Service]
66
+ Type=simple
67
+ User=arangodb
68
+ Group=arangodb
69
+ LoadCredentialEncrypted=arangodb-jwt:${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred
70
+ ExecStart=/usr/bin/arangodb --starter.mode=cluster --starter.address=${node.address} --starter.host=${node.address} --starter.data-dir=${COMMUNITY_DATABASE_ROOT} --starter.disable-ipv6 --auth.jwt-secret=%d/arangodb-jwt${join}${role}
71
+ Restart=always
72
+ RestartSec=5
73
+ LimitNOFILE=100000
74
+ LimitCORE=0
75
+ UMask=0077
76
+ NoNewPrivileges=true
77
+ PrivateTmp=true
78
+ PrivateDevices=true
79
+ ProtectSystem=strict
80
+ ProtectHome=true
81
+ ReadWritePaths=${COMMUNITY_DATABASE_ROOT}
82
+ ProtectKernelTunables=true
83
+ ProtectKernelModules=true
84
+ ProtectControlGroups=true
85
+ RestrictSUIDSGID=true
86
+ RestrictRealtime=true
87
+ LockPersonality=true
88
+
89
+ [Install]
90
+ WantedBy=multi-user.target
91
+ `;
92
+ };
93
+ var apiUnit = (node) => `[Unit]
94
+ Description=ForgeZero typed Community cluster rehearsal API
95
+ After=forgezero-rehearsal-db.service network-online.target
96
+ Requires=forgezero-rehearsal-db.service
97
+
98
+ [Service]
99
+ Type=simple
100
+ User=forgezero
101
+ Group=forgezero
102
+ WorkingDirectory=${COMMUNITY_REHEARSAL_ROOT}/current
103
+ LoadCredentialEncrypted=arangodb-jwt:${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred
104
+ Environment=HOST=127.0.0.1
105
+ Environment=PORT=8787
106
+ Environment=NODE_NAME=${node.name}
107
+ Environment=ARANGO_URL=http://127.0.0.1:8529
108
+ Environment=ARANGO_URLS=http://10.42.0.21:8529,http://10.42.0.22:8529,http://10.42.0.23:8529,http://10.42.0.24:8529
109
+ Environment=ARANGO_READ_PREFERRED_URLS=http://10.42.0.24:8529
110
+ Environment=ARANGO_READ_PREFERRED_FALLBACK=error
111
+ ExecStart=/usr/local/bin/bun src/index.ts
112
+ Restart=always
113
+ RestartSec=2
114
+ LimitCORE=0
115
+ NoNewPrivileges=true
116
+ PrivateTmp=true
117
+ ProtectSystem=strict
118
+ ProtectHome=true
119
+ ProtectKernelTunables=true
120
+ ProtectKernelModules=true
121
+ ProtectControlGroups=true
122
+ RestrictSUIDSGID=true
123
+ RestrictRealtime=true
124
+ LockPersonality=true
125
+
126
+ [Install]
127
+ WantedBy=multi-user.target
128
+ `;
129
+ function planCommunityRehearsalPrepare(request) {
130
+ const node = exactNode(request.node);
131
+ const releaseRoot = `${COMMUNITY_REHEARSAL_ROOT}/releases/${request.release}`;
132
+ return [
133
+ { kind: "exec", argv: ["/usr/bin/arangod", "--version"] },
134
+ { kind: "exec", argv: ["/usr/bin/systemctl", "stop", "forgezero-community-api.service"], accepted: [0, 5] },
135
+ { kind: "exec", argv: ["/usr/bin/systemctl", "stop", "forgezero-rehearsal-db.service"], accepted: [0, 5] },
136
+ { kind: "remove-tree", path: COMMUNITY_DATABASE_ROOT },
137
+ { kind: "exec", argv: ["/usr/bin/install", "-d", "-m", "0700", "-o", "root", "-g", "root", COMMUNITY_CREDENTIAL_ROOT] },
138
+ { kind: "exec", argv: ["/usr/bin/install", "-d", "-m", "0700", "-o", "arangodb", "-g", "arangodb", COMMUNITY_DATABASE_ROOT] },
139
+ { kind: "exec", argv: ["/usr/bin/install", "-d", "-m", "0755", "-o", "root", "-g", "root", releaseRoot] },
140
+ { kind: "unlink", path: `${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred.next` },
141
+ { kind: "exec", argv: ["/usr/bin/systemd-creds", "encrypt", "--name=arangodb-jwt", "-", `${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred.next`], stdin: `${request.jwt}
142
+ ` },
143
+ { kind: "exec", argv: ["/usr/bin/chmod", "0400", `${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred.next`] },
144
+ { kind: "exec", argv: ["/usr/bin/mv", "-f", `${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred.next`, `${COMMUNITY_CREDENTIAL_ROOT}/arangodb-jwt.cred`] },
145
+ { kind: "write", path: "/etc/systemd/system/forgezero-rehearsal-db.service", content: databaseUnit(node), mode: 420 },
146
+ { kind: "exec", argv: ["/usr/bin/tar", "-xzf", request.archivePath, "-C", releaseRoot] },
147
+ { kind: "exec", argv: ["/usr/bin/chown", "-R", "forgezero:forgezero", releaseRoot] },
148
+ { kind: "exec", argv: ["/usr/sbin/runuser", "-u", "forgezero", "--", "/usr/local/bin/bun", "install", "--cwd", releaseRoot, "--frozen-lockfile", "--production"] },
149
+ { kind: "unlink", path: `${COMMUNITY_REHEARSAL_ROOT}/current` },
150
+ { kind: "symlink", target: releaseRoot, path: `${COMMUNITY_REHEARSAL_ROOT}/current` },
151
+ { kind: "write", path: "/etc/systemd/system/forgezero-community-api.service", content: apiUnit(node), mode: 420 },
152
+ { kind: "unlink", path: request.archivePath }
153
+ ];
154
+ }
155
+ async function exec(argv, stdin) {
156
+ const child = Bun.spawn([...argv], { stdin: stdin === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe" });
157
+ if (stdin !== undefined && child.stdin && typeof child.stdin !== "number") {
158
+ child.stdin.write(stdin);
159
+ child.stdin.end();
160
+ }
161
+ const [stdout, stderr, exitCode] = await Promise.all([
162
+ new Response(child.stdout).text(),
163
+ new Response(child.stderr).text(),
164
+ child.exited
165
+ ]);
166
+ return { exitCode, output: `${stdout}${stderr}`.slice(0, 256 * 1024) };
167
+ }
168
+ async function applyOperations(operations) {
169
+ for (const operation of operations) {
170
+ if (operation.kind === "remove-tree")
171
+ rmSync(operation.path, { recursive: true, force: true });
172
+ else if (operation.kind === "write") {
173
+ mkdirSync(dirname(operation.path), { recursive: true });
174
+ writeFileSync(operation.path, operation.content, { mode: operation.mode });
175
+ } else if (operation.kind === "unlink") {
176
+ try {
177
+ unlinkSync(operation.path);
178
+ } catch (cause) {
179
+ if (cause.code !== "ENOENT")
180
+ throw cause;
181
+ }
182
+ } else if (operation.kind === "symlink")
183
+ symlinkSync(operation.target, operation.path);
184
+ else {
185
+ const result = await exec(operation.argv, operation.stdin);
186
+ if (!(operation.accepted ?? [0]).includes(result.exitCode))
187
+ throw new Error(`${operation.argv[0]} failed (${result.exitCode}): ${result.output.trim()}`);
188
+ if (operation.argv[0] === "/usr/bin/arangod" && !result.output.split(/\r?\n/, 1)[0]?.includes(COMMUNITY_REHEARSAL_VERSION)) {
189
+ throw new Error(`community rehearsal requires ArangoDB ${COMMUNITY_REHEARSAL_VERSION}`);
190
+ }
191
+ }
192
+ }
193
+ }
194
+ var responseFor = async (request) => {
195
+ const base = "http://127.0.0.1:8787";
196
+ if (request.operation === "health")
197
+ return fetch(`${base}/health`, { signal: AbortSignal.timeout(15000) });
198
+ if (request.operation === "init")
199
+ return fetch(`${base}/init`, { method: "POST", signal: AbortSignal.timeout(15000) });
200
+ if (request.operation === "write")
201
+ return fetch(`${base}/documents`, {
202
+ method: "POST",
203
+ headers: { "content-type": "application/json" },
204
+ body: JSON.stringify({ key: request.key, value: request.value }),
205
+ signal: AbortSignal.timeout(15000)
206
+ });
207
+ if (request.operation === "read")
208
+ return fetch(`${base}/documents/${encodeURIComponent(request.key)}`, { signal: AbortSignal.timeout(15000) });
209
+ if (request.operation === "query")
210
+ return fetch(`${base}/query?value=${encodeURIComponent(request.value)}`, { signal: AbortSignal.timeout(15000) });
211
+ return fetch(`${base}/cluster`, { signal: AbortSignal.timeout(15000) });
212
+ };
213
+ async function runCommunityRehearsalHost(request) {
214
+ if ((process.getuid?.() ?? -1) !== 0)
215
+ throw new Error("community-rehearsal must run as root");
216
+ const node = exactNode(request.node);
217
+ if (request.action === "prepare") {
218
+ const metadata = lstatSync(request.archivePath);
219
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size < 1 || metadata.size > 64 * 1024 * 1024 || createHash("sha256").update(readFileSync(request.archivePath)).digest("hex") !== request.archiveSha256) {
220
+ throw new Error("community rehearsal archive is not the declared bounded release");
221
+ }
222
+ await applyOperations(planCommunityRehearsalPrepare(request));
223
+ return { ok: true, action: request.action, node: node.name, release: request.release };
224
+ }
225
+ if (request.action === "database-enable") {
226
+ await applyOperations([
227
+ { kind: "exec", argv: ["/usr/bin/systemctl", "daemon-reload"] },
228
+ { kind: "exec", argv: ["/usr/bin/systemctl", "enable", "forgezero-rehearsal-db.service"] },
229
+ { kind: "exec", argv: ["/usr/bin/systemctl", "restart", "forgezero-rehearsal-db.service"] }
230
+ ]);
231
+ return { ok: true, action: request.action, node: node.name };
232
+ }
233
+ if (request.action === "api-enable") {
234
+ await applyOperations([
235
+ { kind: "exec", argv: ["/usr/bin/systemctl", "daemon-reload"] },
236
+ { kind: "exec", argv: ["/usr/bin/systemctl", "enable", "--now", "forgezero-community-api.service"] }
237
+ ]);
238
+ return { ok: true, action: request.action, node: node.name };
239
+ }
240
+ if (request.action === "database-status" || request.action === "api-status") {
241
+ const unit = request.action === "database-status" ? "forgezero-rehearsal-db.service" : "forgezero-community-api.service";
242
+ const result = await exec(["/usr/bin/systemctl", "is-active", "--quiet", unit]);
243
+ if (result.exitCode !== 0)
244
+ throw new Error(`${unit} is not active`);
245
+ return { ok: true, action: request.action, node: node.name };
246
+ }
247
+ if (request.action === "database-ready" || request.action === "starter-ready" || request.action === "api-ready") {
248
+ const url = request.action === "database-ready" ? "http://127.0.0.1:8529/_api/version" : request.action === "starter-ready" ? "http://10.42.0.21:8528/version" : "http://127.0.0.1:8787/health";
249
+ const response2 = await fetch(url, { signal: AbortSignal.timeout(2000) }).catch(() => {
250
+ return;
251
+ });
252
+ const ready = Boolean(response2 && (request.action === "database-ready" ? response2.status === 200 || response2.status === 401 : response2.ok));
253
+ await response2?.body?.cancel();
254
+ return { ok: true, action: request.action, node: node.name, ready };
255
+ }
256
+ if (request.action !== "api")
257
+ throw new Error("unsupported community rehearsal host action");
258
+ const response = await responseFor(request);
259
+ const body = await response.text();
260
+ if (!response.ok)
261
+ throw new Error(`community rehearsal API returned HTTP ${response.status}: ${body.slice(0, 4096)}`);
262
+ return { ok: true, action: request.action, node: node.name, operation: request.operation, status: response.status, body };
263
+ }
264
+ export {
265
+ runCommunityRehearsalHost,
266
+ planCommunityRehearsalPrepare,
267
+ parseCommunityRehearsalHostRequest,
268
+ COMMUNITY_REHEARSAL_VERSION,
269
+ COMMUNITY_REHEARSAL_ROOT,
270
+ COMMUNITY_DATABASE_ROOT,
271
+ COMMUNITY_CREDENTIAL_ROOT
272
+ };
@@ -9,6 +9,60 @@
9
9
  */
10
10
  export declare const AGENT_CREDENTIAL_LOCATIONS: readonly ["operator", "metal", "platform-compute", "tenant-compute"];
11
11
  export type AgentCredentialLocation = (typeof AGENT_CREDENTIAL_LOCATIONS)[number];
12
+ /**
13
+ * Stable logical names shared by operator input, Vault fields and systemd.
14
+ *
15
+ * `CF_TUNNEL_TOKEN` is the broad attended/tenant-control credential that may
16
+ * create Tunnels and reconcile one exact DNS hostname. It is attended input
17
+ * before custody and Vault-only for the post-operational platform controller;
18
+ * it is never loaded by cloudflared. cloudflared receives only the connector
19
+ * returned for its own Tunnel.
20
+ *
21
+ * `CF_API_TOKEN` is the exact-account runtime/control credential used for
22
+ * Workers KV and the existing Worker's secret bindings. Durable Object
23
+ * application messages still go through that Worker's binding and use the
24
+ * separate REALTIME_PUBLISH_SECRET; a Cloudflare REST token is not a Durable
25
+ * Object invocation credential.
26
+ */
27
+ export declare const CLOUDFLARE_CREDENTIAL_NAMES: {
28
+ readonly api: "CF_API_TOKEN";
29
+ readonly tunnel: "CF_TUNNEL_TOKEN";
30
+ readonly connector: "CF_TUNNEL_CONNECTOR_TOKEN";
31
+ readonly realtimePublish: "REALTIME_PUBLISH_SECRET";
32
+ readonly realtimeTicket: "REALTIME_TICKET_SECRET";
33
+ };
34
+ export declare const CLOUDFLARE_CREDENTIAL_SCHEMA: {
35
+ readonly CF_TUNNEL_TOKEN: {
36
+ readonly permissions: readonly ["Account:Cloudflare Tunnel Write", "Account:Cloudflare One Connector: WARP Write", "Account:Cloudflare One Networks Write", "Account:Zero Trust Write", "Zone:DNS Write"];
37
+ readonly platformBootstrap: "attended-file";
38
+ readonly platformRuntime: "vault";
39
+ readonly tenantControl: "vault";
40
+ };
41
+ readonly CF_API_TOKEN: {
42
+ readonly permissions: readonly ["Account:Workers KV Storage Write", "Account:Workers Scripts Write"];
43
+ readonly platformBootstrap: "attended-file";
44
+ readonly platformRuntime: "vault-then-systemd";
45
+ readonly tenantControl: "vault";
46
+ };
47
+ readonly CF_TUNNEL_CONNECTOR_TOKEN: {
48
+ readonly permissions: readonly ["one named Tunnel connector"];
49
+ readonly platformBootstrap: "derived";
50
+ readonly platformRuntime: "systemd";
51
+ readonly tenantControl: "derived";
52
+ };
53
+ readonly REALTIME_PUBLISH_SECRET: {
54
+ readonly permissions: readonly ["Worker realtime publish endpoint"];
55
+ readonly platformBootstrap: "generated-and-installed-worker-secret";
56
+ readonly platformRuntime: "vault-then-systemd";
57
+ readonly tenantControl: "vault";
58
+ };
59
+ readonly REALTIME_TICKET_SECRET: {
60
+ readonly permissions: readonly ["Worker realtime subscription tickets"];
61
+ readonly platformBootstrap: "generated-and-installed-worker-secret";
62
+ readonly platformRuntime: "vault-then-systemd";
63
+ readonly tenantControl: "vault";
64
+ };
65
+ };
12
66
  export declare const AGENT_CREDENTIAL_POLICY: {
13
67
  readonly operator: {
14
68
  readonly vault: false;