@rubytech/create-realagent 1.0.616 → 1.0.617

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.
@@ -17,25 +17,9 @@ export function loadBrand() {
17
17
  throw new Error(`brand.json not found at ${brandPath} — PLATFORM_ROOT may be incorrect`);
18
18
  }
19
19
  const parsed = JSON.parse(readFileSync(brandPath, "utf-8"));
20
- // configDir/productName fall back to Maxy values for forward compat with
21
- // older manifests; cloudflare.zones does NOT — its absence is an
22
- // authoritative-scope problem the bundler should have caught.
23
- if (!parsed.cloudflare ||
24
- !Array.isArray(parsed.cloudflare.zones) ||
25
- parsed.cloudflare.zones.length === 0) {
26
- throw new Error(`brand.json at ${brandPath} is missing a non-empty cloudflare.zones array. ` +
27
- `The Cloudflare plugin refuses to start without an explicit zone declaration. ` +
28
- `Add cloudflare.zones to brands/<name>/brand.json and republish the brand package.`);
29
- }
30
- for (const zone of parsed.cloudflare.zones) {
31
- if (typeof zone !== "string" || zone.length === 0) {
32
- throw new Error(`brand.json at ${brandPath} has invalid cloudflare.zones entry: ${JSON.stringify(zone)}`);
33
- }
34
- }
35
20
  cachedBrand = {
36
21
  configDir: parsed.configDir ?? ".maxy",
37
22
  productName: parsed.productName ?? "Maxy",
38
- cloudflare: { zones: parsed.cloudflare.zones.slice() },
39
23
  };
40
24
  return cachedBrand;
41
25
  }
@@ -155,14 +139,15 @@ function resetAccountBinding() {
155
139
  return false;
156
140
  }
157
141
  }
