@forgezero/agent 0.1.39 → 0.1.40

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.
@@ -130,23 +130,32 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
130
130
  const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
131
131
  const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
132
132
  const tunnelPath = `/accounts/${config.accountId}/cfd_tunnel/${config.tunnelId}/configurations`;
133
- const current = await cf(tunnelAuth, tunnelPath, {}, fetcher);
134
- const existing = current.config?.ingress ?? [];
135
- const catchAll = existing.filter((rule) => !("hostname" in rule));
136
- const otherHosts = existing.filter((rule) => ("hostname" in rule) && rule.hostname !== config.hostname);
137
- await cf(tunnelAuth, tunnelPath, {
138
- method: "PUT",
139
- body: JSON.stringify({ config: { ingress: [
140
- { hostname: config.hostname, service: config.service },
141
- ...otherHosts,
142
- ...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
143
- ] } })
144
- }, fetcher);
145
133
  const dnsPath = `/zones/${config.zoneId}/dns_records`;
146
- const records = await cf(dnsAuth, `${dnsPath}?type=CNAME&name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher);
134
+ const [current, records] = await Promise.all([
135
+ cf(tunnelAuth, tunnelPath, {}, fetcher),
136
+ cf(dnsAuth, `${dnsPath}?name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher)
137
+ ]);
147
138
  if (records.length > 1) {
148
139
  throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
149
140
  }
141
+ const existingRecord = records[0];
142
+ if (existingRecord && (existingRecord.type !== "CNAME" || existingRecord.name && existingRecord.name.toLowerCase() !== config.hostname.toLowerCase())) {
143
+ throw new Error(`Cloudflare DNS hostname ${config.hostname} is already owned by an incompatible ${existingRecord.type ?? "unknown"} record`);
144
+ }
145
+ const existing = current.config?.ingress ?? [];
146
+ const catchAll = existing.filter((rule) => !("hostname" in rule));
147
+ const otherHosts = existing.filter((rule) => ("hostname" in rule) && rule.hostname !== config.hostname);
148
+ const desiredIngress = [
149
+ { hostname: config.hostname, service: config.service },
150
+ ...otherHosts,
151
+ ...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
152
+ ];
153
+ if (JSON.stringify(existing) !== JSON.stringify(desiredIngress)) {
154
+ await cf(tunnelAuth, tunnelPath, {
155
+ method: "PUT",
156
+ body: JSON.stringify({ config: { ingress: desiredIngress } })
157
+ }, fetcher);
158
+ }
150
159
  const record = {
151
160
  type: "CNAME",
152
161
  name: config.hostname,
@@ -154,233 +163,13 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
154
163
  proxied: true,
155
164
  ttl: 1
156
165
  };
