@rubytech/create-realagent 1.0.614 → 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 (46) hide show
  1. package/dist/index.js +42 -8
  2. package/package.json +1 -1
  3. package/payload/platform/config/brand.json +4 -0
  4. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +23 -13
  5. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/mcp-stderr-tee/dist/index.js +86 -89
  7. package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
  8. package/payload/platform/lib/mcp-stderr-tee/src/index.ts +86 -101
  9. package/payload/platform/plugins/admin/mcp/dist/index.js +33 -2
  10. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  11. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts.map +1 -1
  12. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +2 -0
  13. package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js.map +1 -1
  14. package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +22 -8
  15. package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -4
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +196 -0
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +81 -0
  18. package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +65 -0
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +70 -0
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +124 -0
  21. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +232 -183
  22. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +181 -30
  24. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  25. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +938 -154
  26. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  27. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  28. package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +10 -0
  29. package/payload/platform/plugins/cloudflare/references/setup-guide.md +32 -27
  30. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +25 -3
  31. package/payload/platform/plugins/docs/PLUGIN.md +2 -0
  32. package/payload/platform/plugins/docs/references/cloudflare.md +68 -0
  33. package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
  34. package/payload/platform/plugins/docs/references/troubleshooting.md +2 -0
  35. package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts +9 -2
  36. package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts.map +1 -1
  37. package/payload/platform/plugins/email/mcp/dist/lib/providers.js +545 -92
  38. package/payload/platform/plugins/email/mcp/dist/lib/providers.js.map +1 -1
  39. package/payload/platform/scripts/logs-read.sh +114 -54
  40. package/payload/platform/templates/agents/admin/IDENTITY.md +6 -0
  41. package/payload/platform/templates/agents/public/IDENTITY.md +1 -0
  42. package/payload/platform/templates/specialists/agents/content-producer.md +4 -0
  43. package/payload/platform/templates/specialists/agents/personal-assistant.md +16 -8
  44. package/payload/platform/templates/specialists/agents/project-manager.md +4 -0
  45. package/payload/platform/templates/specialists/agents/research-assistant.md +4 -0
  46. package/payload/server/server.js +714 -125
@@ -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,74 +96,130 @@ 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();
101
- 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;
107
- }
108
122
  /**
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.
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.
112
129
  */
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;
130
+ export function writeAccountBinding(accountId) {
131
+ const dir = bindingDir();
132
+ mkdirSync(dir, { recursive: true });
133
+ const binding = { accountId, boundAt: new Date().toISOString() };
134
+ writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
135
+ return binding;
120
136
  }
121
137
  /**
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.
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.
126
142
  */