158
- export function matchManifestZone(hostname, declaredZones) {
142
+ export function matchAccountZone(hostname, accountZones) {
143
+ const active = accountZones.filter((z) => z.status === "active").map((z) => z.name);
159
144
  const lc = hostname.toLowerCase();
160
- const candidates = declaredZones.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
145
+ const candidates = active.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
161
146
  if (candidates.length === 0) {
162
- return { ok: false, matchedZone: null, declaredZones: declaredZones.slice() };
147
+ return { ok: false, matchedZone: null, accountZones: active };
163
148
  }
164
149
  candidates.sort((a, b) => b.length - a.length);
165
- return { ok: true, matchedZone: candidates[0], declaredZones: declaredZones.slice() };
150
+ return { ok: true, matchedZone: candidates[0], accountZones: active };
166
151
  }
167
152
  export function logRefuse(detail) {
168
153
  const brand = (() => {
@@ -176,7 +161,7 @@ export function logRefuse(detail) {
176
161
  const fields = {
177
162
  reason: detail.reason,
178
163
  brand: brand?.productName ?? "unknown",
179
- manifestZones: detail.fields.manifestZones ?? brand?.cloudflare.zones ?? null,
164
+ accountZones: detail.fields.accountZones ?? null,
180
165
  boundAccountId: detail.fields.boundAccountId ?? null,
181
166
  certAccountId: detail.fields.certAccountId ?? null,
182
167
  requestedDomain: detail.fields.requestedDomain ?? null,
@@ -187,16 +172,14 @@ export function logRefuse(detail) {
187
172
  console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
188
173
  }
189
174
  /**
190
- * Emit a single recovery instruction. Every refusal instructs the same path:
191
- * tunnel-login under the account that owns the brand's declared zones.
192
- * No alternative auth path exists.
175
+ * Single recovery instruction. Every refusal instructs the same path:
176
+ * tunnel-login under the Cloudflare account that owns the target zone.
193
177
  */
194
178
  export function recoveryMessage() {
195
- const brand = loadBrand();
196
179
  return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
197
- `the declared zones for this brand: ${brand.cloudflare.zones.join(", ")}. ` +
198
- `If you recently rotated the cert under a different account, pass force=true ` +
199
- `to clear the existing cert.pem and account binding before re-authenticating.`);
180
+ `the target zone. If you recently rotated the cert under a different account, ` +
181
+ `pass force=true to clear the existing cert.pem and account binding before ` +
182
+ `re-authenticating.`);
200
183
  }
201
184
  // ---------------------------------------------------------------------------
202
185
  // Reset auth — unlinks cert.pem (both paths) and the account binding
@@ -468,17 +451,6 @@ export async function listZones() {
468
451
  }
469
452
  return zones;
470
453
  }
471
- export function checkDeclaredZonesOnAccount(declaredZones, accountZones) {
472
- return declaredZones.map((zone) => {
473
- const lc = zone.toLowerCase();
474
- const match = accountZones.find((z) => z.name.toLowerCase() === lc);
475
- return {
476
- zone,
477
- presentOnAccount: !!match,
478
- activeOnAccount: match?.status === "active",
479
- };
480
- });
481
- }
482
454
  export async function getZoneId(domain) {
483
455
  const { client } = getClient();
484
456
  const zones = await client.zones.list({ name: domain });
@@ -902,47 +874,39 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
902
874
  return configPath;
903
875
  }
904
876
  // ---------------------------------------------------------------------------
905
- // CLI-based DNS routing — guarded by manifest scope + post-flight FQDN check
877
+ // CLI-based DNS routing — guarded by live account-zone check + post-flight
906
878
  //
907
879
  // `cloudflared tunnel route dns` resolves the target zone from cert.pem's
908
- // account. If the hostname's registrable parent zone is not on that
909
- // account, cloudflared silently writes a CNAME under a different zone on
910
- // the bound account (e.g. admin.example.com.othersite.example when the
911
- // account holds othersite.example but not example.com). Two layers stop
912
- // this:
880
+ // account. If the hostname's registrable parent zone is not on that account,
881
+ // cloudflared silently writes a CNAME under a different zone (the original
882
+ // joelsmalley.xyz wrong-routing bug). Defence in depth:
913
883
  //
914
- // (1) Manifest-scope pre-flight: refuse before invoking the CLI when the
915
- // hostname's registrable parent is not in `brand.cloudflare.zones`.
916
- // The brand declared the zones; nothing else is routable.
917
- // (2) Post-flight FQDN assertion: parse the actual FQDN from
918
- // `cloudflared`'s INF line and refuse with a reverse-and-cleanup if
919
- // it doesn't exactly equal the requested hostname — defence-in-depth
920
- // against cloudflared writing under a sibling zone the manifest also
921
- // declared but doesn't actually own on this account.
884
+ // (1) Live account-zone check: list the bound account's zones, refuse if
885
+ // hostname's registrable parent is not active on that account.
886
+ // (2) Post-flight FQDN assertion: the FQDN cloudflared wrote must equal
887
+ // the requested hostname. Mismatch reverse-and-refuse.
922
888
  //
923
- // `getClient()` is invoked first to enforce auth pre-conditions
924
- // (cert+binding+accountId match) so account-drift refusals fire before any
925
- // CLI process spawns.
889
+ // `getClient()` runs first to enforce auth pre-conditions.
926
890
  // ---------------------------------------------------------------------------
927
891
  export async function routeDnsCli(tunnelId, hostname) {
928
892
  const bin = findBinary();
929
893
  if (!bin)
930
894
  throw new Error("cloudflared is not installed");
931
- // Auth pre-condition: cert + binding + accountId match. Throws
932
- // CloudflareRefusalError on drift before any CLI call.
933
895
  const { client, accountId: boundAccountId } = getClient();
934
- // (1) Manifest-scope pre-flight against brand.cloudflare.zones.
935
- const brand = loadBrand();
936
- const scope = matchManifestZone(hostname, brand.cloudflare.zones);
896
+ // (1) Live account-zone check.
897
+ const accountZones = await listZones();
898
+ const scope = matchAccountZone(hostname, accountZones);
937
899
  if (!scope.ok) {
938
900
  const detail = {
939
901
  reason: "scope-mismatch",
940
- message: `Cannot route ${hostname} — its registrable parent is not in this brand's declared ` +
941
- `Cloudflare zones (${brand.cloudflare.zones.join(", ")}). ` +
942
- `Hostnames outside the declared scope are refused by design.`,
902
+ message: `Cannot route ${hostname} — no active zone on the bound Cloudflare account ` +
903
+ `owns this hostname's registrable parent. Active zones on this account: ` +
904
+ `${scope.accountZones.join(", ") || "none"}. Add the parent zone to the account ` +
905
+ `(cf-add-zone or via the Cloudflare dashboard), or pick a hostname under one ` +
906
+ `of the existing zones.`,
943
907
  fields: {
944
908
  requestedHostname: hostname,
945
- manifestZones: brand.cloudflare.zones,
909
+ accountZones: scope.accountZones,
946
910
  },
947
911
  };
948
912
  logRefuse(detail);
@@ -963,19 +927,10 @@ export async function routeDnsCli(tunnelId, hostname) {
963
927
  }
964
928
  throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
965
929
  }
966
- // (2) Post-flight FQDN assertion. Parse the CNAME name cloudflared
967
- // actually created from its INF line:
968
- // "INF Added CNAME <fqdn> which will route to this tunnel ..."
930
+ // (2) Post-flight FQDN assertion.
969
931
  const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
970
932
  if (!fqdnMatch) {
971
- // Format drift — log loudly. We treat the absence of the parse as an
972
- // observability gap rather than silent success because we can no
973
- // longer assert wrong-zone safety.
974
933
  console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
975
- // Surfacing as refusal would block correct flows when cloudflared
976
- // changes its log line; surface as unverified-success with the
977
- // requested hostname instead. Operators following [cloudflare:*]
978
- // grep cadence will see the WARNING line.
979
934
  return {
980
935
  created: true,
981
936
  output: output.trim(),
@@ -985,45 +940,34 @@ export async function routeDnsCli(tunnelId, hostname) {
985
940
  }
986
941
  const actualFqdn = fqdnMatch[1];
987
942
  if (actualFqdn !== hostname) {
988
- // Defence-in-depth: cloudflared wrote the CNAME under a different
989
- // FQDN than requested. Log raw evidence first, attempt cleanup ONLY
990
- // if the wrong zone is NOT in our declared scope (we never delete
991
- // in-scope records as "cleanup"), then refuse.
943
+ // cloudflared wrote a CNAME under a different FQDN than requested —
944
+ // somehow the live-zone check passed but the routing landed elsewhere.
945
+ // Log evidence, attempt to delete the wrong CNAME, refuse.
992
946
  console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
993
947
  requestedHostname: hostname,
994
948
  actualFqdn,
995
949
  tunnelId,
996
950
  boundAccountId,
997
951
  })}`);
998
- const actualScope = matchManifestZone(actualFqdn, brand.cloudflare.zones);
999
- let cleanupResult = "skipped-in-scope";
1000
- if (!actualScope.ok) {
1001
- try {
1002
- // Find zone for actualFqdn on the bound account; if found, delete
1003
- // the CNAME we just inadvertently created.
1004
- const allZones = await listZones();
1005
- const owningZone = allZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
1006
- if (owningZone) {
1007
- const records = await client.dns.records.list({
1008
- zone_id: owningZone.id,
1009
- name: { exact: actualFqdn },
1010
- type: "CNAME",
1011
- });
1012
- for (const r of records.result ?? []) {
1013
- if (r.id)
1014
- await client.dns.records.delete(r.id, { zone_id: owningZone.id });
1015
- }
1016
- cleanupResult = "ok";
1017
- }
1018
- else {
1019
- cleanupResult = "failed"; // nothing to delete — surface as informational
952
+ let cleanupResult = "failed";
953
+ try {
954
+ const owningZone = accountZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
955
+ if (owningZone) {
956
+ const records = await client.dns.records.list({
957
+ zone_id: owningZone.id,
958
+ name: { exact: actualFqdn },
959
+ type: "CNAME",
960
+ });
961
+ for (const r of records.result ?? []) {
962
+ if (r.id)
963
+ await client.dns.records.delete(r.id, { zone_id: owningZone.id });
1020
964
  }
1021
- }
1022
- catch (cleanupErr) {
1023
- cleanupResult = "failed";
1024
- console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
965
+ cleanupResult = "ok";
1025
966
  }
1026
967
  }
968
+ catch (cleanupErr) {
969
+ console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
970
+ }
1027
971
  console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
1028
972
  requestedHostname: hostname,
1029
973
  actualFqdn,
@@ -1032,24 +976,18 @@ export async function routeDnsCli(tunnelId, hostname) {
1032
976
  const detail = {
1033
977
  reason: "post-flight-fqdn-mismatch",
1034
978
  message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
1035
- `This indicates cert.pem's account does not own the zone for ${hostname} despite ` +
1036
- `passing manifest scope. ` +
1037
- recoveryMessage(),
979
+ `Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
1038
980
  fields: {
1039
981
  requestedHostname: hostname,
1040
982
  actualFqdn,
1041
983
  tunnelId,
1042
984
  boundAccountId,
1043
- manifestZones: brand.cloudflare.zones,
1044
985
  },
1045
986
  };
1046
987
  logRefuse(detail);
1047
988
  throw new CloudflareRefusalError(detail);
1048
989
  }
1049
990
  console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
1050
- if (process.env.CLOUDFLARE_DEBUG === "1") {
1051
- console.error(`[cloudflare:guard-pass] ${JSON.stringify({ op: "routeDnsCli", requestedHostname: hostname, zone: scope.matchedZone })}`);
1052
- }
1053
991
  return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
1054
992
  }
1055
993
  // ---------------------------------------------------------------------------
@@ -1376,237 +1314,105 @@ export function stopTunnel() {
1376
1314
  writeState({ ...state, pid: null, startedAt: null });
1377
1315
  }
1378
1316
  /**
1379
- * Resolve the live ScopeContext from the brand manifest + binding +
1380
- * read-only SDK client. Used by tools; tests bypass with their own
1381
- * ScopeContext.
1317
+ * cf-verify: read everything, classify nothing as good or bad. The agent
1318
+ * decides what to keep based on what the user is establishing now.
1319
+ *
1320
+ * "Orphans" are simply account-side things the device's current
1321
+ * tunnel.state + alias-domains.json don't reference. They may be pollution
1322
+ * from a previous setup, or may belong to other devices on the same
1323
+ * account — the agent surfaces them and the user decides.
1382
1324
  */
1383
- export function liveScopeContext() {
1325
+ export async function cfVerifyCore() {
1384
1326
  const brand = loadBrand();
1327
+ console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
1328
+ const device = readDeviceSnapshot();
1385
1329
  const ro = getReadOnlyClient();
1386
- return {
1387
- declaredZones: brand.cloudflare.zones,
1388
- binding: readAccountBinding(),
1389
- client: ro.client,
1390
- };
1391
- }
1392
- /**
1393
- * cf-verify implementation. Pure-ish: only reads (account state via SDK,
1394
- * device state via fs). Never mutates. Always returns a structured report
1395
- * even when cert/binding/account state is absent — fresh-install devices
1396
- * get a report with everything tagged MISSING.
1397
- */
1398
- export async function cfVerifyCore(ctx = liveScopeContext()) {
1399
- const brand = loadBrand();
1400
- const artefacts = [];
1401
- console.error(`[cloudflare:verify-start] ${JSON.stringify({
1402
- brand: brand.productName,
1403
- declaredZones: ctx.declaredZones,
1404
- bindingPresent: !!ctx.binding,
1405
- })}`);
1406
- // --- Local artefact: cert.pem ---
1407
- const certCreds = parseCertPem();
1408
- if (!certCreds) {
1409
- artefacts.push({ type: "cert.pem", id: certPath(), tag: "missing" });
1410
- }
1411
- else if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
1412
- artefacts.push({
1413
- type: "cert.pem",
1414
- id: certPath(),
1415
- tag: "out-of-scope",
1416
- reason: `cert account ${certCreds.accountId} != binding account ${ctx.binding.accountId}`,
1417
- detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
1418
- });
1419
- }
1420
- else {
1421
- artefacts.push({ type: "cert.pem", id: certPath(), tag: "in-scope" });
1422
- }
1423
- // --- Local artefact: binding ---
1424
- if (!ctx.binding) {
1425
- artefacts.push({ type: "binding", id: bindingFile(), tag: "missing" });
1426
- }
1427
- else {
1428
- artefacts.push({
1429
- type: "binding",
1430
- id: bindingFile(),
1431
- tag: "in-scope",
1432
- detail: { accountId: ctx.binding.accountId, boundAt: ctx.binding.boundAt },
1433
- });
1434
- }
1435
- // --- Account artefacts (only readable when bound and cert valid) ---
1436
- // Determine if we can call the SDK at all. cfVerify must never fail on
1437
- // unbound devices — it just reports everything as missing.
1438
- let accountSummary = null;
1439
- if (ctx.client && certCreds) {
1330
+ let account = null;
1331
+ if (ro.client && ro.certAccountId) {
1440
1332
  try {
1441
- const [tunnels, zones] = await Promise.all([
1442
- listTunnelsViaClient(ctx.client, certCreds.accountId),
1443
- listZonesViaClient(ctx.client),
1333
+ const [zones, tunnels] = await Promise.all([
1334
+ listZonesViaClient(ro.client),
1335
+ listTunnelsViaClient(ro.client, ro.certAccountId),
1444
1336
  ]);
1445
- accountSummary = { tunnels, zones };
1446
- }
1447
- catch (err) {
1448
- console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
1449
- }
1450
- }
1451
- // --- Declared zones: present + active on account? ---
1452
- if (accountSummary) {
1453
- const visibility = checkDeclaredZonesOnAccount(ctx.declaredZones, accountSummary.zones);
1454
- for (const v of visibility) {
1455
- if (!v.presentOnAccount) {
1456
- artefacts.push({
1457
- type: "declared-zone",
1458
- id: v.zone,
1459
- tag: "missing",
1460
- reason: "declared in brand.json but absent from bound account",
1461
- });
1462
- }
1463
- else if (!v.activeOnAccount) {
1464
- artefacts.push({
1465
- type: "declared-zone",
1466
- id: v.zone,
1467
- tag: "out-of-scope",
1468
- reason: "present on bound account but not active (likely awaiting nameservers)",
1469
- });
1470
- }
1471
- else {
1472
- artefacts.push({ type: "declared-zone", id: v.zone, tag: "in-scope" });
1473
- }
1474
- }
1475
- // Zones on the account NOT in the declared scope — informational tag,
1476
- // out-of-scope, no action required by rebuild (we never delete zones).
1477
- for (const z of accountSummary.zones) {
1478
- if (!ctx.declaredZones.some((d) => d.toLowerCase() === z.name.toLowerCase())) {
1479
- artefacts.push({
1480
- type: "declared-zone",
1481
- id: z.name,
1482
- tag: "out-of-scope",
1483
- reason: "zone on bound account but not in brand.cloudflare.zones",
1484
- });
1485
- }
1486
- }
1487
- // --- Tunnels on account ---
1488
- const persistedTunnelId = readState()?.tunnelId ?? null;
1489
- for (const t of accountSummary.tunnels) {
1490
- const isOurs = t.id === persistedTunnelId;
1491
- artefacts.push({
1492
- type: "tunnel",
1493
- id: `${t.name} (${t.id})`,
1494
- tag: isOurs ? "in-scope" : "out-of-scope",
1495
- reason: isOurs ? undefined : "tunnel on account but not in this brand's persisted state",
1496
- detail: { tunnelId: t.id, tunnelName: t.name },
1497
- });
1498
- }
1499
- // --- DNS CNAMEs under declared zones ---
1500
- if (ctx.client) {
1501
- for (const declared of ctx.declaredZones) {
1502
- const zone = accountSummary.zones.find((z) => z.name.toLowerCase() === declared.toLowerCase() && z.status === "active");
1503
- if (!zone)
1337
+ const cnames = [];
1338
+ for (const z of zones) {
1339
+ if (z.status !== "active")
1504
1340
  continue;
1505
1341
  try {
1506
- const records = await listCnamesUnderZone(ctx.client, zone.id);
1507
- for (const rec of records) {
1508
- const ourTunnelTarget = persistedTunnelId
1509
- ? `${persistedTunnelId}.cfargotunnel.com`
1510
- : null;
1511
- const isOurs = ourTunnelTarget !== null && rec.content === ourTunnelTarget;
1512
- artefacts.push({
1513
- type: "dns-cname",
1514
- id: rec.name,
1515
- tag: isOurs ? "in-scope" : "out-of-scope",
1516
- reason: isOurs ? undefined : `CNAME → ${rec.content} (not this brand's tunnel)`,
1517
- detail: { zoneId: zone.id, recordId: rec.id, content: rec.content },
1518
- });
1342
+ const recs = await listCnamesUnderZone(ro.client, z.id);
1343
+ for (const r of recs) {
1344
+ cnames.push({ zone: z.name, recordId: r.id, name: r.name, content: r.content });
1519
1345
  }
1520
1346
  }
1521
1347
  catch (err) {
1522
- console.error(`[cloudflare:verify] failed to list CNAMEs under ${declared}: ${err instanceof Error ? err.message : String(err)}`);
1348
+ console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
1523
1349
  }
1524
1350
  }
1351
+ account = {
1352
+ accountId: ro.certAccountId,
1353
+ zones: zones.map((z) => ({ id: z.id, name: z.name, status: z.status })),
1354
+ tunnels: tunnels.map((t) => ({ id: t.id, name: t.name })),
1355
+ cnames,
1356
+ };
1525
1357
  }