157
- await cf(dnsAuth, records[0] ? `${dnsPath}/${records[0].id}` : dnsPath, {
158
- method: records[0] ? "PUT" : "POST",
159
- body: JSON.stringify(record)
160
- }, fetcher);
161
- }
162
- var exactAccountPermissionGroup = async (config, name, fetcher) => {
163
- const groups = await cf(config, `/accounts/${config.accountId}/tokens/permission_groups?name=${encodeURIComponent(name)}` + "&scope=com.cloudflare.api.account", {}, fetcher);
164
- const matches = groups.filter((group) => group.name === name && group.scopes?.includes("com.cloudflare.api.account") && Boolean(group.id && /^[a-f0-9]{32}$/i.test(group.id)));
165
- if (matches.length !== 1) {
166
- throw new Error(`Cloudflare account token permission group ${name} is ${matches.length === 0 ? "missing" : "ambiguous"}`);
167
- }
168
- return { id: matches[0].id, name };
169
- };
170
- async function createCloudflareAccountRuntimeToken(config, fetcher = fetch) {
171
- if (!/^[a-f0-9]{32}$/i.test(config.accountId))
172
- throw new Error("Cloudflare account id is invalid");
173
- const name = config.name.trim();
174
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(name)) {
175
- throw new Error("Cloudflare account runtime-token name is invalid");
176
- }
177
- const permissionNames = [...new Set(config.permissionNames)];
178
- if (permissionNames.length === 0)
179
- throw new Error("Cloudflare account runtime token needs a permission group");
180
- const permissionGroups = await Promise.all(permissionNames.map((permissionName) => exactAccountPermissionGroup(config, permissionName, fetcher)));
181
- const body = {
182
- name,
183
- policies: [{
184
- effect: "allow",
185
- permission_groups: permissionGroups.map(({ id }) => ({ id })),
186
- resources: { [`com.cloudflare.api.account.${config.accountId}`]: "*" }
187
- }]
188
- };
189
- const created = await cf(config, `/accounts/${config.accountId}/tokens`, { method: "POST", body: JSON.stringify(body) }, fetcher);
190
- if (!created.id || !/^[a-f0-9]{32}$/i.test(created.id) || !created.value || !/^[A-Za-z0-9._-]{40,80}$/.test(created.value)) {
191
- throw new Error("Cloudflare did not return the one-time account runtime-token id and value");
192
- }
193
- return { id: created.id, value: created.value, name, permissionNames };
194
- }
195
- async function ensureCloudflareAccessServiceToken(config, fetcher = fetch) {
196
- const name = config.name.trim();
197
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
198
- throw new Error("Cloudflare Access service-token name is invalid");
199
- }
200
- const path = `/accounts/${config.accountId}/access/service_tokens`;
201
- const tokens = await cf(config, `${path}?per_page=1000`, {}, fetcher);
202
- const matches = tokens.filter((token) => token.name === name);
203
- if (matches.length > 1)
204
- throw new Error(`Cloudflare Access service token ${name} is ambiguous`);
205
- if (matches[0]) {
206
- if (!config.existing || config.existing.tokenId !== matches[0].id || config.existing.clientId !== matches[0].client_id || !config.existing.clientSecret) {
207
- throw new Error(`Cloudflare Access service token ${name} exists but its one-time client secret was not supplied`);
208
- }
209
- return { credentials: config.existing, created: false };
210
- }
211
- const created = await cf(config, path, {
212
- method: "POST",
213
- body: JSON.stringify({ name, duration: config.duration ?? "8760h" })
214
- }, fetcher);
215
- if (!created.id || !created.client_id || !created.client_secret) {
216
- throw new Error("Cloudflare did not return the new Access service-token secret");
217
- }
218
- return {
219
- credentials: {
220
- tokenId: created.id,
221
- clientId: created.client_id,
222
- clientSecret: created.client_secret
223
- },
224
- created: true
225
- };
226
- }
227
- async function ensureCloudflareAccessPolicy(config, fetcher = fetch) {
228
- const path = `/accounts/${config.accountId}/access/policies`;
229
- const policies = await cf(config, `${path}?per_page=1000`, {}, fetcher);
230
- const matches = policies.filter((policy2) => policy2.name === config.name);
231
- if (matches.length > 1)
232
- throw new Error(`Cloudflare Access policy ${config.name} is ambiguous`);
233
- const desired = {
234
- name: config.name,
235
- decision: "non_identity",
236
- include: [{ service_token: { token_id: config.serviceTokenId } }]
237
- };
238
- const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, {
239
- method: matches[0] ? "PUT" : "POST",
240
- body: JSON.stringify(desired)
241
- }, fetcher);
242
- return { policy, created: !matches[0] };
243
- }
244
- async function ensureCloudflareAccessApplication(config, fetcher = fetch) {
245
- const path = `/accounts/${config.accountId}/access/apps`;
246
- const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
247
- const matches = applications.filter((application2) => application2.domain === config.hostname || application2.self_hosted_domains?.includes(config.hostname));
248
- if (matches.length > 1)
249
- throw new Error(`Cloudflare Access application for ${config.hostname} is ambiguous`);
250
- const desired = {
251
- name: config.name,
252
- type: "self_hosted",
253
- domain: config.hostname,
254
- session_duration: "24h",
255
- service_auth_401_redirect: true,
256
- policies: [{ id: config.policyId, precedence: 1 }]
257
- };
258
- const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
259
- return { application, created: !matches[0] };
260
- }
261
- async function ensureCloudflareWarpEnrollmentApplication(config, fetcher = fetch) {
262
- const name = config.name.trim();
263
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
264
- throw new Error("Cloudflare WARP enrollment application name is invalid");
265
- }
266
- const path = `/accounts/${config.accountId}/access/apps`;
267
- const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
268
- const matches = applications.filter((application2) => application2.type === "warp" || application2.name === name);
269
- if (matches.length > 1)
270
- throw new Error(`Cloudflare WARP enrollment application ${name} is ambiguous`);
271
- if (matches[0] && (matches[0].type !== "warp" || matches[0].name !== name)) {
272
- throw new Error(`Cloudflare Access application ${name} is not the owned WARP enrollment application`);
273
- }
274
- const desired = {
275
- name,
276
- type: "warp",
277
- policies: [{ id: config.policyId, precedence: 1 }]
278
- };
279
- const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
280
- if (!application.id || application.type && application.type !== "warp") {
281
- throw new Error("Cloudflare did not return the WARP enrollment application");
282
- }
283
- return { application, created: !matches[0] };
284
- }
285
- async function ensureCloudflareVirtualNetwork(config, fetcher = fetch) {
286
- const name = config.name.trim();
287
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
288
- throw new Error("Cloudflare VNET name is invalid");
289
- const path = `/accounts/${config.accountId}/teamnet/virtual_networks`;
290
- const networks = await cf(config, `${path}?per_page=1000`, {}, fetcher);
291
- const matches = networks.filter((network) => !network.deleted_at && network.name === name);
292
- if (matches.length > 1)
293
- throw new Error(`Cloudflare VNET ${name} is ambiguous`);
294
- if (matches[0])
295
- return { virtualNetwork: matches[0], created: false };
296
- const virtualNetwork = await cf(config, path, {
297
- method: "POST",
298
- body: JSON.stringify({ name, comment: config.comment.slice(0, 256), is_default_network: false })
299
- }, fetcher);
300
- if (!virtualNetwork.id || !/^[0-9a-f-]{36}$/i.test(virtualNetwork.id)) {
301
- throw new Error("Cloudflare did not return the VNET id");
302
- }
303
- return { virtualNetwork, created: true };
304
- }
305
- async function ensureCloudflareWarpDevicePolicy(config, fetcher = fetch) {
306
- const name = config.name.trim();
307
- if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
308
- throw new Error("Cloudflare WARP device profile name is invalid");
309
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(config.serviceTokenId))
310
- throw new Error("Cloudflare service-token id is invalid");
311
- if (!/^[0-9a-f-]{36}$/i.test(config.virtualNetworkId))
312
- throw new Error("Cloudflare VNET id is invalid");
313
- const precedence = config.precedence ?? 100;
314
- if (!Number.isInteger(precedence) || precedence < 1 || precedence > 999999) {
315
- throw new Error("Cloudflare WARP device profile precedence is invalid");
316
- }
317
- const match = `identity.service_token_uuid == "${config.serviceTokenId}"`;
318
- const listPath = `/accounts/${config.accountId}/devices/policies`;
319
- const path = `/accounts/${config.accountId}/devices/policy`;
320
- const policies = await cf(config, `${listPath}?per_page=1000`, {}, fetcher);
321
- const matches = policies.filter((policy2) => policy2.name === name);
322
- if (matches.length > 1)
323
- throw new Error(`Cloudflare WARP device profile ${name} is ambiguous`);
324
- if (matches[0]?.match && matches[0].match !== match) {
325
- throw new Error(`Cloudflare WARP device profile ${name} belongs to another enrollment identity`);
326
- }
327
- const desired = {
328
- name,
329
- match,
330
- precedence,
331
- description: "ForgeZero non-interactive compute enrollment",
332
- enabled: true,
333
- allow_mode_switch: false,
334
- allowed_to_leave: false,
335
- auto_connect: 0,
336
- switch_locked: true,
337
- service_mode_v2: { mode: "warp" },
338
- virtual_networks: { allowed: [config.virtualNetworkId], default: config.virtualNetworkId }
339
- };
340
- const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PATCH" : "POST", body: JSON.stringify(desired) }, fetcher);
341
- if (!policy.id)
342
- throw new Error("Cloudflare did not return the WARP device profile id");
343
- return { policy, created: !matches[0] };
344
- }
345
- async function configureCloudflareWorkerAccessSecrets(config, fetcher = fetch) {
346
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(config.scriptName)) {
347
- throw new Error("Cloudflare Worker script name is invalid");
348
- }
349
- await cf(config, `/accounts/${config.accountId}/workers/scripts/${config.scriptName}/secrets-bulk`, {
350
- method: "PATCH",
351
- body: JSON.stringify({
352
- secrets: {
353
- CF_ACCESS_CLIENT_ID: {
354
- name: "CF_ACCESS_CLIENT_ID",
355
- type: "secret_text",
356
- text: config.credentials.clientId
357
- },
358
- CF_ACCESS_CLIENT_SECRET: {
359
- name: "CF_ACCESS_CLIENT_SECRET",
360
- type: "secret_text",
361
- text: config.credentials.clientSecret
362
- }
363
- }
364
- })
365
- }, fetcher);
366
- }
367
- async function ensureCloudflareKvNamespace(config, fetcher = fetch) {
368
- const title = config.title.trim();
369
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(title)) {
370
- throw new Error("Cloudflare KV namespace title is invalid");
166
+ 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
+ if (!dnsAlreadyCorrect) {
168
+ await cf(dnsAuth, existingRecord ? `${dnsPath}/${encodeURIComponent(existingRecord.id)}` : dnsPath, {
169
+ method: existingRecord ? "PUT" : "POST",
170
+ body: JSON.stringify(record)
171
+ }, fetcher);
371
172
  }
372
- const path = `/accounts/${config.accountId}/storage/kv/namespaces`;
373
- const namespaces = await cf(config, `${path}?per_page=1000`, {}, fetcher);
374
- const matches = namespaces.filter((namespace2) => namespace2.title === title);
375
- if (matches.length > 1)
376
- throw new Error(`Cloudflare KV namespace ${title} is ambiguous`);
377
- if (matches[0])
378
- return { namespace: matches[0], created: false };
379
- const namespace = await cf(config, path, {
380
- method: "POST",
381
- body: JSON.stringify({ title })
382
- }, fetcher);
383
- return { namespace, created: true };
384
173
  }
