@rubytech/create-realagent 1.0.615 → 1.0.616

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/config/brand.json +4 -0
  3. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +23 -13
  4. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/mcp-stderr-tee/dist/index.js +86 -89
  6. package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
  7. package/payload/platform/lib/mcp-stderr-tee/src/index.ts +86 -101
  8. package/payload/platform/plugins/admin/mcp/dist/index.js +33 -2
  9. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  10. package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +22 -8
  11. package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -4
  12. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +196 -0
  13. package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +81 -0
  14. package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +65 -0
  15. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +70 -0
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +124 -0
  17. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +221 -200
  18. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  19. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +174 -39
  20. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  21. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +891 -194
  22. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  23. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  24. package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +10 -0
  25. package/payload/platform/plugins/cloudflare/references/setup-guide.md +31 -32
  26. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +25 -3
  27. package/payload/platform/plugins/docs/PLUGIN.md +2 -0
  28. package/payload/platform/plugins/docs/references/cloudflare.md +68 -0
  29. package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
  30. package/payload/platform/scripts/logs-read.sh +114 -54
  31. package/payload/platform/templates/specialists/agents/personal-assistant.md +12 -8
  32. package/payload/server/server.js +387 -71
@@ -17,12 +17,35 @@ 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
+ }
20
35
  cachedBrand = {
21
36
  configDir: parsed.configDir ?? ".maxy",
22
37
  productName: parsed.productName ?? "Maxy",
38
+ cloudflare: { zones: parsed.cloudflare.zones.slice() },
23
39
  };
24
40
  return cachedBrand;
25
41
  }
42
+ /**
43
+ * Test-only: reset the cached brand so a subsequent loadBrand() re-reads
44
+ * the manifest. Production code never calls this.
45
+ */
46
+ export function _resetBrandCache() {
47
+ cachedBrand = null;
48
+ }
26
49
  // ---------------------------------------------------------------------------
27
50
  // Binary detection (unchanged — cloudflared binary still needed for daemon)
28
51
  // ---------------------------------------------------------------------------
@@ -73,69 +96,119 @@ export function version() {
73
96
  return null;
74
97
  }
75
98
  }