127
- export function resetAuth() {
128
- const result = { deletedToken: false, deletedCert: false };
129
- const tf = tokenFile();
143
+ function resetAccountBinding() {
144
+ const path = bindingFile();
130
145
  try {
131
- unlinkSync(tf);
132
- result.deletedToken = true;
133
- console.error(`[tunnel-login] deleted: ${tf}`);
146
+ unlinkSync(path);
147
+ console.error(`[cloudflare:binding] reset ${path}`);
148
+ return true;
134
149
  }
135
150
  catch (err) {
136
151
  const code = err.code;
137
- if (code === "ENOENT") {
138
- // File already gone — no-op
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();
139
171
  }
140
- else {
141
- console.error(`[tunnel-login] failed to delete token file ${tf}: ${err}`);
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)}`);
188
+ }
189
+ /**
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.
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
+ // ---------------------------------------------------------------------------
210
+ export function resetAuth() {
211
+ const result = { deletedCert: false, deletedBinding: false };
212
+ const loginState = readLoginState();
213
+ if (loginState?.pid && isProcessAlive(loginState.pid)) {
214
+ try {
215
+ process.kill(loginState.pid, "SIGTERM");
216
+ console.error(`[tunnel-login] killed stale login process PID ${loginState.pid}`);
217
+ }
218
+ catch {
219
+ // Process may have exited between check and kill
142
220
  }
143
221
  }
222
+ clearLoginState();
144
223
  const cp = certPath();
145
224
  try {
146
225
  unlinkSync(cp);
@@ -156,19 +235,21 @@ export function resetAuth() {
156
235
  console.error(`[tunnel-login] failed to delete cert.pem ${cp}: ${err}`);
157
236
  }
158
237
  }
159
- // Kill any running login process before clearing state — prevents a stale
160
- // cloudflared from writing a new cert.pem after we just deleted it.
161
- const loginState = readLoginState();
162
- if (loginState?.pid && isProcessAlive(loginState.pid)) {
163
- try {
164
- process.kill(loginState.pid, "SIGTERM");
165
- console.error(`[tunnel-login] killed stale login process PID ${loginState.pid}`);
238
+ const legacyCp = legacyCertPath();
239
+ try {
240
+ unlinkSync(legacyCp);
241
+ console.error(`[tunnel-login] deleted legacy cert.pem: ${legacyCp}`);
242
+ }
243
+ catch (err) {
244
+ const code = err.code;
245
+ if (code === "ENOENT") {
246
+ console.error(`[tunnel-login] legacy cert.pem absent: ${legacyCp}`);
166
247
  }
167
- catch {
168
- // Process may have exited between check and kill
248
+ else {
249
+ console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
169
250
  }
170
251
  }
171
- clearLoginState();
252
+ result.deletedBinding = resetAccountBinding();
172
253
  return result;
173
254
  }
174
255
  // ---------------------------------------------------------------------------
@@ -180,22 +261,43 @@ export function resetAuth() {
180
261
  function certPath() {
181
262
  return join(homedir(), loadBrand().configDir, "cloudflared/cert.pem");
182
263
  }
264
+ // cloudflared tunnel login always writes here regardless of --origincert.
265
+ // Same literal lives in the installer's cloudflared migration block.
266
+ function legacyCertPath() {
267
+ return join(homedir(), ".cloudflared", "cert.pem");
268
+ }
183
269
  /**
184
270
  * Find cert.pem, checking the brand-specific path first, then cloudflared's
185
- * default location (~/.cloudflared/cert.pem). cloudflared tunnel login ALWAYS
186
- * writes to ~/.cloudflared/cert.pem regardless of --origincert, so we must
187
- * check there as a fallback and copy to the brand-specific path when found.
271
+ * default location. cloudflared tunnel login ALWAYS writes to
272
+ * ~/.cloudflared/cert.pem regardless of --origincert, so we must check
273
+ * there as a fallback.
274
+ *
275
+ * Move-on-read: when the cert is found at the legacy path, copy it to the
276
+ * brand path AND unlink the legacy file. Without the unlink, the legacy
277
+ * file would survive `tunnel-login force=true` (which only deletes brand
278
+ * paths) and silently re-import on the next read — defeating the recovery
279
+ * path operators rely on after switching Cloudflare accounts.
280
+ *
281
+ * Failure to unlink the legacy file after a successful copy is non-fatal:
282
+ * the read still succeeds against the brand path, and the warning surfaces
283
+ * the residual stale file.
188
284
  */
189
285
  function findCert() {
190
286
  const brandPath = certPath();
191
287
  if (existsSync(brandPath))
192
288
  return brandPath;
193
- const defaultPath = join(homedir(), ".cloudflared", "cert.pem");
289
+ const defaultPath = legacyCertPath();
194
290
  if (existsSync(defaultPath)) {
195
291
  const dir = join(homedir(), loadBrand().configDir, "cloudflared");
196
292
  mkdirSync(dir, { recursive: true });
197
293
  copyFileSync(defaultPath, brandPath);
198
- console.error(`[tunnel-login] cert.pem found at ${defaultPath}, copied to ${brandPath}`);
294
+ try {
295
+ unlinkSync(defaultPath);
296
+ console.error(`[tunnel-login] cert.pem migrated from ${defaultPath} to ${brandPath}`);
297
+ }
298
+ catch (err) {
299
+ console.error(`[tunnel-login] WARNING: cert.pem migrated to ${brandPath} but legacy ${defaultPath} could not be removed: ${err}`);
300
+ }
199
301
  return brandPath;
200
302
  }
201
303
  return null;
@@ -237,80 +339,117 @@ export function parseCertPem() {
237
339
  }
238
340
  // ---------------------------------------------------------------------------
239
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.
240
349
  // ---------------------------------------------------------------------------
241
- export function getClient() {
242
- const token = readToken();
243
- if (!token) {
244
- 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;
245
357
  }
246
- return {
247
- client: new Cloudflare({ apiToken: token.apiToken }),
248
- accountId: token.accountId,
249
- };
250
358
  }
251
- export async function validateToken(apiToken) {
252
- const client = new Cloudflare({ apiToken });
253
- try {
254
- // Try zones first most accounts have at least one zone
255
- const zones = await client.zones.list({ per_page: 1 });
256
- const zone = zones.result?.[0];
257
- if (zone) {
258
- return {
259
- valid: true,
260
- accountId: zone.account.id ?? null,
261
- accountName: zone.account.name ?? null,
262
- };
263
- }
264
- // No zones — discover account ID via accounts.list() (works for new accounts)
265
- const accounts = await client.accounts.list({ per_page: 1 });
266
- const account = accounts.result?.[0];
267
- if (account) {
268
- return {
269
- valid: true,
270
- accountId: account.id ?? null,
271
- accountName: account.name ?? null,
272
- };
273
- }
274
- // Token is valid but no zones and no accounts discoverable
275
- return {
276
- valid: true,
277
- accountId: null,
278
- accountName: null,
279
- 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 },
280
378
  };
281
- }
282
- catch (err) {
283
- const msg = err instanceof Error ? err.message : String(err);
284
- if (msg.includes("Authentication")) {
285
- return {
286
- valid: false,
287
- accountId: null,
288
- accountName: null,
289
- error: "Token rejected by Cloudflare check that it is correct and has not expired.",
290
- };
291
- }
292
- return {
293
- valid: false,
294
- accountId: null,
295
- accountName: null,
296
- 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
+ },
297
405
  };
406
+ logRefuse(detail);
407
+ throw new CloudflareRefusalError(detail);
298
408
  }
409
+ return {
410
+ client: new Cloudflare({ apiToken: creds.apiToken }),
411
+ accountId: binding.accountId,
412
+ };
299
413
  }
300
- export async function validateAuth() {
301
- const token = readToken();
302
- if (!token) {
303
- return { hasToken: false, tokenValid: false };
304
- }
305
- try {
306
- const client = new Cloudflare({ apiToken: token.apiToken });
307
- await client.zones.list({ per_page: 1 });
308
- return { hasToken: true, tokenValid: true };
309
- }
310
- catch (err) {
311
- const msg = err instanceof Error ? err.message : String(err);
312
- return { hasToken: true, tokenValid: false, error: msg };
313
- }
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;
314
453
  }
315
454
  export async function listZones() {
316
455
  const { client } = getClient();
@@ -329,6 +468,17 @@ export async function listZones() {
329
468
  }
330
469
  return zones;
331
470
  }
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
+ }
332
482
  export async function getZoneId(domain) {
333
483
  const { client } = getClient();
334
484
  const zones = await client.zones.list({ name: domain });
@@ -342,9 +492,6 @@ export async function getZoneId(domain) {
342
492
  }
343
493
  export async function createZone(domain) {
344
494
  const { client, accountId } = getClient();
345
- if (!accountId) {
346
- throw new Error("No account ID available. Re-run cf-set-token — the token must be re-validated to discover the account ID.");
347
- }
348
495
  // Idempotent — check if zone already exists on this account
349
496
  const existing = await client.zones.list({ name: domain });
350
497
  const match = existing.result?.[0];
@@ -656,11 +803,11 @@ export async function createDnsRecord(zoneId, subdomain, tunnelId) {
656
803
  return { created: true, existing: false, updated: false };
657
804
  }
658
805
  // ---------------------------------------------------------------------------
659
- // CLI-based tunnel operations — fallback for cert-derived tokens (cfut_*)
806
+ // CLI-based tunnel operations
660
807
  //
661
- // cert-derived tokens may lack SDK-level permissions for tunnel CRUD and
662
- // DNS management. The cloudflared CLI uses cert.pem directly, which carries
663
- // 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.
664
811
  // ---------------------------------------------------------------------------
665
812
  export function createTunnelCli(name) {
666
813
  const bin = findBinary();
@@ -755,35 +902,155 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
755
902
  return configPath;
756
903
  }
757
904
  // ---------------------------------------------------------------------------
758
- // 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:
759
913
  //
760
- // cert-derived tokens lack Zone:DNS:Edit, so the SDK's createDnsRecord()
761
- // fails. `cloudflared tunnel route dns` uses the cert.pem directly, which
762
- // carries Argo Tunnel permissions sufficient for creating CNAME records
763
- // 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.
922
+ //
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.
764
926
  // ---------------------------------------------------------------------------
765
- export function routeDnsCli(tunnelId, hostname) {
927
+ export async function routeDnsCli(tunnelId, hostname) {
766
928
  const bin = findBinary();
767
929
  if (!bin)
768
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
+ }
769
951
  const cert = findCert();
770
952
  if (!cert)
771
- throw new Error("No cert.pem found — run tunnel-login first");
953
+ throw new Error("No cert.pem found — getClient() should have refused first");
954
+ let output;
772
955
  try {
773
- const output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
774
- console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite)`);
775
- return { created: true, output: output.trim() };
956
+ output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
776
957
  }