385
174
  async function ensureCloudflareTunnel(config, fetcher = fetch) {
386
175
  const name = config.name.trim();
@@ -406,27 +195,13 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
406
195
 
407
196
  // src/cloudflare-bootstrap.ts
408
197
  import { constants } from "fs";
409
- import { chmod, lstat, mkdir, mkdtemp, open, rename, rm, stat, unlink } from "fs/promises";
410
- import { dirname, join, resolve } from "path";
411
- import { tmpdir } from "os";
412
198
  import { randomUUID } from "crypto";
413
- import { isIP as isIP2 } from "net";
414
- var acceptanceFetch = async (url, label, fetcher, headers) => {
415
- let response;
416
- try {
417
- response = await fetcher(url, {
418
- method: "GET",
419
- headers,
420
- redirect: "manual",
421
- signal: AbortSignal.timeout(5000)
422
- });
423
- } catch {
424
- throw new Error(`${label} is unreachable`);
425
- }
426
- if (!response.ok)
427
- throw new Error(`${label} returned HTTP ${response.status}`);
428
- return response.status;
429
- };
199
+ import { chmod, lstat, mkdir, open, rename, stat, unlink } from "fs/promises";
200
+ import { dirname, join, resolve } from "path";
201
+ var TOKEN = /^[A-Za-z0-9._-]{40,80}$/;
202
+ var CONNECTOR_TOKEN = /^[A-Za-z0-9._-]{40,16384}$/;
203
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
204
+ var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
430
205
  var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
431
206
  async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
432
207
  const metadata = await handle.stat();
@@ -435,8 +210,9 @@ async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
435
210
  if (metadata.nlink !== 1)
436
211
  throw new Error(`${path} must not have multiple hard links`);
437
212
  const uid = ownerUid();
438
- if (uid !== undefined && uid !== 0 && metadata.uid !== uid)
213
+ if (uid !== undefined && uid !== 0 && metadata.uid !== uid) {
439
214
  throw new Error(`${path} must be owned by the current operator`);
215
+ }
440
216
  if ((metadata.mode & 63) !== 0)
441
217
  throw new Error(`${path} must not be accessible by group or other users`);
442
218
  if ((metadata.mode & 256) === 0)
@@ -461,25 +237,36 @@ async function readOwnerOnlyFile(path, maximumBytes) {
461
237
  }
462
238
  async function readOwnerApiToken(path) {
463
239
  const token = (await readOwnerOnlyFile(path, 4096)).trim();
464
- if (!/^[A-Za-z0-9._-]{40,80}$/.test(token)) {
240
+ if (!TOKEN.test(token))
465
241
  throw new Error(`${resolve(path)} must contain exactly one Cloudflare API token`);
466
- }
467
242
  return token;
468
243
  }
469
244
  async function readCloudflareBootstrapTokens(files) {
470
245
  const entries = await Promise.all([
471
246
  ["apiToken", files.apiTokenFile],
247
+ ["managementApiToken", files.managementApiTokenFile],
248
+ ["runtimeApiToken", files.runtimeApiTokenFile],
472
249
  ["tunnelApiToken", files.tunnelApiTokenFile],
473
250
  ["dnsApiToken", files.dnsApiTokenFile],
474
- ["kvApiToken", files.kvApiTokenFile],
475
- ["accessApiToken", files.accessApiTokenFile],
476
- ["workerApiToken", files.workerApiTokenFile]
251
+ ["kvApiToken", files.kvApiTokenFile]
477
252
  ].map(async ([key, path]) => [key, path ? await readOwnerApiToken(path) : undefined]));
478
- const tokens = Object.fromEntries(entries.filter(([, value]) => value !== undefined));
479
- const unified = tokens.apiToken;
480
- for (const key of ["tunnelApiToken", "dnsApiToken", "kvApiToken", "accessApiToken", "workerApiToken"]) {
481
- if (!tokens[key] && !unified)
482
- throw new Error(`Cloudflare ${key} file is required when --token-file is omitted`);
253
+ const supplied = Object.fromEntries(entries.filter(([, value]) => value !== undefined));
254
+ const tokens = {
255
+ ...supplied.apiToken ? { apiToken: supplied.apiToken } : {},
256
+ ...supplied.tunnelApiToken || supplied.managementApiToken ? {
257
+ tunnelApiToken: supplied.tunnelApiToken ?? supplied.managementApiToken
258
+ } : {},
259
+ ...supplied.dnsApiToken || supplied.managementApiToken ? {
260
+ dnsApiToken: supplied.dnsApiToken ?? supplied.managementApiToken
261
+ } : {},
262
+ ...supplied.kvApiToken || supplied.runtimeApiToken ? {
263
+ kvApiToken: supplied.kvApiToken ?? supplied.runtimeApiToken
264
+ } : {}
265
+ };
266
+ for (const key of ["tunnelApiToken", "dnsApiToken", "kvApiToken"]) {
267
+ if (!tokens[key] && !tokens.apiToken) {
268
+ throw new Error(`Cloudflare ${key} file is required when apiTokenFile is omitted`);
269
+ }
483
270
  }
484
271
  return tokens;
485
272
  }
@@ -489,36 +276,30 @@ var validateId = (value, label) => {
489
276
  throw new Error(`${label} must be a 32-character hexadecimal id`);
490
277
  return normalized;
491
278
  };
492
- var validateName = (value, label, maximum, allowSpaces = true) => {
279
+ var validateName = (value, label) => {
493
280
  const normalized = value.trim();
494
- const pattern = allowSpaces ? /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/ : /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
495
- if (!normalized || normalized.length > maximum || !pattern.test(normalized)) {
281
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(normalized))
496
282
  throw new Error(`${label} is invalid`);
497
- }
498
283
  return normalized;
499
284
  };
500
- var privateAddress = (value) => {
501
- const address = value.trim().toLowerCase();
502
- const family = isIP2(address);
503
- if (family === 4) {
504
- const [a, b] = address.split(".").map(Number);
505
- if (a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31)
506
- return address;
285
+ var normalizeService = (value) => {
286
+ let service;
287
+ try {
288
+ service = new URL(value);
289
+ } catch {
290
+ throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
507
291
  }
508
- if (family === 6) {
509
- const first = Number.parseInt(address.split(":", 1)[0], 16);
510
- if (Number.isFinite(first) && (first & 65024) === 64512)
511
- return address;
292
+ if (service.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(service.hostname) || !service.port || service.pathname !== "/" || service.username || service.password || service.search || service.hash) {
293
+ throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
512
294
  }
513
- throw new Error("Cloudflare private database address must be RFC 1918 IPv4 or unique-local IPv6");
295
+ return service.toString().replace(/\/$/, "");
514
296
  };
515
297
  function validateCloudflareBootstrapCoordinates(input) {
516
298
  const nodeInputs = input.nodes?.length ? input.nodes : [{
517
299
  nodeName: input.tunnelName,
518
300
  hostname: input.hostname,
519
301
  service: input.service,
520
- tunnelName: input.tunnelName,
521
- applicationName: input.applicationName
302
+ tunnelName: input.tunnelName
522
303
  }];
523
304
  if (nodeInputs.length < 1 || nodeInputs.length > 32) {
524
305
  throw new Error("Cloudflare bootstrap requires between 1 and 32 explicit nodes");
@@ -528,88 +309,32 @@ function validateCloudflareBootstrapCoordinates(input) {
528
309
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName))
529
310
  throw new Error("Cloudflare node name is invalid");
530
311
  const hostname = node.hostname.trim().toLowerCase();
531
- if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)) {
312
+ if (!HOSTNAME.test(hostname))
532
313
  throw new Error("Cloudflare public node hostname is invalid");
533
- }
534
- const serviceUrl = new URL(node.service);
535
- if (serviceUrl.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(serviceUrl.hostname) || !serviceUrl.port || serviceUrl.pathname !== "/" || serviceUrl.search || serviceUrl.hash) {
536
- throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
537
- }
538
314
  return {
539
315
  nodeName,
540
316
  hostname,
541
- service: serviceUrl.toString().replace(/\/$/, ""),
542
- tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name", 100, false),
543
- applicationName: validateName(node.applicationName, "Cloudflare Access application name", 100),
544
- ...node.privateAddress ? { privateAddress: privateAddress(node.privateAddress) } : {}
317
+ service: normalizeService(node.service),
318
+ tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name")
545
319
  };
546
320
  });
547
321
  for (const [label, values] of [
548
322
  ["node name", nodes.map(({ nodeName }) => nodeName)],
549
323
  ["hostname", nodes.map(({ hostname }) => hostname)],
550
- ["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)],
551
- ["Access application name", nodes.map(({ applicationName }) => applicationName)]
324
+ ["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)]
552
325
  ]) {
553
326
  if (new Set(values).size !== values.length)
554
327
  throw new Error(`Cloudflare fleet ${label} must be unique`);
555
328
  }
556
329
  const first = nodes[0];
557
- const workerScriptName = input.workerScriptName.trim();
558
- if (!/^[a-z][a-z0-9-]{0,62}$/.test(workerScriptName))
559
- throw new Error("Cloudflare Worker script name is invalid");
560
- const workerCompatibilityDate = input.workerCompatibilityDate.trim();
561
- if (!/^20\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/.test(workerCompatibilityDate)) {
562
- throw new Error("Cloudflare Worker compatibility date is invalid");
563
- }
564
- const publicDomains = [...new Set(input.publicDomains.map((domain) => domain.trim().toLowerCase()))];
565
- if (publicDomains.length < 1 || publicDomains.length > 10 || publicDomains.some((domain) => !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain) || nodes.some(({ hostname }) => hostname === domain))) {
566
- throw new Error("Cloudflare Worker public domains are invalid or include the private origin hostname");
567
- }
568
- const workerDirectory = resolve(input.workerDirectory);
569
- const workerMain = input.workerMain.trim();
570
- if (!workerMain || workerMain.startsWith("/") || workerMain.split(/[\\/]/).includes("..")) {
571
- throw new Error("Cloudflare Worker main must be a project-relative path");
572
- }
573
- const runtimeTokenNamePrefix = input.runtimeTokenNamePrefix.trim();
574
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(runtimeTokenNamePrefix)) {
575
- throw new Error("Cloudflare runtime-token name prefix is invalid");
576
- }
577
- if (input.createPrivateNetworkRuntimeToken && !input.createRuntimeTokens) {
578
- throw new Error("private-network runtime token requires runtime-token creation");
579
- }
580
- const privateNetwork = input.privateNetwork ? {
581
- warpOrganization: validateName(input.privateNetwork.warpOrganization, "Cloudflare WARP organization", 63, false).toLowerCase(),
582
- virtualNetworkName: validateName(input.privateNetwork.virtualNetworkName, "Cloudflare VNET name", 100),
583
- deviceProfileName: validateName(input.privateNetwork.deviceProfileName, "Cloudflare WARP device profile name", 100),
584
- enrollmentApplicationName: validateName(input.privateNetwork.enrollmentApplicationName, "Cloudflare WARP enrollment application name", 100),
585
- ...input.privateNetwork.deviceProfilePrecedence !== undefined ? { deviceProfilePrecedence: input.privateNetwork.deviceProfilePrecedence } : {}
586
- } : undefined;
587
- if (privateNetwork && (!input.createPrivateNetworkRuntimeToken || nodes.every((node) => !node.privateAddress))) {
588
- throw new Error("Cloudflare private network requires its runtime token and at least one DB node private address");
589
- }
590
- if (!privateNetwork && nodes.some((node) => node.privateAddress)) {
591
- throw new Error("Cloudflare node private addresses require privateNetwork coordinates");
592
- }
593
330
  return {
594
331
  accountId: validateId(input.accountId, "Cloudflare account id"),
595
332
  zoneId: validateId(input.zoneId, "Cloudflare zone id"),
596
333
  hostname: first.hostname,
597
334
  service: first.service,
598
335
  tunnelName: first.tunnelName,
599
- kvNamespaceTitle: validateName(input.kvNamespaceTitle, "Cloudflare KV namespace title", 128, false),
600
- workerScriptName,
601
- serviceTokenName: validateName(input.serviceTokenName, "Cloudflare Access service-token name", 100),
602
- policyName: validateName(input.policyName, "Cloudflare Access policy name", 100),
603
- applicationName: first.applicationName,
604
- workerDirectory,
605
- workerMain,
606
- workerCompatibilityDate,
607
- publicDomains,
608
- createRuntimeTokens: input.createRuntimeTokens,
609
- createPrivateNetworkRuntimeToken: input.createPrivateNetworkRuntimeToken,
610
- runtimeTokenNamePrefix,
611
- nodes,
612
- ...privateNetwork ? { privateNetwork } : {}
336
+ kvNamespaceId: validateId(input.kvNamespaceId, "Cloudflare KV namespace id"),
337
+ nodes
613
338
  };
614
339
  }
615
340
  function planCloudflareBootstrap(input, outputPath) {
@@ -621,120 +346,18 @@ function planCloudflareBootstrap(input, outputPath) {
621
346
  outputFile: resolve(outputPath),
622
347
  coordinates,
623
348
  operations: [
624
- "create or reuse one Workers KV namespace",
625
349
  "create or reuse one remotely-managed Tunnel per node and checkpoint every connector token",
626
- ...coordinates.createRuntimeTokens ? [
627
- "create exact-account least-privilege runtime tokens and checkpoint their one-time values"
628
- ] : [],
629
- "deploy the shared Worker once with the created NODES binding and stable custom domains",
630
- "create or reuse one shared Access service token/policy and one self-hosted application per node",
631
- ...coordinates.privateNetwork ? [
632
- "create or reuse the VNET, WARP enrollment application, locked service-token device profile and exact DB host routes"
633
- ] : [],
634
- "write the Access client id and secret to the existing Worker as encrypted secrets",
635
- "reconcile each node ingress rule and proxied CNAME only after all Access applications are ready"
350
+ "preflight each exact DNS hostname, refuse ambiguous or incompatible records, and update its existing CNAME or create it only when absent",
351
+ "reconcile each Tunnel public-hostname ingress rule to the declared loopback API service",
352
+ "write one node-specific handoff containing the connector token and owner-supplied KV-write token"
636
353
  ],
637
354
  secrets: [
638
- "API tokens are read only from owner-only files and are never written to output",
639
- "the output contains connector, Access and requested runtime credentials and is atomically written with mode 0600",
640
- "the normal API process does not receive or import the management token files"
355
+ "API tokens are read only from owner-only files and are never placed in argv or stdout",
356
+ "the management token is never persisted; connector and KV runtime capabilities are atomically checkpointed with mode 0600",
357
+ "the normal API process receives only its KV-write token and never Tunnel or DNS management authority"
641
358
  ]
642
359
  };
643
360
  }
644
- var defaultWorkerCommandRunner = async ({ command, cwd, env }) => {
645
- const child = Bun.spawn([...command], {
646
- cwd,
647
- env: { ...env },
648
- stdin: "ignore",
649
- stdout: "pipe",
650
- stderr: "pipe"
651
- });
652
- const [exitCode, stdout, stderr] = await Promise.all([
653
- child.exited,
654
- new Response(child.stdout).text(),
655
- new Response(child.stderr).text()
656
- ]);
657
- return { exitCode, stdout, stderr };
658
- };
659
- var inheritedWorkerEnvironment = () => {
660
- const allowed = [
661
- "PATH",
662
- "HOME",
663
- "TMPDIR",
664
- "XDG_CONFIG_HOME",
665
- "XDG_CACHE_HOME",
666
- "SSL_CERT_FILE",
667
- "SSL_CERT_DIR",
668
- "NODE_EXTRA_CA_CERTS",
669
- "HTTPS_PROXY",
670
- "HTTP_PROXY",
671
- "NO_PROXY"
672
- ];
673
- return Object.fromEntries(allowed.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
674
- };
675
- var redact = (text, secrets) => {
676
- let safe = text.slice(0, 4096);
677
- for (const secret of secrets)
678
- if (secret)
679
- safe = safe.split(secret).join("[REDACTED]");
680
- return safe.trim();
681
- };
682
- async function deployCloudflareWorker(coordinates, kvNamespaceId, apiToken, runner = defaultWorkerCommandRunner) {
683
- const validated = validateCloudflareBootstrapCoordinates(coordinates);
684
- const workerDirectoryMetadata = await stat(validated.workerDirectory);
685
- if (!workerDirectoryMetadata.isDirectory())
686
- throw new Error("Cloudflare Worker directory is not a directory");
687
- const workerMain = resolve(validated.workerDirectory, validated.workerMain);
688
- const workerMainMetadata = await stat(workerMain);
689
- if (!workerMainMetadata.isFile())
690
- throw new Error("Cloudflare Worker main is not a regular file");
691
- const wrangler = resolve(validated.workerDirectory, "node_modules/.bin/wrangler");
692
- const wranglerMetadata = await stat(wrangler);
693
- if (!wranglerMetadata.isFile())
694
- throw new Error("Cloudflare Wrangler is not installed in the Worker project");
695
- if (!/^[a-f0-9]{32}$/.test(kvNamespaceId))
696
- throw new Error("Cloudflare KV namespace id is invalid");
697
- const temporaryDirectory = await mkdtemp(join(tmpdir(), "fz-wrangler-"));
698
- await chmod(temporaryDirectory, 448);
699
- const configurationPath = join(temporaryDirectory, "wrangler.json");
700
- try {
701
- const handle = await open(configurationPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 384);
702
- try {
703
- await handle.writeFile(`${JSON.stringify({
704
- name: validated.workerScriptName,
705
- main: workerMain,
706
- compatibility_date: validated.workerCompatibilityDate,
707
- workers_dev: false,
708
- routes: validated.publicDomains.map((pattern) => ({ pattern, custom_domain: true })),
709
- observability: { enabled: true },
710
- kv_namespaces: [{ binding: "NODES", id: kvNamespaceId }]
711
- }, null, 2)}
712
- `);
713
- await handle.sync();
714
- } finally {
715
- await handle.close();
716
- }
717
- const result = await runner({
718
- command: [wrangler, "deploy", "--config", configurationPath],
719
- cwd: validated.workerDirectory,
720
- env: {
721
- ...inheritedWorkerEnvironment(),
722
- XDG_CONFIG_HOME: temporaryDirectory,
723
- XDG_CACHE_HOME: temporaryDirectory,
724
- WRANGLER_LOG_PATH: join(temporaryDirectory, "wrangler.log"),
725
- CLOUDFLARE_ACCOUNT_ID: validated.accountId,
726
- CLOUDFLARE_API_TOKEN: apiToken,
727
- WRANGLER_SEND_METRICS: "false"
728
- }
729
- });
730
- if (result.exitCode !== 0) {
731
- const detail = redact(result.stderr || result.stdout || "no Wrangler diagnostic", [apiToken]);
732
- throw new Error(`Cloudflare Worker deployment failed with exit ${result.exitCode}: ${detail}`);
733
- }
734
- } finally {
735
- await rm(temporaryDirectory, { recursive: true, force: true });
736
- }
737
- }
738
361
  async function readExistingOutput(path) {
739
362
  try {
740
363
  await lstat(path);
@@ -743,13 +366,15 @@ async function readExistingOutput(path) {
743
366
  return;
744
367
  throw cause;
745
368
  }
746
- const text = await readOwnerOnlyFile(path, 1048576);
747
- let output;
369
+ let parsed;
748
370
  try {
749
- output = JSON.parse(text);
750
- } catch {
751
- throw new Error(`${resolve(path)} is not valid bootstrap JSON`);
371
+ parsed = JSON.parse(await readOwnerOnlyFile(path, 1048576));
372
+ } catch (cause) {
373
+ if (cause instanceof SyntaxError)
374
+ throw new Error(`${resolve(path)} is not valid bootstrap JSON`);
375
+ throw cause;
752
376
  }
377
+ const output = parsed;
753
378
  if (output.format !== 1 || output.kind !== "forgezero-cloudflare-bootstrap" || !output.resources) {
754
379
  throw new Error(`${resolve(path)} is not a ForgeZero Cloudflare bootstrap output`);
755
380
  }
@@ -777,7 +402,7 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
777
402
  throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
778
403
  }
779
404
  const resource = matches[0];
780
- if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !resource.applicationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(resource.tunnelId) || !/^[A-Za-z0-9._-]{40,16384}$/.test(resource.connectorToken)) {
405
+ if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !UUID.test(resource.tunnelId) || !CONNECTOR_TOKEN.test(resource.connectorToken)) {
781
406
  throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
782
407
  }
783
408
  return {
@@ -817,44 +442,29 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
817
442
  ].includes(key));
818
443
  if (unknown.length)
819
444
  throw new Error(`Cloudflare host handoff contains unsupported field ${unknown[0]}`);
820
- if (output.warp) {
821
- const unknownWarp = Object.keys(output.warp).filter((key) => ![
822
- "organization",
823
- "clientId",
824
- "clientSecret",
825
- "virtualNetworkId",
826
- "deviceProfileId"
827
- ].includes(key));
828
- if (unknownWarp.length)
829
- throw new Error(`Cloudflare host handoff WARP contains unsupported field ${unknownWarp[0]}`);
830
- }
831
445
  const normalizedNodeName = nodeName.trim().toLowerCase();
832
446
  let service;
833
447
  try {
834
- service = new URL(output.service ?? "");
448
+ service = normalizeService(output.service ?? "");
835
449
  } catch {}
836
- if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !/^[a-f0-9]{32}$/i.test(output.accountId ?? "") || !/^[a-f0-9]{32}$/i.test(output.zoneId ?? "") || !/^[a-f0-9]{32}$/i.test(output.kvNamespaceId ?? "") || !/^[A-Za-z0-9._-]{40,80}$/.test(output.kvRuntimeToken ?? "") || !output.hostname || !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(output.hostname) || !service || service.protocol !== "http:" || service.hostname !== "127.0.0.1" || !service.port || service.pathname !== "/" || service.username || service.password || service.search || service.hash || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(output.tunnelId ?? "") || !/^[A-Za-z0-9._-]{40,16384}$/.test(output.connectorToken ?? "")) {
450
+ if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") || !CONNECTOR_TOKEN.test(output.connectorToken ?? "") || !TOKEN.test(output.kvRuntimeToken ?? "") || !/^[a-f0-9]{32}$/i.test(output.accountId ?? "") || !/^[a-f0-9]{32}$/i.test(output.zoneId ?? "") || !/^[a-f0-9]{32}$/i.test(output.kvNamespaceId ?? "")) {
837
451
  throw new Error("Cloudflare host handoff is malformed or belongs to another node");
838
452
  }