76
- // ---------------------------------------------------------------------------
77
- // API token storage
78
- //
79
- // Stored under ~/{configDir}/cloudflare/ so each brand has its own token.
80
- // Functions (not consts) because loadBrand() requires PLATFORM_ROOT at runtime.
81
- // ---------------------------------------------------------------------------
82
- function tokenDir() {
99
+ function bindingDir() {
83
100
  return join(homedir(), loadBrand().configDir, "cloudflare");
84
101
  }
85
- function tokenFile() {
86
- return join(tokenDir(), "api-token");
102
+ function bindingFile() {
103
+ return join(bindingDir(), "account-binding.json");
87
104
  }
88
- export function readToken() {
105
+ export function readAccountBinding() {
89
106
  try {
90
- const path = tokenFile();
107
+ const path = bindingFile();
91
108
  if (!existsSync(path))
92
109
  return null;
93
- return JSON.parse(readFileSync(path, "utf-8"));
110
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
111
+ if (typeof parsed?.accountId !== "string" || typeof parsed?.boundAt !== "string") {
112
+ console.error(`[cloudflare:binding] malformed account-binding.json at ${path} — treating as absent`);
113
+ return null;
114
+ }
115
+ return { accountId: parsed.accountId, boundAt: parsed.boundAt };
94
116
  }
95
- catch {
117
+ catch (err) {
118
+ console.error(`[cloudflare:binding] failed to read ${bindingFile()}: ${err}`);
96
119
  return null;
97
120
  }
98
121
  }
99
- export function writeToken(apiToken, accountId) {
100
- const dir = tokenDir();
122
+ /**
123
+ * @internal — production code MUST go through `materializeBinding` so the
124
+ * source-of-truth lifecycle (fresh-login vs migration) is preserved and
125
+ * logged. This function is exported only so unit tests can pre-seed
126
+ * bindings without re-implementing the file format. A new tool handler
127
+ * that calls `writeAccountBinding` directly is a code-review block —
128
+ * silent overwrites mask account drift, defeating the four-step guard.
129
+ */
130
+ export function writeAccountBinding(accountId) {
131
+ const dir = bindingDir();
101
132
  mkdirSync(dir, { recursive: true });
102
- const data = { apiToken, accountId };
103
- writeFileSync(join(dir, "api-token"), JSON.stringify(data), { mode: 0o600 });
104
- }
105
- export function hasToken() {
106
- return readToken() !== null;
133
+ const binding = { accountId, boundAt: new Date().toISOString() };
134
+ writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
135
+ return binding;
107
136
  }
108
137
  /**
109
- * Returns the prefix of the stored API token (e.g. "cfut_" for cert-derived
110
- * tokens), or null if no token is stored. Used to distinguish cert-derived
111
- * tokens (limited scope) from user-created API tokens.
138
+ * Reset the binding by unlinking the file. Only force-reset paths
139
+ * (tunnel-login force=true, cf-rebuild after wrong-account detection)
140
+ * call this. Tools must never overwrite the binding on a write path —
141
+ * an overwrite would mask account drift, defeating the guard.
112
142
  */
113
- export function getTokenPrefix() {
114
- const token = readToken();
115
- if (!token)
116
- return null;
117
- // cfut_ is 5 chars; return up to 8 chars for other token prefixes
118
- const prefix = token.apiToken.slice(0, 8);
119
- return prefix || null;
143
+ function resetAccountBinding() {
144
+ const path = bindingFile();
145
+ try {
146
+ unlinkSync(path);
147
+ console.error(`[cloudflare:binding] reset ${path}`);
148
+ return true;
149
+ }
150
+ catch (err) {
151
+ const code = err.code;
152
+ if (code === "ENOENT")
153
+ return false;
154
+ console.error(`[cloudflare:binding] failed to reset ${path}: ${err}`);
155
+ return false;
156
+ }
157
+ }
158
+ export function matchManifestZone(hostname, declaredZones) {
159
+ const lc = hostname.toLowerCase();
160
+ const candidates = declaredZones.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
161
+ if (candidates.length === 0) {
162
+ return { ok: false, matchedZone: null, declaredZones: declaredZones.slice() };
163
+ }
164
+ candidates.sort((a, b) => b.length - a.length);
165
+ return { ok: true, matchedZone: candidates[0], declaredZones: declaredZones.slice() };
166
+ }
167
+ export function logRefuse(detail) {
168
+ const brand = (() => {
169
+ try {
170
+ return loadBrand();
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ })();
176
+ const fields = {
177
+ reason: detail.reason,
178
+ brand: brand?.productName ?? "unknown",
179
+ manifestZones: detail.fields.manifestZones ?? brand?.cloudflare.zones ?? null,
180
+ boundAccountId: detail.fields.boundAccountId ?? null,
181
+ certAccountId: detail.fields.certAccountId ?? null,
182
+ requestedDomain: detail.fields.requestedDomain ?? null,
183
+ requestedHostname: detail.fields.requestedHostname ?? null,
184
+ actualFqdn: detail.fields.actualFqdn ?? null,
185
+ tunnelId: detail.fields.tunnelId ?? null,
186
+ };
187
+ console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
120
188
  }
121
189
  /**
122
- * Delete the stored API token and cert.pem, enabling a fresh authentication
123
- * flow. Handles missing files (no-op) and permission errors (log + continue)
124
- * gracefully. Used by tunnel-login --force to break out of the dead-end
125
- * when the stored token has insufficient permissions.
126
- *
127
- * Dual-path cert deletion: cloudflared writes cert.pem to ~/.cloudflared/
128
- * regardless of --origincert, so a complete reset must unlink BOTH the
129
- * brand-specific path AND the legacy path. Without the legacy unlink,
130
- * findCert() would re-import the stale cert on the next read.
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.
131
193
  */
194
+ export function recoveryMessage() {
195
+ const brand = loadBrand();
196
+ 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.`);
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Reset auth — unlinks cert.pem (both paths) and the account binding
203
+ //
204
+ // Kill order matters: terminate any in-flight `cloudflared tunnel login`
205
+ // process BEFORE deleting cert state, otherwise its browser handshake can
206
+ // complete between our unlink and return, writing a fresh cert.pem to the
207
+ // legacy path that findCert() would silently re-import on the next read.
208
+ // Same pattern Task 531 closed; preserved here.
209
+ // ---------------------------------------------------------------------------
132
210
  export function resetAuth() {
133
- const result = { deletedToken: false, deletedCert: false };
134
- // Kill any running login process BEFORE clearing cert/token state. Otherwise
135
- // an in-flight `cloudflared tunnel login` could complete its browser handshake
136
- // between our unlinks and return from resetAuth, writing a fresh cert.pem to
137
- // the legacy path immediately after we just deleted it — and findCert would
138
- // re-import it on the next read, defeating the reset entirely.
211
+ const result = { deletedCert: false, deletedBinding: false };
139
212
  const loginState = readLoginState();
140
213
  if (loginState?.pid && isProcessAlive(loginState.pid)) {
141
214
  try {
@@ -147,21 +220,6 @@ export function resetAuth() {
147
220
  }
148
221
  }
149
222
  clearLoginState();
150
- const tf = tokenFile();
151
- try {
152
- unlinkSync(tf);
153
- result.deletedToken = true;
154
- console.error(`[tunnel-login] deleted: ${tf}`);
155
- }
156
- catch (err) {
157
- const code = err.code;
158
- if (code === "ENOENT") {
159
- // File already gone — no-op
160
- }
161
- else {
162
- console.error(`[tunnel-login] failed to delete token file ${tf}: ${err}`);
163
- }
164
- }
165
223
  const cp = certPath();
166
224
  try {
167
225
  unlinkSync(cp);
@@ -191,6 +249,7 @@ export function resetAuth() {
191
249
  console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
192
250
  }
193
251
  }
252
+ result.deletedBinding = resetAccountBinding();
194
253
  return result;
195
254
  }
196
255
  // ---------------------------------------------------------------------------
@@ -280,80 +339,117 @@ export function parseCertPem() {
280
339
  }
281
340
  // ---------------------------------------------------------------------------
282
341
  // SDK client
342
+ //
343
+ // Two entry points: getClient() for mutating operations enforces the auth
344
+ // pre-conditions (cert present, binding present, accountId-from-cert
345
+ // matches binding) — uncircumventable for any code path that needs an SDK
346
+ // instance. getReadOnlyClient() for cf-verify allows operating on an
347
+ // unbound device so the audit can report MISSING artefacts without
348
+ // throwing. Both rely on cert.pem as the single account identity source.
283
349
  // ---------------------------------------------------------------------------
284
- export function getClient() {
285
- const token = readToken();
286
- if (!token) {
287
- throw new Error("No API token configured. Run cf-set-token to set one.");
350
+ /** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
351
+ export class CloudflareRefusalError extends Error {
352
+ refusal;
353
+ constructor(detail) {
354
+ super(detail.message);
355
+ this.name = "CloudflareRefusalError";
356
+ this.refusal = detail;
288
357
  }
289
- return {
290
- client: new Cloudflare({ apiToken: token.apiToken }),
291
- accountId: token.accountId,
292
- };
293
358
  }
294
- export async function validateToken(apiToken) {
295
- const client = new Cloudflare({ apiToken });
296
- try {
297
- // Try zones first most accounts have at least one zone
298
- const zones = await client.zones.list({ per_page: 1 });
299
- const zone = zones.result?.[0];
300
- if (zone) {
301
- return {
302
- valid: true,
303
- accountId: zone.account.id ?? null,
304
- accountName: zone.account.name ?? null,
305
- };
306
- }
307
- // No zones — discover account ID via accounts.list() (works for new accounts)
308
- const accounts = await client.accounts.list({ per_page: 1 });
309
- const account = accounts.result?.[0];
310
- if (account) {
311
- return {
312
- valid: true,
313
- accountId: account.id ?? null,
314
- accountName: account.name ?? null,
315
- };
316
- }
317
- // Token is valid but no zones and no accounts discoverable
318
- return {
319
- valid: true,
320
- accountId: null,
321
- accountName: null,
322
- error: "Token is valid but could not discover account ID — no zones or accounts found.",
359
+ /**
360
+ * Build an SDK client for mutating operations. Throws CloudflareRefusalError
361
+ * with a structured `refusal` field if any auth pre-condition fails:
362
+ * - cert.pem absent or unparseable → unbound-device
363
+ * - account-binding.json absent → unbound-device
364
+ * - cert.pem accountId !== binding.accountId → account-drift
365
+ *
366
+ * The refusal is logged with [cloudflare:refuse] before the throw, so the
367
+ * single greppable signal exists regardless of how the caller handles the
368
+ * exception.
369
+ */
370
+ export function getClient() {
371
+ const creds = parseCertPem();
372
+ if (!creds) {
373
+ const detail = {
374
+ reason: "unbound-device",
375
+ message: `No cert.pem found — this device has not completed Cloudflare authentication. ` +
376
+ recoveryMessage(),
377
+ fields: { boundAccountId: readAccountBinding()?.accountId ?? null },
323
378
  };
324
- }
325
- catch (err) {
326
- const msg = err instanceof Error ? err.message : String(err);
327
- if (msg.includes("Authentication")) {
328
- return {
329
- valid: false,
330
- accountId: null,
331
- accountName: null,
332
- error: "Token rejected by Cloudflare check that it is correct and has not expired.",
333
- };
334
- }
335
- return {
336
- valid: false,
337
- accountId: null,
338
- accountName: null,
339
- error: msg,
379
+ logRefuse(detail);
380
+ throw new CloudflareRefusalError(detail);
381
+ }
382
+ const binding = readAccountBinding();
383
+ if (!binding) {
384
+ const detail = {
385
+ reason: "unbound-device",
386
+ message: `No account binding recorded — tunnel-login must complete a fresh authentication ` +
387
+ `to bind this device to a Cloudflare account before any other tool can run. ` +
388
+ recoveryMessage(),
389
+ fields: { certAccountId: creds.accountId },
390
+ };
391
+ logRefuse(detail);
392
+ throw new CloudflareRefusalError(detail);
393
+ }
394
+ if (creds.accountId !== binding.accountId) {
395
+ const detail = {
396
+ reason: "account-drift",
397
+ message: `cert.pem is bound to account ${creds.accountId}, but this device's recorded ` +
398
+ `binding is account ${binding.accountId}. The cert was rotated under a different ` +
399
+ `Cloudflare account since the binding was established. ` +
400
+ recoveryMessage(),
401
+ fields: {
402
+ boundAccountId: binding.accountId,
403
+ certAccountId: creds.accountId,
404
+ },
340
405
  };
406
+ logRefuse(detail);
407
+ throw new CloudflareRefusalError(detail);
341
408
  }
409
+ return {
410
+ client: new Cloudflare({ apiToken: creds.apiToken }),
411
+ accountId: binding.accountId,
412
+ };
342
413
  }
343
- export async function validateAuth() {
344
- const token = readToken();
345
- if (!token) {
346
- return { hasToken: false, tokenValid: false };
347
- }
348
- try {
349
- const client = new Cloudflare({ apiToken: token.apiToken });
350
- await client.zones.list({ per_page: 1 });
351
- return { hasToken: true, tokenValid: true };
352
- }
353
- catch (err) {
354
- const msg = err instanceof Error ? err.message : String(err);
355
- return { hasToken: true, tokenValid: false, error: msg };
356
- }
414
+ export function getReadOnlyClient() {
415
+ const creds = parseCertPem();
416
+ const binding = readAccountBinding();
417
+ const certAccountId = creds?.accountId ?? null;
418
+ const boundAccountId = binding?.accountId ?? null;
419
+ const client = creds ? new Cloudflare({ apiToken: creds.apiToken }) : null;
420
+ const bindingMatches = !!(creds && binding && creds.accountId === binding.accountId);
421
+ return { client, certAccountId, boundAccountId, bindingMatches };
422
+ }
423
+ export function validateAuth() {
424
+ const creds = parseCertPem();
425
+ const binding = readAccountBinding();
426
+ return {
427
+ hasCert: !!creds,
428
+ hasBinding: !!binding,
429
+ bound: !!(creds && binding && creds.accountId === binding.accountId),
430
+ certAccountId: creds?.accountId ?? null,
431
+ boundAccountId: binding?.accountId ?? null,
432
+ };
433
+ }
434
+ /**
435
+ * Establish the device-local account binding from cert.pem. Used by:
436
+ * (1) tunnel-login fresh-success path — first time creds are derived
437
+ * (2) migration path — when cert.pem exists from a prior install but no
438
+ * binding has yet been written (silent materialization, since the
439
+ * cert was already trust-established by the operator's prior login)
440
+ *
441
+ * Caller is responsible for declared-zone visibility validation BEFORE
442
+ * calling this — bindings should never be written to an account that
443
+ * doesn't own the brand's declared zones (Task 201 write-after-confirm).
444
+ */
445
+ export function materializeBinding(source) {
446
+ const creds = parseCertPem();
447
+ if (!creds) {
448
+ throw new Error("Cannot materialize binding — cert.pem absent or unparseable");
449
+ }
450
+ const binding = writeAccountBinding(creds.accountId);
451
+ console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
452
+ return binding;
357
453
  }
358
454
  export async function listZones() {
359
455
  const { client } = getClient();
@@ -372,16 +468,16 @@ export async function listZones() {
372
468
  }
373
469
  return zones;
374
470
  }
375
- export function verifyZoneOnAccount(hostname, zones) {
376
- const activeZones = zones.filter((z) => z.status === "active").map((z) => z.name);
377
- const candidates = activeZones.filter((z) => hostname === z || hostname.endsWith(`.${z}`));
378
- if (candidates.length > 0) {
379
- candidates.sort((a, b) => b.length - a.length);
380
- return { zone: candidates[0], missingParent: null, availableZones: activeZones };
381
- }
382
- const parts = hostname.split(".");
383
- const missingParent = parts.length >= 2 ? parts.slice(-2).join(".") : hostname;
384
- return { zone: null, missingParent, availableZones: activeZones };
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
+ });
385
481
  }
386
482
  export async function getZoneId(domain) {
387
483
  const { client } = getClient();
@@ -396,9 +492,6 @@ export async function getZoneId(domain) {
396
492
  }
397
493
  export async function createZone(domain) {
398
494
  const { client, accountId } = getClient();
399
- if (!accountId) {
400
- throw new Error("No account ID available. Re-run cf-set-token — the token must be re-validated to discover the account ID.");
401
- }
402
495
  // Idempotent — check if zone already exists on this account
403
496
  const existing = await client.zones.list({ name: domain });
404
497
  const match = existing.result?.[0];
@@ -710,11 +803,11 @@ export async function createDnsRecord(zoneId, subdomain, tunnelId) {
710
803
  return { created: true, existing: false, updated: false };
711
804
  }
712
805
  // ---------------------------------------------------------------------------
713
- // CLI-based tunnel operations — fallback for cert-derived tokens (cfut_*)
806
+ // CLI-based tunnel operations
714
807
  //
715
- // cert-derived tokens may lack SDK-level permissions for tunnel CRUD and
716
- // DNS management. The cloudflared CLI uses cert.pem directly, which carries
717
- // Argo Tunnel permissions sufficient for these operations.
808
+ // `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS management.
809
+ // We use it (rather than the SDK) for tunnel-create / route-dns because the
810
+ // cert-bound credential carries the Argo Tunnel permissions needed in one step.
718
811
  // ---------------------------------------------------------------------------
719
812
  export function createTunnelCli(name) {
720
813
  const bin = findBinary();
@@ -809,68 +902,155 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
809
902
  return configPath;
810
903
  }
811
904
  // ---------------------------------------------------------------------------
812
- // CLI-based DNS routing
905
+ // CLI-based DNS routing — guarded by manifest scope + post-flight FQDN check
906
+ //
907
+ // `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:
813
913
  //
814
- // cert-derived tokens lack Zone:DNS:Edit, so the SDK's createDnsRecord()
815
- // fails. `cloudflared tunnel route dns` uses the cert.pem directly, which
816
- // carries Argo Tunnel permissions sufficient for creating CNAME records
817
- // that point to the tunnel.
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.
818
922
  //
819
- // Pre-flight zone-account check: cloudflared resolves the target zone
820
- // from cert.pem's account and silently falls back to a sibling zone when
821
- // the hostname's parent zone is not present. verifyZoneOnAccount() is the
822
- // choke point that intercepts that failure class before the CLI call.
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.
823
926
  // ---------------------------------------------------------------------------
824
927
  export async function routeDnsCli(tunnelId, hostname) {
825
928
  const bin = findBinary();
826
929
  if (!bin)
827
930
  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
+ 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);
937
+ if (!scope.ok) {
938
+ const detail = {
939
+ 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.`,
943
+ fields: {
944
+ requestedHostname: hostname,
945
+ manifestZones: brand.cloudflare.zones,
946
+ },
947
+ };
948
+ logRefuse(detail);
949
+ throw new CloudflareRefusalError(detail);
950
+ }
828
951
  const cert = findCert();
829
952
  if (!cert)
830
- throw new Error("No cert.pem found — run tunnel-login first");
831
- const zones = await listZones();
832
- const match = verifyZoneOnAccount(hostname, zones);
833
- console.error(`[tunnel-route-dns] zone-check hostname=${hostname} matchedZone=${match.zone ?? "none"} availableZones=${JSON.stringify(match.availableZones)}`);
834
- if (!match.zone) {
835
- const availableList = match.availableZones.length > 0
836
- ? match.availableZones.join(", ")
837
- : "none";
838
- throw new Error(`Cannot route ${hostname} to a tunnel — the zone that owns this hostname is not on the Cloudflare account bound to this device. ` +
839
- `Best guess at missing zone: "${match.missingParent}" (derived from the last two labels of ${hostname}; for multi-label TLDs such as .co.uk the real zone may be longer). ` +
840
- `Available zones on this account: ${availableList}. ` +
841
- `Recovery: run tunnel-login while signed into the Cloudflare account that owns the zone for ${hostname}. ` +
842
- `This rebinds cert.pem to the correct account. Do not use cf-set-token with a token from a different account — zone routing uses cert.pem, not the API token.`);
843
- }
953
+ throw new Error("No cert.pem found — getClient() should have refused first");
954
+ let output;
844
955
  try {
845
- const output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
846
- // Parse the CNAME name cloudflared actually created from its INF line:
847
- // "INF Added CNAME <fqdn> which will route to this tunnel ..."
848
- // Log a diagnostic when the parse fails so a silent format drift cannot
849
- // mask a wrong-zone routing (which would otherwise defeat the warning
850
- // branch in tunnel-create's result line). Falling back to the input
851
- // hostname is safe because the pre-flight above guarantees zone match.
852
- const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
853
- let fqdn;
854
- if (fqdnMatch) {
855
- fqdn = fqdnMatch[1];
856
- }
857
- else {
858
- console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Using input hostname. Output: ${output.trim()}`);
859
- fqdn = hostname;
860
- }
861
- console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${fqdn} zone=${match.zone}`);
862
- return { created: true, output: output.trim(), fqdn, zone: match.zone };
956
+ output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
863
957
  }
864
958
  catch (err) {
865
959
  const msg = err instanceof Error ? err.stderr ?? err.message : String(err);
866
- // With --overwrite-dns this path should not trigger for CNAME conflicts.
867
- // If it fires, log the full error so the cause is diagnosable.
868
960
  if (typeof msg === "string" && msg.includes("already exists")) {
869
961
  console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
870
- return { created: false, output: msg, fqdn: hostname, zone: match.zone };
962
+ return { created: false, output: msg, fqdn: hostname, zone: scope.matchedZone };
871
963
  }
872
964
  throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
873
965
  }
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 ..."
969
+ const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
970
+ 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
+ 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
+ return {
980
+ created: true,
981
+ output: output.trim(),
982
+ fqdn: hostname,
983
+ zone: scope.matchedZone,
984
+ };
985
+ }
986
+ const actualFqdn = fqdnMatch[1];
987
+ 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.
992
+ console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
993
+ requestedHostname: hostname,
994
+ actualFqdn,
995
+ tunnelId,
996
+ boundAccountId,
997
+ })}`);
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
1020
+ }
1021
+ }
1022
+ catch (cleanupErr) {
1023
+ cleanupResult = "failed";
1024
+ console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
1025
+ }
1026
+ }
1027
+ console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
1028
+ requestedHostname: hostname,
1029
+ actualFqdn,
1030
+ result: cleanupResult,
1031
+ })}`);
1032
+ const detail = {
1033
+ reason: "post-flight-fqdn-mismatch",
1034
+ 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(),
1038
+ fields: {
1039
+ requestedHostname: hostname,
1040
+ actualFqdn,
1041
+ tunnelId,
1042
+ boundAccountId,
1043
+ manifestZones: brand.cloudflare.zones,
1044
+ },
1045
+ };
1046
+ logRefuse(detail);
1047
+ throw new CloudflareRefusalError(detail);
1048
+ }
1049
+ 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
+ return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
874
1054
  }
875
1055
  // ---------------------------------------------------------------------------
876
1056
  // tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
@@ -1103,8 +1283,8 @@ export function getPersistedHostnames() {
1103
1283
  }
1104
1284
  return [];
1105
1285
  }
1106
- export async function getStatus(domain) {
1107
- const auth = await validateAuth();
1286
+ export function getStatus(domain) {
1287
+ const auth = validateAuth();
1108
1288
  const state = readState();
1109
1289
  const running = state !== null && isProcessAlive(state.pid);
1110
1290
  const effectiveDomain = domain ?? state?.domain ?? null;
@@ -1112,10 +1292,11 @@ export async function getStatus(domain) {
1112
1292
  return {
1113
1293
  installed: isInstalled(),
1114
1294
  version: version(),
1115
- hasCert: hasCert(),
1116
- hasToken: auth.hasToken,
1117
- tokenValid: auth.tokenValid,
1118
- authError: auth.error ?? null,
1295
+ hasCert: auth.hasCert,
1296
+ hasBinding: auth.hasBinding,
1297
+ bound: auth.bound,
1298
+ certAccountId: auth.certAccountId,
1299
+ boundAccountId: auth.boundAccountId,
1119
1300
  running,
1120
1301
  pid: running ? state.pid : null,
1121
1302
  tunnelId: state?.tunnelId ?? null,
@@ -1194,4 +1375,520 @@ export function stopTunnel() {
1194
1375
  // Preserve tunnel identity, clear process lifecycle
1195
1376
  writeState({ ...state, pid: null, startedAt: null });
1196
1377
  }
1378
+ /**
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.
1382
+ */
1383
+ export function liveScopeContext() {
1384
+ const brand = loadBrand();
1385
+ 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) {
1440
+ try {
1441
+ const [tunnels, zones] = await Promise.all([
1442
+ listTunnelsViaClient(ctx.client, certCreds.accountId),
1443
+ listZonesViaClient(ctx.client),
1444
+ ]);
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)
1504
+ continue;
1505
+ 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
+ });
1519
+ }
1520
+ }
1521
+ catch (err) {
1522
+ console.error(`[cloudflare:verify] failed to list CNAMEs under ${declared}: ${err instanceof Error ? err.message : String(err)}`);
1523
+ }
1524
+ }
1525
+ }
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" });
1577
+ }
1578
+ }
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
+ };
1594
+ console.error(`[cloudflare:verify-complete] ${JSON.stringify({
1595
+ brand: brand.productName,
1596
+ ...counts,
1597
+ })}`);
1598
+ 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,
1606
+ };
1607
+ }
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.
1610
+ async function listZonesViaClient(client) {
1611
+ const zones = [];
1612
+ for await (const zone of client.zones.list()) {
1613
+ zones.push({
1614
+ id: zone.id,
1615
+ name: zone.name,
1616
+ status: zone.status ?? "unknown",
1617
+ nameservers: zone.name_servers ?? [],
1618
+ account: { id: zone.account.id ?? "", name: zone.account.name ?? "" },
1619
+ });
1620
+ }
1621
+ return zones;
1622
+ }
1623
+ async function listTunnelsViaClient(client, accountId) {
1624
+ const summaries = [];
1625
+ const result = await client.zeroTrust.tunnels.cloudflared.list({
1626
+ account_id: accountId,
1627
+ is_deleted: false,
1628
+ });
1629
+ for (const t of result.result ?? []) {
1630
+ if (!t.id || !t.name)
1631
+ continue;
1632
+ summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
1633
+ }
1634
+ return summaries;
1635
+ }
1636
+ async function listCnamesUnderZone(client, zoneId) {
1637
+ const out = [];
1638
+ for await (const rec of client.dns.records.list({ zone_id: zoneId, type: "CNAME" })) {
1639
+ if (!rec.id || !rec.name)
1640
+ continue;
1641
+ out.push({ id: rec.id, name: rec.name, content: rec.content ?? "" });
1642
+ }
1643
+ return out;
1644
+ }
1645
+ export async function cfRebuildCore(opts = {}) {
1646
+ const dryRun = opts.dryRun ?? false;
1647
+ const ctx = opts.ctx ?? liveScopeContext();
1648
+ 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
+ };
1677
+ }
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 })}`);
1696
+ return {
1697
+ brand: brand.productName,
1698
+ declaredZones: ctx.declaredZones,
1699
+ dryRun,
1700
+ actions,
1701
+ halted: true,
1702
+ haltReason: msg,
1703
+ };
1704
+ }
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) {
1717
+ if (!dryRun) {
1718
+ try {
1719
+ materializeBinding("migration");
1720
+ }
1721
+ 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
+ };
1739
+ }
1740
+ }
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
+ });
1751
+ }
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")
1765
+ 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;
1822
+ }
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:
1830
+ action.result = "failed";
1831
+ action.resultDetail = `unknown artefact type ${a.type}`;
1832
+ }
1833
+ }
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
+ }
1841
+ actions.push(action);
1842
+ }
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")
1851
+ 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
+ });
1872
+ }
1873
+ }
1874
+ // Step 6: rerun verify (unless dry-run) to capture final state.
1875
+ let finalVerify;
1876
+ if (!dryRun) {
1877
+ finalVerify = await cfVerifyCore(ctx);
1878
+ }
1879
+ console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
1880
+ brand: brand.productName,
1881
+ dryRun,
1882
+ actionCount: actions.length,
1883
+ finalCounts: finalVerify?.counts ?? null,
1884
+ })}`);
1885
+ return {
1886
+ brand: brand.productName,
1887
+ declaredZones: ctx.declaredZones,
1888
+ dryRun,
1889
+ actions,
1890
+ halted: false,
1891
+ finalVerify,
1892
+ };
1893
+ }
1197
1894
  //# sourceMappingURL=cloudflared.js.map