777
958
  catch (err) {
778
959
  const msg = err instanceof Error ? err.stderr ?? err.message : String(err);
779
- // With --overwrite-dns this path should not trigger for CNAME conflicts.
780
- // If it fires, log the full error so the cause is diagnosable.
781
960
  if (typeof msg === "string" && msg.includes("already exists")) {
782
961
  console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
783
- return { created: false, output: msg };
962
+ return { created: false, output: msg, fqdn: hostname, zone: scope.matchedZone };
784
963
  }
785
964
  throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
786
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 };
787
1054
  }
788
1055
  // ---------------------------------------------------------------------------
789
1056
  // tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
@@ -1016,8 +1283,8 @@ export function getPersistedHostnames() {
1016
1283
  }
1017
1284
  return [];
1018
1285
  }
1019
- export async function getStatus(domain) {
1020
- const auth = await validateAuth();
1286
+ export function getStatus(domain) {
1287
+ const auth = validateAuth();
1021
1288
  const state = readState();
1022
1289
  const running = state !== null && isProcessAlive(state.pid);
1023
1290
  const effectiveDomain = domain ?? state?.domain ?? null;
@@ -1025,10 +1292,11 @@ export async function getStatus(domain) {
1025
1292
  return {
1026
1293
  installed: isInstalled(),
1027
1294
  version: version(),
1028
- hasCert: hasCert(),
1029
- hasToken: auth.hasToken,
1030
- tokenValid: auth.tokenValid,
1031
- authError: auth.error ?? null,
1295
+ hasCert: auth.hasCert,
1296
+ hasBinding: auth.hasBinding,
1297
+ bound: auth.bound,
1298
+ certAccountId: auth.certAccountId,
1299
+ boundAccountId: auth.boundAccountId,
1032
1300
  running,
1033
1301
  pid: running ? state.pid : null,
1034
1302
  tunnelId: state?.tunnelId ?? null,
@@ -1107,4 +1375,520 @@ export function stopTunnel() {
1107
1375
  // Preserve tunnel identity, clear process lifecycle
1108
1376
  writeState({ ...state, pid: null, startedAt: null });
1109
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
+ }
1110
1894
  //# sourceMappingURL=cloudflared.js.map