839
- const network = output.privateNetworkRuntimeToken;
840
- if (network !== undefined && !/^[A-Za-z0-9._-]{40,80}$/.test(network)) {
453
+ if (output.privateNetworkRuntimeToken !== undefined && !TOKEN.test(output.privateNetworkRuntimeToken)) {
841
454
  throw new Error("Cloudflare host handoff private-network capability is malformed");
842
455
  }
843
- if (Boolean(output.warp) !== Boolean(network)) {
456
+ if (Boolean(output.warp) !== Boolean(output.privateNetworkRuntimeToken)) {
844
457
  throw new Error("Cloudflare host handoff private-network resources and capability disagree");
845
458
  }
846
- if (output.warp && (!output.warp.clientId || !output.warp.clientSecret || !/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(output.warp.organization) || !/^[0-9a-f-]{36}$/i.test(output.warp.virtualNetworkId) || !output.warp.deviceProfileId)) {
847
- throw new Error("Cloudflare host handoff WARP enrollment is malformed");
848
- }
849
459
  const { format: _format, kind: _kind, ...handoff } = output;
850
460
  return handoff;
851
461
  }
852
462
  async function prepareOwnerOutputDirectory(absolutePath) {
853
463
  const directory = dirname(absolutePath);
854
464
  await mkdir(directory, { recursive: true, mode: 448 });
855
- const directoryMetadata = await stat(directory);
465
+ const metadata = await stat(directory);
856
466
  const uid = ownerUid();
857
- if (!directoryMetadata.isDirectory() || uid !== undefined && directoryMetadata.uid !== uid || (directoryMetadata.mode & 18) !== 0) {
467
+ if (!metadata.isDirectory() || uid !== undefined && metadata.uid !== uid || (metadata.mode & 18) !== 0) {
858
468
  throw new Error(`bootstrap output directory ${directory} must be operator-owned and not group/other writable`);
859
469
  }
860
470
  return directory;
@@ -891,13 +501,6 @@ async function writeOwnerBootstrapOutput(path, output) {
891
501
  await writeOwnerJson(path, output);
892
502
  }
893
503
  async function writeCloudflareHostHandoffs(checkpointPath, output) {
894
- const kv = output.resources.runtimeTokens?.kv?.value;
895
- if (!kv || !/^[A-Za-z0-9._-]{40,80}$/.test(kv)) {
896
- return;
897
- }
898
- const network = output.resources.runtimeTokens?.privateNetwork?.value;
899
- const privateNetwork = output.resources.privateNetwork;
900
- const access = output.resources.access;
901
504
  for (const node of output.resources.nodes) {
902
505
  const handoff = {
903
506
  format: 1,
@@ -910,34 +513,19 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
910
513
  accountId: output.coordinates.accountId,
911
514
  zoneId: output.coordinates.zoneId,
912
515
  kvNamespaceId: output.resources.kvNamespaceId,
913
- kvRuntimeToken: kv,
914
- ...network ? { privateNetworkRuntimeToken: network } : {},
915
- ...privateNetwork && access ? { warp: {
916
- organization: privateNetwork.warpOrganization,
917
- clientId: access.clientId,
918
- clientSecret: access.clientSecret,
919
- virtualNetworkId: privateNetwork.virtualNetworkId,
920
- deviceProfileId: privateNetwork.deviceProfileId
921
- } } : {}
516
+ kvRuntimeToken: output.resources.kvRuntimeToken
922
517
  };
923
518
  await writeOwnerJson(cloudflareHostHandoffPath(checkpointPath, node.nodeName), handoff);
924
519
  }
925
520
  }
926
521
  var tokenFor = (tokens, key) => {
927
522
  const token = tokens[key]?.trim() || tokens.apiToken?.trim();
928
- if (!token)
523
+ if (!token || !TOKEN.test(token))
929
524
  throw new Error(`Cloudflare ${key} is not configured`);
930
525
  return token;
931
526
  };
932
- var initialManagementToken = (tokens) => {
933
- const token = tokens.apiToken?.trim();
934
- if (!token) {
935
- throw new Error("initial Cloudflare --token-file is required to create account-owned runtime tokens");
936
- }
937
- return token;
938
- };
939
527
  var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
940
- async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch, workerRunner = defaultWorkerCommandRunner) {
528
+ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
941
529
  const coordinates = validateCloudflareBootstrapCoordinates(input);
942
530
  const absoluteOutput = resolve(outputPath);
943
531
  const existing = await readExistingOutput(absoluteOutput);
@@ -945,19 +533,18 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
945
533
  throw new Error("bootstrap output belongs to different Cloudflare coordinates; choose a different output file");
946
534
  }
947
535
  await prepareOwnerOutputDirectory(absoluteOutput);
948
- const namespace = await ensureCloudflareKvNamespace({
949
- accountId: coordinates.accountId,
950
- title: coordinates.kvNamespaceTitle,
951
- apiToken: tokenFor(tokens, "kvApiToken")
952
- }, fetcher);
536
+ const kvRuntimeToken = tokenFor(tokens, "kvApiToken");
953
537
  const nodeResources = [];
954
538
  const createdNodes = [];
955
- let resources;
956
539
  for (const node of coordinates.nodes) {
957
540
  const checkpointed = existing?.resources.nodes?.find(({ nodeName }) => nodeName === node.nodeName);
541
+ let resource;
958
542
  let created = false;
959
543
  if (checkpointed) {
960
- nodeResources.push(checkpointed);
544
+ if (checkpointed.hostname !== node.hostname || checkpointed.service !== node.service || checkpointed.tunnelName !== node.tunnelName || !UUID.test(checkpointed.tunnelId) || !CONNECTOR_TOKEN.test(checkpointed.connectorToken)) {
545
+ throw new Error(`checkpointed Cloudflare node ${node.nodeName} is malformed`);
546
+ }
547
+ resource = checkpointed;
961
548
  } else {
962
549
  const tunnel = await ensureCloudflareTunnel({
963
550
  accountId: coordinates.accountId,
@@ -965,234 +552,29 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
965
552
  apiToken: tokenFor(tokens, "tunnelApiToken")
966
553
  }, fetcher);
967
554
  created = tunnel.created;
968
- nodeResources.push({
969
- nodeName: node.nodeName,
970
- hostname: node.hostname,
971
- service: node.service,
972
- tunnelName: node.tunnelName,
973
- tunnelId: tunnel.tunnel.id,
974
- connectorToken: tunnel.connectorToken
975
- });
555
+ resource = { ...node, tunnelId: tunnel.tunnel.id, connectorToken: tunnel.connectorToken };
976
556
  }
977
- createdNodes.push({ nodeName: node.nodeName, tunnel: created, application: false });
978
- const firstNode = nodeResources[0];
979
- resources = {
980
- tunnelId: firstNode.tunnelId,
981
- kvNamespaceId: namespace.namespace.id,
982
- hostname: firstNode.hostname,
983
- service: firstNode.service,
984
- connectorToken: firstNode.connectorToken,
985
- nodes: [...nodeResources],
986
- ...existing?.resources.access ? { access: existing.resources.access } : {},
987
- ...existing?.resources.runtimeTokens ? { runtimeTokens: existing.resources.runtimeTokens } : {},
988
- ...existing?.resources.worker ? { worker: existing.resources.worker } : {},
989
- ...existing?.resources.privateNetwork ? { privateNetwork: existing.resources.privateNetwork } : {}
990
- };
557
+ nodeResources.push(resource);
558
+ createdNodes.push({ nodeName: node.nodeName, tunnel: created });
559
+ const first2 = nodeResources[0];
991
560
  await writeOwnerBootstrapOutput(absoluteOutput, {
992
561
  format: 1,
993
562
  kind: "forgezero-cloudflare-bootstrap",
994
- phase: resources.access ? "access-token-provisioned" : "edge-resources-provisioned",
563
+ phase: "edge-resources-provisioned",
995
564
  updatedAt: new Date().toISOString(),
996
565
  coordinates,
997
- resources
998
- });
999
- }
1000
- if (!resources)
1001
- throw new Error("Cloudflare fleet has no nodes");
1002
- if (coordinates.createRuntimeTokens && !resources.runtimeTokens) {
1003
- resources = {
1004
- ...resources,
1005
- runtimeTokens: { kv: await createCloudflareAccountRuntimeToken({
1006
- accountId: coordinates.accountId,
1007
- name: `${coordinates.runtimeTokenNamePrefix}-kv-runtime`,
1008
- permissionNames: ["Workers KV Storage Write"],
1009
- apiToken: initialManagementToken(tokens)
1010
- }, fetcher) }
1011
- };
1012
- await writeOwnerBootstrapOutput(absoluteOutput, {
1013
- format: 1,
1014
- kind: "forgezero-cloudflare-bootstrap",
1015
- phase: "runtime-tokens-created",
1016
- updatedAt: new Date().toISOString(),
1017
- coordinates,
1018
- resources
1019
- });
1020
- }
1021
- if (coordinates.createPrivateNetworkRuntimeToken && resources.runtimeTokens && !resources.runtimeTokens.privateNetwork) {
1022
- resources = {
1023
- ...resources,
1024
- runtimeTokens: {
1025
- ...resources.runtimeTokens,
1026
- privateNetwork: await createCloudflareAccountRuntimeToken({
1027
- accountId: coordinates.accountId,
1028
- name: `${coordinates.runtimeTokenNamePrefix}-private-network-runtime`,
1029
- permissionNames: ["Cloudflare One Networks Write", "Zero Trust Write"],
1030
- apiToken: initialManagementToken(tokens)
1031
- }, fetcher)
1032
- }
1033
- };
1034
- await writeOwnerBootstrapOutput(absoluteOutput, {
1035
- format: 1,
1036
- kind: "forgezero-cloudflare-bootstrap",
1037
- phase: "runtime-tokens-created",
1038
- updatedAt: new Date().toISOString(),
1039
- coordinates,
1040
- resources
1041
- });
1042
- }
1043
- if (!resources.worker) {
1044
- await deployCloudflareWorker(coordinates, namespace.namespace.id, tokenFor(tokens, "workerApiToken"), workerRunner);
1045
- resources = {
1046
- ...resources,
1047
- worker: {
1048
- scriptName: coordinates.workerScriptName,
1049
- publicDomains: coordinates.publicDomains,
1050
- deployed: true
566
+ resources: {
567
+ tunnelId: first2.tunnelId,
568
+ kvNamespaceId: coordinates.kvNamespaceId,
569
+ hostname: first2.hostname,
570
+ service: first2.service,
571
+ connectorToken: first2.connectorToken,
572
+ kvRuntimeToken,
573
+ nodes: [...nodeResources]
1051
574
  }
1052
- };
1053
- await writeOwnerBootstrapOutput(absoluteOutput, {
1054
- format: 1,
1055
- kind: "forgezero-cloudflare-bootstrap",
1056
- phase: "worker-deployed",
1057
- updatedAt: new Date().toISOString(),
1058
- coordinates,
1059
- resources
1060
575
  });
1061
576
  }
1062
- const serviceToken = await ensureCloudflareAccessServiceToken({
1063
- accountId: coordinates.accountId,
1064
- name: coordinates.serviceTokenName,
1065
- apiToken: tokenFor(tokens, "accessApiToken"),
1066
- existing: resources.access
1067
- }, fetcher);
1068
- resources = { ...resources, access: serviceToken.credentials };
1069
- await writeOwnerBootstrapOutput(absoluteOutput, {
1070
- format: 1,
1071
- kind: "forgezero-cloudflare-bootstrap",
1072
- phase: "access-token-provisioned",
1073
- updatedAt: new Date().toISOString(),
1074
- coordinates,
1075
- resources
1076
- });
1077
- const policy = await ensureCloudflareAccessPolicy({
1078
- accountId: coordinates.accountId,
1079
- name: coordinates.policyName,
1080
- serviceTokenId: serviceToken.credentials.tokenId,
1081
- apiToken: tokenFor(tokens, "accessApiToken")
1082
- }, fetcher);
1083
- if (!policy.policy.id)
1084
- throw new Error("Cloudflare did not return the Access policy id");
1085
- resources = {
1086
- ...resources,
1087
- access: { ...serviceToken.credentials, policyId: policy.policy.id }
1088
- };
1089
- await writeOwnerBootstrapOutput(absoluteOutput, {
1090
- format: 1,
1091
- kind: "forgezero-cloudflare-bootstrap",
1092
- phase: "access-token-provisioned",
1093
- updatedAt: new Date().toISOString(),
1094
- coordinates,
1095
- resources
1096
- });
1097
- let privateNetworkCreated = false;
1098
- if (coordinates.privateNetwork && !resources.privateNetwork) {
1099
- const managementToken = initialManagementToken(tokens);
1100
- const virtualNetwork = await ensureCloudflareVirtualNetwork({
1101
- accountId: coordinates.accountId,
1102
- name: coordinates.privateNetwork.virtualNetworkName,
1103
- comment: "ForgeZero private database network",
1104
- apiToken: managementToken
1105
- }, fetcher);
1106
- const enrollment = await ensureCloudflareWarpEnrollmentApplication({
1107
- accountId: coordinates.accountId,
1108
- name: coordinates.privateNetwork.enrollmentApplicationName,
1109
- policyId: policy.policy.id,
1110
- apiToken: tokenFor(tokens, "accessApiToken")
1111
- }, fetcher);
1112
- const deviceProfile = await ensureCloudflareWarpDevicePolicy({
1113
- accountId: coordinates.accountId,
1114
- name: coordinates.privateNetwork.deviceProfileName,
1115
- serviceTokenId: serviceToken.credentials.tokenId,
1116
- virtualNetworkId: virtualNetwork.virtualNetwork.id,
1117
- precedence: coordinates.privateNetwork.deviceProfilePrecedence,
1118
- apiToken: managementToken
1119
- }, fetcher);
1120
- const routes = [];
1121
- for (const node of coordinates.nodes.filter((item) => item.privateAddress)) {
1122
- const resource = resources.nodes.find((item) => item.nodeName === node.nodeName);
1123
- const route = await ensureCloudflarePrivateDatabaseRoute({
1124
- accountId: coordinates.accountId,
1125
- tunnelId: resource.tunnelId,
1126
- privateAddress: node.privateAddress,
1127
- virtualNetworkId: virtualNetwork.virtualNetwork.id,
1128
- comment: `ForgeZero ${node.nodeName} database`,
1129
- apiToken: managementToken
1130
- }, fetcher);
1131
- await ensureCloudflareWarpDatabaseInclude({
1132
- accountId: coordinates.accountId,
1133
- policyId: deviceProfile.policy.id,
1134
- privateAddress: node.privateAddress,
1135
- description: `ForgeZero ${node.nodeName} database`,
1136
- apiToken: managementToken
1137
- }, fetcher);
1138
- routes.push({ nodeName: node.nodeName, routeId: route.route.id, privateAddress: node.privateAddress });
1139
- }
1140
- resources = {
1141
- ...resources,
1142
- privateNetwork: {
1143
- warpOrganization: coordinates.privateNetwork.warpOrganization,
1144
- virtualNetworkId: virtualNetwork.virtualNetwork.id,
1145
- deviceProfileId: deviceProfile.policy.id,
1146
- enrollmentApplicationId: enrollment.application.id,
1147
- routes
1148
- }
1149
- };
1150
- privateNetworkCreated = virtualNetwork.created || enrollment.created || deviceProfile.created || routes.length > 0;
1151
- await writeOwnerBootstrapOutput(absoluteOutput, {
1152
- format: 1,
1153
- kind: "forgezero-cloudflare-bootstrap",
1154
- phase: "access-token-provisioned",
1155
- updatedAt: new Date().toISOString(),
1156
- coordinates,
1157
- resources
1158
- });
1159
- }
1160
- for (const node of coordinates.nodes) {
1161
- const application = await ensureCloudflareAccessApplication({
1162
- accountId: coordinates.accountId,
1163
- name: node.applicationName,
1164
- hostname: node.hostname,
1165
- policyId: policy.policy.id,
1166
- apiToken: tokenFor(tokens, "accessApiToken")
1167
- }, fetcher);
1168
- if (!application.application.id)
1169
- throw new Error(`Cloudflare did not return the Access application id for ${node.nodeName}`);
1170
- resources = {
1171
- ...resources,
1172
- nodes: resources.nodes.map((resource) => resource.nodeName === node.nodeName ? { ...resource, applicationId: application.application.id } : resource),
1173
- access: {
1174
- ...resources.access,
1175
- ...node.nodeName === coordinates.nodes[0].nodeName ? { applicationId: application.application.id } : {}
1176
- }
1177
- };
1178
- const createdNode = createdNodes.find(({ nodeName }) => nodeName === node.nodeName);
1179
- createdNode.application = application.created;
1180
- await writeOwnerBootstrapOutput(absoluteOutput, {
1181
- format: 1,
1182
- kind: "forgezero-cloudflare-bootstrap",
1183
- phase: "access-token-provisioned",
1184
- updatedAt: new Date().toISOString(),
1185
- coordinates,
1186
- resources
1187
- });
1188
- }
1189
- await configureCloudflareWorkerAccessSecrets({
1190
- accountId: coordinates.accountId,
1191
- scriptName: coordinates.workerScriptName,
1192
- credentials: serviceToken.credentials,
1193
- apiToken: tokenFor(tokens, "workerApiToken")
1194
- }, fetcher);
1195
- for (const node of resources.nodes) {
577
+ for (const node of nodeResources) {
1196
578
  await configureCloudflareEdge({
1197
579
  accountId: coordinates.accountId,
1198
580
  zoneId: coordinates.zoneId,
@@ -1204,21 +586,24 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
1204
586
  dnsApiToken: tokenFor(tokens, "dnsApiToken")
1205
587
  }, fetcher);
1206
588
  }
589
+ const first = nodeResources[0];
1207
590
  const output = {
1208
591
  format: 1,
1209
592
  kind: "forgezero-cloudflare-bootstrap",
1210
593
  phase: "complete",
1211
594
  updatedAt: new Date().toISOString(),
1212
595
  coordinates,
1213
- resources,
596
+ resources: {
597
+ tunnelId: first.tunnelId,
598
+ kvNamespaceId: coordinates.kvNamespaceId,
599
+ hostname: first.hostname,
600
+ service: first.service,
601
+ connectorToken: first.connectorToken,
602
+ kvRuntimeToken,
603
+ nodes: nodeResources
604
+ },
1214
605
  created: {
1215
606
  tunnel: createdNodes.some(({ tunnel }) => tunnel),
1216
- kvNamespace: namespace.created,
1217
- serviceToken: serviceToken.created,
1218
- policy: policy.created,
1219
- application: createdNodes.some(({ application }) => application),
1220
- privateNetwork: privateNetworkCreated,
1221
- workerDeployed: true,
1222
607
  nodes: createdNodes
1223
608
  }
1224
609
  };
@@ -1234,91 +619,65 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
1234
619
  kind: "forgezero-cloudflare-bootstrap-evidence",
1235
620
  phase: "planned",
1236
621
  checkpointFile: plan.outputFile,
1237
- workerScriptName: plan.coordinates.workerScriptName,
1238
- publicDomains: plan.coordinates.publicDomains,
1239
622
  nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
1240
623
  };
1241
624
  }
1242
625
  if (!request.tokenFiles || !Object.values(request.tokenFiles).some(Boolean)) {
1243
626
  throw new Error("Cloudflare apply requires owner-only management token file paths");
1244
627
  }
1245
- if (plan.coordinates.createRuntimeTokens && !request.tokenFiles.apiTokenFile) {
1246
- throw new Error("Cloudflare runtime-token creation requires the initial management token file");
1247
- }
1248
628
  const tokens = await readCloudflareBootstrapTokens(request.tokenFiles);
1249
- const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch, dependencies.workerRunner);
629
+ const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch);
1250
630
  return {
1251
631
  format: 1,
1252
632
  kind: "forgezero-cloudflare-bootstrap-evidence",
1253
633
  phase: "complete",
1254
634
  checkpointFile: plan.outputFile,
1255
635
  kvNamespaceId: output.resources.kvNamespaceId,
1256
- workerScriptName: output.coordinates.workerScriptName,
1257
- publicDomains: output.resources.worker?.publicDomains ?? output.coordinates.publicDomains,
1258
- runtimeTokenIds: {
1259
- kv: output.resources.runtimeTokens?.kv.id,
1260
- privateNetwork: output.resources.runtimeTokens?.privateNetwork?.id
1261
- },
1262
- nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId, applicationId }) => ({
636
+ nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId }) => ({
1263
637
  nodeName,
1264
638
  hostname,
1265
- ...output.resources.runtimeTokens?.kv ? {
1266
- handoffFile: cloudflareHostHandoffPath(plan.outputFile, nodeName)
1267
- } : {},
1268
- tunnelId,
1269
- applicationId
639
+ handoffFile: cloudflareHostHandoffPath(plan.outputFile, nodeName),
640
+ tunnelId
1270
641
  }))