1526
- }
1527
- else {
1528
- // Without an SDK client we cannot enumerate account state — surface
1529
- // each declared zone as MISSING.
1530
- for (const zone of ctx.declaredZones) {
1531
- artefacts.push({
1532
- type: "declared-zone",
1533
- id: zone,
1534
- tag: "missing",
1535
- reason: "cannot read bound account (no cert or no client)",
1536
- });
1537
- }
1538
- }
1539
- // --- Local artefacts: tunnel.state, config.yml, alias-domains.json ---
1540
- const persisted = readState();
1541
- if (!persisted) {
1542
- artefacts.push({ type: "tunnel.state", id: statePath(), tag: "missing" });
1543
- }
1544
- else {
1545
- // tunnel.state is in-scope when its tunnelId is present on the bound
1546
- // account AND its hostnames are all in declared scope.
1547
- const allHostnames = getPersistedHostnames();
1548
- const allInScope = allHostnames.every((h) => matchManifestZone(h, ctx.declaredZones).ok);
1549
- const tunnelOnAccount = accountSummary?.tunnels.some((t) => t.id === persisted.tunnelId) ?? null;
1550
- if (allInScope && tunnelOnAccount !== false) {
1551
- artefacts.push({ type: "tunnel.state", id: statePath(), tag: "in-scope" });
1552
- }
1553
- else {
1554
- const reasons = [];
1555
- if (!allInScope)
1556
- reasons.push("contains hostnames outside declared scope");
1557
- if (tunnelOnAccount === false)
1558
- reasons.push("references tunnel absent from bound account");
1559
- artefacts.push({
1560
- type: "tunnel.state",
1561
- id: statePath(),
1562
- tag: "out-of-scope",
1563
- reason: reasons.join("; "),
1564
- detail: { tunnelId: persisted.tunnelId, hostnames: JSON.stringify(allHostnames) },
1565
- });
1566
- }
1567
- // config.yml mirrors tunnel.state in scope assessment.
1568
- if (existsSync(persisted.configPath)) {
1569
- artefacts.push({
1570
- type: "config.yml",
1571
- id: persisted.configPath,
1572
- tag: allInScope ? "in-scope" : "out-of-scope",
1573
- });
1574
- }
1575
- else {
1576
- artefacts.push({ type: "config.yml", id: persisted.configPath, tag: "missing" });
1358
+ catch (err) {
1359
+ console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
1577
1360
  }
1578
1361
  }
1579
- const aliases = loadAliasDomains();
1580
- for (const alias of aliases) {
1581
- const inScope = matchManifestZone(alias, ctx.declaredZones).ok;
1582
- artefacts.push({
1583
- type: "alias-domain",
1584
- id: alias,
1585
- tag: inScope ? "in-scope" : "out-of-scope",
1586
- reason: inScope ? undefined : "alias hostname outside brand.cloudflare.zones",
1587
- });
1588
- }
1589
- const counts = {
1590
- inScope: artefacts.filter((a) => a.tag === "in-scope").length,
1591
- outOfScope: artefacts.filter((a) => a.tag === "out-of-scope").length,
1592
- missing: artefacts.filter((a) => a.tag === "missing").length,
1593
- };
1362
+ const orphans = computeOrphans(account, device);
1594
1363
  console.error(`[cloudflare:verify-complete] ${JSON.stringify({
1595
1364
  brand: brand.productName,
1596
- ...counts,
1365
+ orphanTunnels: orphans.tunnels.length,
1366
+ orphanCnames: orphans.cnames.length,
1367
+ orphanZones: orphans.zones.length,
1597
1368
  })}`);
1369
+ return { brand: brand.productName, device, account, orphans };
1370
+ }
1371
+ function readDeviceSnapshot() {
1372
+ const auth = validateAuth();
1373
+ const state = readState();
1374
+ const aliases = [...loadAliasDomains()];
1598
1375
  return {
1599
- brand: brand.productName,
1600
- declaredZones: ctx.declaredZones,
1601
- bindingPresent: !!ctx.binding,
1602
- bindingMatchesCert: !!(certCreds && ctx.binding && certCreds.accountId === ctx.binding.accountId),
1603
- certPresent: !!certCreds,
1604
- artefacts,
1605
- counts,
1376
+ certPath: certPath(),
1377
+ certPresent: auth.hasCert,
1378
+ bindingPath: bindingFile(),
1379
+ bindingPresent: auth.hasBinding,
1380
+ bindingMatchesCert: auth.bound,
1381
+ certAccountId: auth.certAccountId,
1382
+ boundAccountId: auth.boundAccountId,
1383
+ tunnelStatePath: statePath(),
1384
+ tunnelState: state,
1385
+ configYmlPath: state?.configPath ?? null,
1386
+ configYmlPresent: !!(state?.configPath && existsSync(state.configPath)),
1387
+ aliasDomains: aliases,
1606
1388
  };
1607
1389
  }