1271
642
  };
1272
643
  }
644
+ var acceptanceFetch = async (url, label, fetcher) => {
645
+ let response;
646
+ try {
647
+ response = await fetcher(url, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(5000) });
648
+ } catch {
649
+ throw new Error(`${label} is unreachable`);
650
+ }
651
+ if (!response.ok)
652
+ throw new Error(`${label} returned HTTP ${response.status}`);
653
+ return response.status;
654
+ };
1273
655
  async function verifyCloudflareBootstrapAcceptance(checkpointPath, fetcher = fetch) {
1274
656
  const absolute = resolve(checkpointPath);
1275
657
  const output = await readExistingOutput(absolute);
1276
658
  if (!output || output.phase !== "complete")
1277
659
  throw new Error("Cloudflare acceptance requires a completed owner checkpoint");
1278
660
  const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
1279
- const access = output.resources.access;
1280
- if (!access?.clientId?.trim() || !access.clientSecret?.trim()) {
1281
- throw new Error("Cloudflare acceptance checkpoint is missing the Access service credential");
1282
- }
1283
- if (!output.resources.worker?.deployed || output.resources.worker.scriptName !== coordinates.workerScriptName || JSON.stringify(output.resources.worker.publicDomains) !== JSON.stringify(coordinates.publicDomains)) {
1284
- throw new Error("Cloudflare acceptance checkpoint does not prove the expected Worker deployment");
1285
- }
1286
661
  if (output.resources.nodes.length !== coordinates.nodes.length) {
1287
662
  throw new Error("Cloudflare acceptance checkpoint does not cover the declared node fleet");
1288
663
  }
1289
- const nodeNames = new Set;
1290
- const hostnames = new Set;
1291
664
  for (const node of output.resources.nodes) {
1292
665
  const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
1293
- if (!expected || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(node.tunnelId)) {
666
+ if (!expected || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(node.tunnelId)) {
1294
667
  throw new Error("Cloudflare acceptance checkpoint has an unbound node resource");
1295
668
  }
1296
- if (nodeNames.has(node.nodeName) || hostnames.has(node.hostname)) {
1297
- throw new Error("Cloudflare acceptance checkpoint has duplicate node coordinates");
1298
- }
1299
- nodeNames.add(node.nodeName);
1300
- hostnames.add(node.hostname);
1301
669
  }
1302
- const accessHeaders = {
1303
- "CF-Access-Client-Id": access.clientId,
1304
- "CF-Access-Client-Secret": access.clientSecret
1305
- };
1306
670
  const nodes = await Promise.all(output.resources.nodes.map(async ({ nodeName, hostname }) => ({
1307
671
  nodeName,
1308
672
  hostname,
1309
- status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare origin ${nodeName}`, fetcher, accessHeaders)
1310
- })));
1311
- const publicDomains = await Promise.all(output.resources.worker.publicDomains.map(async (hostname) => ({
1312
- hostname,
1313
- status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare public domain ${hostname}`, fetcher)
673
+ status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare origin ${nodeName}`, fetcher)
1314
674
  })));
1315
675
  return {
1316
676
  format: 1,
1317
677
  kind: "forgezero-cloudflare-bootstrap-acceptance",
1318
678
  checkpointFile: absolute,
1319
679
  verifiedAt: new Date().toISOString(),
1320
- nodes,
1321
- publicDomains
680
+ nodes
1322
681
  };
1323
682
  }
1324
683
  export {
@@ -1331,7 +690,6 @@ export {
1331
690
  readCloudflareConnectorHandoff,
1332
691
  readCloudflareBootstrapTokens,
1333
692
  planCloudflareBootstrap,
1334
- deployCloudflareWorker,
1335
693
  cloudflareHostHandoffPath,
1336
694
  applyCloudflareBootstrap
1337
695
  };