1608
- // Internal helpers — wrap SDK calls so the verify/rebuild cores don't
1609
- // need to know about the SDK shape directly. Keeps mocks shallow.
1390
+ function computeOrphans(account, device) {
1391
+ if (!account)
1392
+ return { tunnels: [], cnames: [], zones: [] };
1393
+ const intendedTunnelId = device.tunnelState?.tunnelId ?? null;
1394
+ const intendedHostnames = new Set();
1395
+ if (device.tunnelState?.adminHostname)
1396
+ intendedHostnames.add(device.tunnelState.adminHostname.toLowerCase());
1397
+ if (device.tunnelState?.publicHostname)
1398
+ intendedHostnames.add(device.tunnelState.publicHostname.toLowerCase());
1399
+ for (const a of device.aliasDomains)
1400
+ intendedHostnames.add(a.toLowerCase());
1401
+ // Zones the intended hostnames live under — those are "in use".
1402
+ const inUseZones = new Set();
1403
+ for (const h of intendedHostnames) {
1404
+ for (const z of account.zones) {
1405
+ if (h === z.name.toLowerCase() || h.endsWith("." + z.name.toLowerCase())) {
1406
+ inUseZones.add(z.name.toLowerCase());
1407
+ break;
1408
+ }
1409
+ }
1410
+ }
1411
+ const orphanTunnels = account.tunnels.filter((t) => t.id !== intendedTunnelId);
1412
+ const orphanCnames = account.cnames.filter((c) => !intendedHostnames.has(c.name.toLowerCase()));
1413
+ const orphanZones = account.zones.filter((z) => !inUseZones.has(z.name.toLowerCase()));
1414
+ return { tunnels: orphanTunnels, cnames: orphanCnames, zones: orphanZones };
1415
+ }
1610
1416
  async function listZonesViaClient(client) {
1611
1417
  const zones = [];
1612
1418
  for await (const zone of client.zones.list()) {
@@ -1644,251 +1450,176 @@ async function listCnamesUnderZone(client, zoneId) {
1644
1450
  }
1645
1451
  export async function cfRebuildCore(opts = {}) {
1646
1452
  const dryRun = opts.dryRun ?? false;
1647
- const ctx = opts.ctx ?? liveScopeContext();
1648
1453
  const brand = loadBrand();
1649
- console.error(`[cloudflare:rebuild-start] ${JSON.stringify({
1650
- brand: brand.productName,
1651
- declaredZones: ctx.declaredZones,
1652
- dryRun,
1653
- })}`);
1654
- // Step 1: cert.pem account integrity. cf-rebuild refuses to delete a
1655
- // wrong-account cert.pem mid-flow — that would create a dead authoring
1656
- // window with no clear next step. Instead surface the discard intent
1657
- // and halt with an actionable instruction.
1658
- const certCreds = parseCertPem();
1659
- const actions = [];
1660
- if (!certCreds) {
1661
- const msg = `cf-rebuild requires cert.pem before it can reconstruct state. ` + recoveryMessage();
1662
- return {
1663
- brand: brand.productName,
1664
- declaredZones: ctx.declaredZones,
1665
- dryRun,
1666
- actions: [
1667
- {
1668
- op: "skip-needs-operator",
1669
- artefact: { type: "cert.pem", id: certPath(), tag: "missing" },
1670
- planned: dryRun,
1671
- resultDetail: msg,
1672
- },
1673
- ],
1674
- halted: true,
1675
- haltReason: msg,
1676
- };
1454
+ console.error(`[cloudflare:rebuild-start] ${JSON.stringify({ brand: brand.productName, dryRun })}`);
1455
+ // Auth gate. We need a client to mutate the account.
1456
+ let client;
1457
+ let accountId;
1458
+ try {
1459
+ const c = getClient();
1460
+ client = c.client;
1461
+ accountId = c.accountId;
1677
1462
  }
1678
- if (ctx.binding && certCreds.accountId !== ctx.binding.accountId) {
1679
- const msg = `cert.pem is bound to account ${certCreds.accountId} but the recorded binding is ` +
1680
- `account ${ctx.binding.accountId}. cf-rebuild will not silently delete cert.pem — ` +
1681
- `re-run tunnel-login force=true to clear the wrong cert and binding, then re-run cf-rebuild. ` +
1682
- recoveryMessage();
1683
- actions.push({
1684
- op: "skip-needs-operator",
1685
- artefact: {
1686
- type: "cert.pem",
1687
- id: certPath(),
1688
- tag: "out-of-scope",
1689
- reason: "wrong-account",
1690
- detail: { certAccountId: certCreds.accountId, boundAccountId: ctx.binding.accountId },
1691
- },
1692
- planned: dryRun,
1693
- resultDetail: msg,
1694
- });
1695
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cert.pem", reason: "wrong-account", planned: true })}`);
1463
+ catch (err) {
1464
+ if (err instanceof CloudflareRefusalError) {
1465
+ return {
1466
+ brand: brand.productName,
1467
+ dryRun,
1468
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1469
+ actions: [],
1470
+ halted: true,
1471
+ haltReason: err.refusal.message,
1472
+ };
1473
+ }
1474
+ throw err;
1475
+ }
1476
+ // Snapshot the account before mutating.
1477
+ const verify = await cfVerifyCore();
1478
+ if (!verify.account) {
1696
1479
  return {
1697
1480
  brand: brand.productName,
1698
- declaredZones: ctx.declaredZones,
1699
1481
  dryRun,
1700
- actions,
1482
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1483
+ actions: [],
1701
1484
  halted: true,
1702
- haltReason: msg,
1485
+ haltReason: "Could not read account state (cf-verify returned no account snapshot).",
1703
1486
  };
1704
1487
  }
1705
- // Step 2: ensure binding is materialized. The fresh-install case passes
1706
- // through here — binding is established from the cert. cf-rebuild does
1707
- // NOT do declared-zone-visibility validation here because the operator
1708
- // may be in the middle of adding zones; visibility shows in the final
1709
- // verify report instead.
1710
- //
1711
- // materializeBinding can throw if cert.pem becomes unreadable between
1712
- // the parseCertPem above and this call (e.g. a concurrent
1713
- // tunnel-login force=true). Wrap in try/catch so the throw becomes a
1714
- // structured halted result, not a raw JS Error escaping past the MCP
1715
- // boundary.
1716
- if (!ctx.binding) {
1488
+ // Resolve the preserve set. Defaults from the device's intended state.
1489
+ const intendedTunnelId = verify.device.tunnelState?.tunnelId ?? null;
1490
+ const intendedHostnames = [];
1491
+ if (verify.device.tunnelState?.adminHostname)
1492
+ intendedHostnames.push(verify.device.tunnelState.adminHostname);
1493
+ if (verify.device.tunnelState?.publicHostname)
1494
+ intendedHostnames.push(verify.device.tunnelState.publicHostname);
1495
+ for (const a of verify.device.aliasDomains)
1496
+ intendedHostnames.push(a);
1497
+ const inferredZones = new Set();
1498
+ for (const h of intendedHostnames) {
1499
+ const lc = h.toLowerCase();
1500
+ for (const z of verify.account.zones) {
1501
+ if (lc === z.name.toLowerCase() || lc.endsWith("." + z.name.toLowerCase())) {
1502
+ inferredZones.add(z.name);
1503
+ break;
1504
+ }
1505
+ }
1506
+ }
1507
+ const preserveZones = (opts.preserve?.zones ?? [...inferredZones]).map((s) => s.toLowerCase());
1508
+ const preserveTunnelIds = opts.preserve?.tunnelIds ?? (intendedTunnelId ? [intendedTunnelId] : []);
1509
+ const preserveCnames = opts.preserve?.cnames
1510
+ ? opts.preserve.cnames.map((c) => ({ zone: c.zone.toLowerCase(), name: c.name.toLowerCase() }))
1511
+ : null;
1512
+ const actions = [];
1513
+ // (a) Delete tunnels not in preserve.
1514
+ for (const t of verify.account.tunnels) {
1515
+ if (preserveTunnelIds.includes(t.id))
1516
+ continue;
1517
+ const action = {
1518
+ op: "delete-tunnel",
1519
+ type: "tunnel",
1520
+ id: t.id,
1521
+ name: t.name,
1522
+ result: dryRun ? "planned" : "ok",
1523
+ };
1717
1524
  if (!dryRun) {
1718
1525
  try {
1719
- materializeBinding("migration");
1526
+ await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
1720
1527
  }
1721
1528
  catch (err) {
1722
- const msg = err instanceof Error ? err.message : String(err);
1723
- const halt = `cf-rebuild could not establish account binding: ${msg}. ${recoveryMessage()}`;
1724
- actions.push({
1725
- op: "skip-needs-operator",
1726
- artefact: { type: "binding", id: bindingFile(), tag: "missing" },
1727
- planned: dryRun,
1728
- result: "failed",
1729
- resultDetail: halt,
1730
- });
1731
- return {
1732
- brand: brand.productName,
1733
- declaredZones: ctx.declaredZones,
1734
- dryRun,
1735
- actions,
1736
- halted: true,
1737
- haltReason: halt,
1738
- };
1529
+ action.result = "failed";
1530
+ action.detail = err instanceof Error ? err.message : String(err);
1739
1531
  }
1740
1532
  }
1741
- actions.push({
1742
- op: "recreate",
1743
- artefact: {
1744
- type: "binding",
1745
- id: bindingFile(),
1746
- tag: "missing",
1747
- },
1748
- planned: dryRun,
1749
- result: "ok",
1750
- });
1533
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "tunnel", id: t.id, name: t.name, planned: dryRun, result: action.result })}`);
1534
+ actions.push(action);
1751
1535
  }
1752
- // Step 3: gather verify report so we have a plan.
1753
- const verify = await cfVerifyCore(ctx);
1754
- // Step 4: discard out-of-scope artefacts. Order matters: DNS records
1755
- // first (so the tunnel can be deleted afterwards without orphaning
1756
- // routes), then alias-domain entries, then tunnel.state, then config.yml.
1757
- // We never delete cert.pem, never delete declared zones (out-of-scope
1758
- // declared-zone entries are operator-action items, not rebuild work),
1759
- // never delete tunnels owned by the bound account that don't match our
1760
- // persisted state (those belong to other devices on the same account
1761
- // — Task 525's multi-device contention pattern).
1762
- const liveClient = ctx.client; // may be null in tests
1763
- for (const a of verify.artefacts) {
1764
- if (a.tag !== "out-of-scope")
1536
+ // (b) Delete CNAMEs not in preserve.
1537
+ // If preserve.cnames is set, ONLY those exact records survive.
1538
+ // Otherwise: any CNAME under a preserved zone is preserved.
1539
+ for (const c of verify.account.cnames) {
1540
+ const zoneLc = c.zone.toLowerCase();
1541
+ const nameLc = c.name.toLowerCase();
1542
+ let keep = false;
1543
+ if (preserveCnames) {
1544
+ keep = preserveCnames.some((p) => p.zone === zoneLc && p.name === nameLc);
1545
+ }
1546
+ else {
1547
+ keep = preserveZones.includes(zoneLc);
1548
+ }
1549
+ if (keep)
1765
1550
  continue;
1766
- const action = { op: "discard", artefact: a, planned: dryRun };
1767
- try {
1768
- switch (a.type) {
1769
- case "dns-cname": {
1770
- if (!dryRun && liveClient) {
1771
- const zoneId = String(a.detail?.zoneId ?? "");
1772
- const recordId = String(a.detail?.recordId ?? "");
1773
- if (zoneId && recordId) {
1774
- await liveClient.dns.records.delete(recordId, { zone_id: zoneId });
1775
- }
1776
- }
1777
- action.result = "ok";
1778
- break;
1779
- }
1780
- case "alias-domain": {
1781
- if (!dryRun) {
1782
- const aliases = loadAliasDomains();
1783
- aliases.delete(a.id);
1784
- const path = aliasDomainPath();
1785
- mkdirSync(join(homedir(), loadBrand().configDir), { recursive: true });
1786
- writeFileSync(path, JSON.stringify([...aliases], null, 2), "utf-8");
1787
- }
1788
- action.result = "ok";
1789
- break;
1790
- }
1791
- case "tunnel.state":
1792
- case "config.yml": {
1793
- if (!dryRun) {
1794
- try {
1795
- unlinkSync(a.id);
1796
- }
1797
- catch (err) {
1798
- const code = err.code;
1799
- if (code !== "ENOENT")
1800
- throw err;
1801
- }
1802
- }
1803
- action.result = "ok";
1804
- break;
1805
- }
1806
- case "tunnel": {
1807
- // Out-of-scope tunnels are siblings on the same account — leave
1808
- // them alone. Other devices may rely on them. Surface as
1809
- // discard intent but skip without mutating.
1810
- action.op = "skip-needs-operator";
1811
- action.result = "ok";
1812
- action.resultDetail = "tunnel on bound account but not this brand's — left untouched";
1813
- break;
1814
- }
1815
- case "declared-zone": {
1816
- // Out-of-scope declared-zones are zones on the bound account that
1817
- // aren't in the manifest. Informational; never deleted.
1818
- action.op = "skip-needs-operator";
1819
- action.result = "ok";
1820
- action.resultDetail = "zone on bound account outside brand scope — informational";
1821
- break;
1551
+ const action = {
1552
+ op: "delete-cname",
1553
+ type: "cname",
1554
+ id: c.recordId,
1555
+ name: c.name,
1556
+ result: dryRun ? "planned" : "ok",
1557
+ detail: `${c.name} ${c.content} under zone ${c.zone}`,
1558
+ };
1559
+ if (!dryRun) {
1560
+ const zoneRec = verify.account.zones.find((z) => z.name.toLowerCase() === zoneLc);
1561
+ if (!zoneRec) {
1562
+ action.result = "failed";
1563
+ action.detail = `zone ${c.zone} not found in snapshot`;
1564
+ }
1565
+ else {
1566
+ try {
1567
+ await client.dns.records.delete(c.recordId, { zone_id: zoneRec.id });
1822
1568
  }
1823
- case "cert.pem":
1824
- case "binding":
1825
- // Handled above (refuse-to-delete cert; binding mutated by force-reset path only)
1826
- action.op = "skip-needs-operator";
1827
- action.result = "ok";
1828
- break;
1829
- default:
1569
+ catch (err) {
1830
1570
  action.result = "failed";
1831
- action.resultDetail = `unknown artefact type ${a.type}`;
1571
+ action.detail = err instanceof Error ? err.message : String(err);
1572
+ }
1832
1573
  }
1833
1574
  }
1834
- catch (err) {
1835
- action.result = "failed";
1836
- action.resultDetail = err instanceof Error ? err.message : String(err);
1837
- }
1838
- if (action.op === "discard") {
1839
- console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: a.type, id: a.id, planned: dryRun, result: action.result ?? null })}`);
1840
- }
1575
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
1841
1576
  actions.push(action);
1842
1577
  }
1843
- // Step 5: create missing in-scope artefacts. We cannot create CF-side
1844
- // missing zones (operator must add zones at cloudflare.com) — surface as
1845
- // skip-needs-operator. We can re-create local state if the tunnel
1846
- // exists on the account but local state is missing; that's a Task 521
1847
- // scenario (re-attaching a tunnel) and is out of scope here. v1 of
1848
- // cf-rebuild surfaces missing local artefacts as informational.
1849
- for (const a of verify.artefacts) {
1850
- if (a.tag !== "missing")
1578
+ // (c) Delete zones not in preserve.
1579
+ for (const z of verify.account.zones) {
1580
+ if (preserveZones.includes(z.name.toLowerCase()))
1851
1581
  continue;
1852
- if (a.type === "declared-zone") {
1853
- actions.push({
1854
- op: "skip-needs-operator",
1855
- artefact: a,
1856
- planned: dryRun,
1857
- result: "ok",
1858
- resultDetail: `declared zone "${a.id}" must be added to the bound Cloudflare account before tunnel-create can route to it`,
1859
- });
1860
- }
1861
- else if (a.type === "binding" || a.type === "cert.pem") {
1862
- // Already handled above
1863
- }
1864
- else {
1865
- actions.push({
1866
- op: "skip-needs-operator",
1867
- artefact: a,
1868
- planned: dryRun,
1869
- result: "ok",
1870
- resultDetail: `missing local artefact — run cloudflare-setup or tunnel-create to create`,
1871
- });
1582
+ const action = {
1583
+ op: "delete-zone",
1584
+ type: "zone",
1585
+ id: z.id,
1586
+ name: z.name,
1587
+ result: dryRun ? "planned" : "ok",
1588
+ };
1589
+ if (!dryRun) {
1590
+ try {
1591
+ await client.zones.delete({ zone_id: z.id });
1592
+ }
1593
+ catch (err) {
1594
+ // Zone deletion may fail (permissions, registrar lock). Surface but
1595
+ // do not halt — orphan zones are informational, not blocking.
1596
+ action.result = "failed";
1597
+ action.detail = err instanceof Error ? err.message : String(err);
1598
+ }
1872
1599
  }
1600
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "zone", id: z.id, name: z.name, planned: dryRun, result: action.result })}`);
1601
+ actions.push(action);
1873
1602
  }
1874
- // Step 6: rerun verify (unless dry-run) to capture final state.
1875
1603
  let finalVerify;
1876
1604
  if (!dryRun) {
1877
- finalVerify = await cfVerifyCore(ctx);
1605
+ finalVerify = await cfVerifyCore();
1878
1606
  }
1879
1607
  console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
1880
1608
  brand: brand.productName,
1881
1609
  dryRun,
1882
1610
  actionCount: actions.length,
1883
- finalCounts: finalVerify?.counts ?? null,
1884
1611
  })}`);
1885
1612
  return {
1886
1613
  brand: brand.productName,
1887
- declaredZones: ctx.declaredZones,
1888
1614
  dryRun,
1615
+ preserve: {
1616
+ zones: preserveZones,
1617
+ tunnelIds: preserveTunnelIds,
1618
+ cnames: preserveCnames,
1619
+ },
1889
1620
  actions,
1890
- halted: false,
1891
1621
  finalVerify,
1622
+ halted: false,
1892
1623
  };
1893
1624
  }
1894
1625
  //# sourceMappingURL=cloudflared.js.map