@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
@@ -12,134 +12,80 @@ const server = new McpServer({
12
12
  version: "0.2.0",
13
13
  });
14
14
  // ===================================================================
15
- // Token management
15
+ // Authentication — cert.pem only
16
+ //
17
+ // The plugin recognises exactly one CF account identity: the one
18
+ // cert.pem was bound to via OAuth. `tunnel-login` is the only auth
19
+ // path; the binding written on success is the device's record of
20
+ // which account it currently speaks to.
16
21
  // ===================================================================
17
- server.tool("cf-set-token", "Set the Cloudflare API token. Validates the token against the Cloudflare API, discovers the account ID, and persists both to ~/{configDir}/cloudflare/api-token. Required scopes: Zone:DNS:Edit, Account:Cloudflare Tunnel:Edit, Zone:Zone:Edit.", {
18
- token: z
19
- .string()
20
- .describe("The Cloudflare API token to store"),
21
- }, async ({ token }) => {
22
- const trimmed = token.trim();
23
- if (!trimmed) {
24
- return {
25
- content: [
26
- {
27
- type: "text",
28
- text: "No token provided. Paste an existing Cloudflare API token as the token argument. If the user does not have a token, use tunnel-login instead — it handles authentication automatically.",
29
- },
30
- ],
31
- isError: true,
32
- };
33
- }
34
- try {
35
- const result = await cloudflared.validateToken(trimmed);
36
- if (!result.valid) {
37
- return {
38
- content: [
39
- {
40
- type: "text",
41
- text: `Token validation failed: ${result.error}`,
42
- },
43
- ],
44
- isError: true,
45
- };
46
- }
47
- if (!result.accountId) {
48
- return {
49
- content: [
50
- {
51
- type: "text",
52
- text: `${result.error}`,
53
- },
54
- ],
55
- isError: true,
56
- };
57
- }
58
- cloudflared.writeToken(trimmed, result.accountId);
59
- return {
60
- content: [
61
- {
62
- type: "text",
63
- text: `API token validated and saved.\n\n Account: ${result.accountName ?? result.accountId}\n Token stored: ~/{configDir}/cloudflare/api-token (mode 0600)\n\nReady for tunnel operations.`,
64
- },
65
- ],
66
- };
67
- }
68
- catch (err) {
69
- return {
70
- content: [
71
- {
72
- type: "text",
73
- text: `Token validation failed: ${err instanceof Error ? err.message : String(err)}`,
74
- },
75
- ],
76
- isError: true,
77
- };
78
- }
79
- });
80
- server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if already authenticated, reports status; (2) if cert.pem exists, derives API credentials; (3) if no cert.pem, spawns login process and returns auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete stored credentials and restart authentication (use when the current token has insufficient permissions).", {
22
+ server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if cert.pem exists and matches the recorded account binding, reports status; (2) if cert.pem exists but no binding has been recorded, materializes the binding from the cert (migration path); (3) if no cert.pem, spawns login process and returns the auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete cert.pem (both brand-specific and legacy paths) and account-binding.json before re-authenticating required when switching Cloudflare accounts.", {
81
23
  force: z
82
24
  .boolean()
83
25
  .optional()
84
- .describe("Delete stored API token and cert.pem before re-authenticating. Use when the current token lacks permissions for the needed operation (e.g. cfut_* cert-derived tokens that cannot manage zones)."),
26
+ .describe("Delete cert.pem (brand path + legacy ~/.cloudflared/cert.pem) and account-binding.json before re-authenticating. Required when switching Cloudflare accounts (e.g. after the prior account was scrubbed or the cert was rotated under a different account)."),
85
27
  }, async ({ force }) => {
86
28
  try {
87
- // Force: delete stored credentials and restart from scratch
29
+ // Force: delete cert.pem (both paths) and binding before restarting
88
30
  if (force) {
89
- console.error("[tunnel-login] force=true — deleting stored token and cert.pem before re-auth");
31
+ console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
90
32
  cloudflared.resetAuth();
91
33
  }
92
- // Phase: Already authenticated
93
- const auth = await cloudflared.validateAuth();
94
- if (auth.hasToken && auth.tokenValid) {
95
- console.error("[tunnel-login] short-circuit: hasToken=true tokenValid=true returning 'already authenticated'");
34
+ const auth = cloudflared.validateAuth();
35
+ // Phase: Already bound — cert + binding present and accountIds agree
36
+ if (auth.bound) {
37
+ console.error(`[tunnel-login] short-circuit: cert and binding agree on account ${auth.boundAccountId}`);
96
38
  return {
97
39
  content: [
98
40
  {
99
41
  type: "text",
100
- text: `Already authenticated. API token is valid.\n\nTo re-authenticate with different permissions, call tunnel-login with force=true. This deletes the stored credentials and restarts the authentication flow.`,
42
+ text: `Already authenticated. cert.pem and account binding agree on account ${auth.boundAccountId}.\n\nTo switch Cloudflare accounts, call tunnel-login with force=true. This deletes cert.pem (both paths) and the account binding, then restarts the authentication flow.`,
101
43
  },
102
44
  ],
103
45
  };
104
46
  }
105
- // Phase: cert.pem exists — derive credentials
106
- if (cloudflared.hasCert()) {
47
+ // Phase: cert.pem exists — derive credentials and either materialize
48
+ // or reconcile the binding.
49
+ if (auth.hasCert) {
107
50
  const creds = cloudflared.parseCertPem();
108
51
  if (!creds) {
109
52
  return {
110
53
  content: [
111
54
  {
112
55
  type: "text",
113
- text: `cert.pem exists but could not extract API credentials from it. The file format may have changed.\n\nFallback: ask the user to paste an existing connection key and use cf-set-token to store it.`,
56
+ text: `cert.pem exists but could not extract API credentials from it. The file format may have changed.\n\nRecovery: run tunnel-login with force=true to clear cert.pem and re-authenticate.`,
114
57
  },
115
58
  ],
116
59
  isError: true,
117
60
  };
118
61
  }
119
- // Validate the cert-derived token
120
- const validation = await cloudflared.validateToken(creds.apiToken);
121
- if (!validation.valid) {
62
+ // Migration path: cert exists, no binding yet — silently materialize
63
+ // (the operator already trusted this cert by completing the prior
64
+ // OAuth flow; we don't re-confirm).
65
+ if (!auth.hasBinding) {
66
+ const binding = cloudflared.materializeBinding("migration");
67
+ return {
68
+ content: [
69
+ {
70
+ type: "text",
71
+ text: `Account binding materialized from existing cert.pem.\n\n Account: ${binding.accountId}\n Bound at: ${binding.boundAt}\n\nReady for tunnel operations.`,
72
+ },
73
+ ],
74
+ };
75
+ }
76
+ // Drift: cert says account X, binding says account Y — refuse with
77
+ // recovery instruction (force=true will clear both).
78
+ if (auth.certAccountId !== auth.boundAccountId) {
122
79
  return {
123
80
  content: [
124
81
  {
125
82
  type: "text",
126
- text: `cert.pem credentials were extracted but the token was rejected by Cloudflare: ${validation.error}\n\nFallback: ask the user to paste an existing connection key and use cf-set-token to store it.`,
83
+ text: `cert.pem is bound to account ${auth.certAccountId} but the recorded binding is account ${auth.boundAccountId}. The cert was rotated under a different Cloudflare account since the binding was established.\n\n${cloudflared.recoveryMessage()}`,
127
84
  },
128
85
  ],
129
86
  isError: true,
130
87
  };
131
88
  }
132
- // Use the account ID from validation (more reliable) or fall back to cert
133
- const accountId = validation.accountId ?? creds.accountId;
134
- cloudflared.writeToken(creds.apiToken, accountId);
135
- return {
136
- content: [
137
- {
138
- type: "text",
139
- text: `Credentials derived from cert.pem and validated.\n\n Account: ${validation.accountName ?? accountId}\n Token stored: ~/{configDir}/cloudflare/api-token (mode 0600)\n\nReady for tunnel operations.`,
140
- },
141
- ],
142
- };
143
89
  }
144
90
  // Phase: Login process already running — wait for authorization
145
91
  const activeLogin = cloudflared.getActiveLogin();
@@ -225,19 +171,27 @@ server.tool("cf-zone-status", "List all zones (domains) on the Cloudflare accoun
225
171
  };
226
172
  }
227
173
  });
228
- server.tool("cf-add-zone", "Add a domain to the Cloudflare account via the official API. Returns the zone status and assigned nameservers. Idempotent returns existing zone info if the domain is already on the account. Requires a valid API token with Zone:Zone:Edit scope (set via cf-set-token).", {
174
+ server.tool("cf-add-zone", "Add a declared brand zone to the Cloudflare account via the official API. The domain MUST appear in this brand's `cloudflare.zones` manifest entry — the tool refuses domains outside that scope. Idempotent: returns existing zone info if already on the account. Requires a successful tunnel-login (cert.pem + account-binding.json present and matching).", {
229
175
  domain: z
230
176
  .string()
231
- .describe("The bare domain to add (e.g. 'mybusiness.com'). Must be a registrable domain, not a subdomain."),
177
+ .describe("Registrable domain to add. Must already be declared in brand.json `cloudflare.zones`."),
232
178
  }, async ({ domain }) => {
233
- if (!cloudflared.hasToken()) {
179
+ // Manifest-scope guard: the brand's declared zones are the only zones
180
+ // the plugin will create. Refusing here also covers the typo case
181
+ // (operator typed "maxy.bo" instead of "maxy.bot").
182
+ const brand = cloudflared.loadBrand();
183
+ const scope = cloudflared.matchManifestZone(domain, brand.cloudflare.zones);
184
+ if (!scope.ok || scope.matchedZone !== domain) {
185
+ const detail = {
186
+ reason: "scope-mismatch",
187
+ message: `cf-add-zone refuses ${domain} — it is not in this brand's declared zones ` +
188
+ `(${brand.cloudflare.zones.join(", ")}). Add the zone to brand.json and republish, ` +
189
+ `or use one of the declared zones.`,
190
+ fields: { requestedDomain: domain, manifestZones: brand.cloudflare.zones },
191
+ };
192
+ cloudflared.logRefuse(detail);
234
193
  return {
235
- content: [
236
- {
237
- type: "text",
238
- text: "No API token configured. Run cf-set-token first.",
239
- },
240
- ],
194
+ content: [{ type: "text", text: detail.message }],
241
195
  isError: true,
242
196
  };
243
197
  }
@@ -264,35 +218,23 @@ server.tool("cf-add-zone", "Add a domain to the Cloudflare account via the offic
264
218
  };
265
219
  }
266
220
  catch (err) {
267
- const msg = err instanceof Error ? err.message : String(err);
268
- console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
269
- // Detect scope/permission errors and guide the user
270
- const lower = msg.toLowerCase();
271
- if (lower.includes("403") || lower.includes("permission") || lower.includes("forbidden")) {
272
- const tokenPrefix = cloudflared.getTokenPrefix();
273
- const isCertDerived = tokenPrefix?.startsWith("cfut_") ?? false;
274
- console.error(`[cf-add-zone] permission denied for ${domain} — token prefix=${tokenPrefix ?? "unknown"} (${isCertDerived ? "cert-derived, limited scope" : "API token"})`);
275
- const scopeExplanation = isCertDerived
276
- ? "The current token was derived from cloudflared's cert.pem (cfut_* prefix). Cert-derived tokens carry Zone:Zone:Read but NOT Zone:Zone:Edit — they cannot create zones or manage DNS. This is a Cloudflare limitation, not a configuration error."
277
- : "The current API token does not have the Zone:Zone:Edit scope required to add domains.";
278
- const recoveryNote = isCertDerived
279
- ? "\n\nDo NOT re-run tunnel-login — it produces the same cert-derived token with the same limited scope."
280
- : "\n\nAlternatively, call tunnel-login with force=true to re-authenticate and derive new credentials.";
221
+ // Refusal-shaped errors thrown by getClient() carry structured detail
222
+ // surface them as agent-facing refusals with the same single recovery path.
223
+ if (err instanceof cloudflared.CloudflareRefusalError) {
281
224
  return {
282
- content: [
283
- {
284
- type: "text",
285
- text: `Zone creation failed — insufficient permissions.\n\n${scopeExplanation}\n\nRecovery options:\n 1. Use cf-set-token with a user-created API token that has these scopes: Zone:Zone:Edit, Zone:DNS:Edit, Account:Cloudflare Tunnel:Edit. The user can create one at dash.cloudflare.com → My Profile → API Tokens.\n 2. Add the domain directly through the Cloudflare dashboard in the VNC browser.${recoveryNote}`,
286
- },
287
- ],
225
+ content: [{ type: "text", text: err.refusal.message }],
288
226
  isError: true,
289
227
  };
290
228
  }
229
+ const msg = err instanceof Error ? err.message : String(err);
230
+ console.error(`[cf-add-zone] failed for ${domain}: ${msg}`);
291
231
  return {
292
232
  content: [
293
233
  {
294
234
  type: "text",
295
- text: `Zone creation failed: ${msg}`,
235
+ text: `Zone creation failed: ${msg}\n\n` +
236
+ `If this looks like a permissions error, the cert.pem account may not own ` +
237
+ `this brand's zones. ${cloudflared.recoveryMessage()}`,
296
238
  },
297
239
  ],
298
240
  isError: true,
@@ -412,17 +354,6 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
412
354
  .optional()
413
355
  .describe("Human-readable tunnel name (default: derived from domain)"),
414
356
  }, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
415
- if (!cloudflared.hasCert()) {
416
- return {
417
- content: [
418
- {
419
- type: "text",
420
- text: "Not authenticated. Run tunnel-login first.",
421
- },
422
- ],
423
- isError: true,
424
- };
425
- }
426
357
  // Validate: admin and public subdomains must differ
427
358
  if (publicSubdomain && adminSubdomain === publicSubdomain) {
428
359
  return {
@@ -443,28 +374,27 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
443
374
  const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
444
375
  const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
445
376
  console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
446
- // Step 0: Zone-account pre-flight. cloudflared CLI routes DNS against
447
- // cert.pem's account and silently falls back to a sibling zone if the
448
- // target zone is absent. Two-layer check by design: (a) this early
449
- // check fails fast before any Argo tunnel resource is created; (b) the
450
- // authoritative choke-point check inside routeDnsCli guards all three
451
- // callers including tunnel-add-hostname's CLI fallback. The duplicate
452
- // listZones() call is intentional — the inner check is the guarantee,
453
- // this outer check is the UX optimisation.
454
- const accountZones = await cloudflared.listZones();
377
+ // Manifest-scope pre-flight: refuse before creating any tunnel resource
378
+ // when any requested hostname falls outside brand.cloudflare.zones.
379
+ // The authoritative scope check ALSO runs inside routeDnsCli (defence
380
+ // in depth), but failing fast here avoids creating an orphan tunnel.
381
+ // getClient() inside routeDnsCli additionally enforces auth pre-conditions
382
+ // (cert + binding + accountId match).
383
+ const brand = cloudflared.loadBrand();
455
384
  for (const h of hostnames) {
456
- const match = cloudflared.verifyZoneOnAccount(h, accountZones);
457
- if (!match.zone) {
458
- const availableList = match.availableZones.length > 0
459
- ? match.availableZones.join(", ")
460
- : "none";
385
+ const scope = cloudflared.matchManifestZone(h, brand.cloudflare.zones);
386
+ if (!scope.ok) {
387
+ const detail = {
388
+ reason: "scope-mismatch",
389
+ message: `Cannot create tunnel for ${h} — its registrable parent is not in this brand's ` +
390
+ `declared Cloudflare zones (${brand.cloudflare.zones.join(", ")}). ` +
391
+ `Either pick a hostname inside the declared zones, or add the parent zone to ` +
392
+ `brand.json and republish the brand package.`,
393
+ fields: { requestedHostname: h, manifestZones: brand.cloudflare.zones },
394
+ };
395
+ cloudflared.logRefuse(detail);
461
396
  return {
462
- content: [
463
- {
464
- type: "text",
465
- text: `Cannot create tunnel for ${h} — the zone that owns this hostname is not on the Cloudflare account bound to this device.\n\n Best guess at missing zone: "${match.missingParent}" (derived from the last two labels of ${h}; for multi-label TLDs such as .co.uk the real zone may be longer)\n Available zones on this account: ${availableList}\n\nRecovery: run tunnel-login while signed into the Cloudflare account that owns the zone for ${h}. 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.`,
466
- },
467
- ],
397
+ content: [{ type: "text", text: detail.message }],
468
398
  isError: true,
469
399
  };
470
400
  }
@@ -540,6 +470,12 @@ server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the s
540
470
  };
541
471
  }
542
472
  catch (err) {
473
+ if (err instanceof cloudflared.CloudflareRefusalError) {
474
+ return {
475
+ content: [{ type: "text", text: err.refusal.message }],
476
+ isError: true,
477
+ };
478
+ }
543
479
  return {
544
480
  content: [
545
481
  {
@@ -651,13 +587,17 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
651
587
  isError: true,
652
588
  };
653
589
  }
654
- // GATE 3: cert.pem must exist
655
- if (!cloudflared.hasCert()) {
590
+ // GATE 3: auth pre-conditions — cert + binding + accountId match.
591
+ // We use validateAuth (non-throwing) so we can produce a uniform
592
+ // refusal message regardless of which condition failed.
593
+ const enableAuth = cloudflared.validateAuth();
594
+ if (!enableAuth.bound) {
595
+ const missing = !enableAuth.hasCert ? "cert.pem" : !enableAuth.hasBinding ? "account binding" : "matching cert/binding";
656
596
  return {
657
597
  content: [
658
598
  {
659
599
  type: "text",
660
- text: `REFUSED: Not authenticated. Run tunnel-login first.`,
600
+ text: `REFUSED: Cannot start tunnel — ${missing} is missing or mismatched. ${cloudflared.recoveryMessage()}`,
661
601
  },
662
602
  ],
663
603
  isError: true,
@@ -757,6 +697,12 @@ server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel mu
757
697
  };
758
698
  }
759
699
  catch (err) {
700
+ if (err instanceof cloudflared.CloudflareRefusalError) {
701
+ return {
702
+ content: [{ type: "text", text: err.refusal.message }],
703
+ isError: true,
704
+ };
705
+ }
760
706
  return {
761
707
  content: [
762
708
  {
@@ -800,45 +746,37 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
800
746
  .string()
801
747
  .describe("The tunnel UUID. Get this from tunnel-status."),
802
748
  }, async ({ hostname, tunnelId }) => {
803
- if (!cloudflared.hasToken()) {
749
+ // Manifest-scope guard: alias hostnames are bound by the same brand
750
+ // declaration as primary hostnames. routeDnsCli enforces this at the
751
+ // CLI boundary too; checking here gives the operator a fast refusal
752
+ // before any ingress mutation.
753
+ const aliasBrand = cloudflared.loadBrand();
754
+ const aliasScope = cloudflared.matchManifestZone(hostname, aliasBrand.cloudflare.zones);
755
+ if (!aliasScope.ok) {
756
+ const detail = {
757
+ reason: "scope-mismatch",
758
+ message: `Cannot add ${hostname} as an alias — its registrable parent is not in this brand's ` +
759
+ `declared Cloudflare zones (${aliasBrand.cloudflare.zones.join(", ")}). ` +
760
+ `Add the parent zone to brand.json and republish to make this alias routable.`,
761
+ fields: { requestedHostname: hostname, manifestZones: aliasBrand.cloudflare.zones },
762
+ };
763
+ cloudflared.logRefuse(detail);
804
764
  return {
805
- content: [
806
- {
807
- type: "text",
808
- text: "No API token configured. Run cf-set-token first.",
809
- },
810
- ],
765
+ content: [{ type: "text", text: detail.message }],
811
766
  isError: true,
812
767
  };
813
768
  }
814
769
  try {
815
- // Step 1: Verify the alias domain's zone exists and is accessible
770
+ // Step 1: Resolve zoneId. getClient() inside getZoneId enforces
771
+ // cert + binding + accountId-match — refusal-shaped errors propagate
772
+ // through this catch block.
816
773
  const zoneId = await cloudflared.getZoneId(hostname);
817
774
  // Step 2: Add hostname to tunnel ingress (read-then-append)
818
775
  const ingress = await cloudflared.addHostnameToIngress(tunnelId, hostname);
819
- // Step 3: Create CNAME in the alias domain's zone — SDK first, CLI fallback
820
- let dns;
821
- try {
822
- dns = await cloudflared.createDnsRecord(zoneId, hostname, tunnelId);
823
- }
824
- catch (dnsErr) {
825
- const dnsMsg = dnsErr instanceof Error ? dnsErr.message : String(dnsErr);
826
- const isPermissionError = dnsMsg.toLowerCase().includes("403") ||
827
- dnsMsg.toLowerCase().includes("permission") ||
828
- dnsMsg.toLowerCase().includes("forbidden");
829
- if (isPermissionError && cloudflared.hasCert()) {
830
- console.error(`[tunnel-add-hostname] SDK DNS failed (${dnsMsg}), falling back to cloudflared CLI`);
831
- const cliResult = await cloudflared.routeDnsCli(tunnelId, hostname);
832
- dns = {
833
- created: cliResult.created,
834
- existing: !cliResult.created,
835
- updated: false,
836
- };
837
- }
838
- else {
839
- throw dnsErr;
840
- }
841
- }
776
+ // Step 3: Create CNAME via SDK. The cert-bound credential carries
777
+ // Zone:DNS:Edit for zones owned by the bound account. On any error,
778
+ // surface as failure — no fallback path remains.
779
+ const dns = await cloudflared.createDnsRecord(zoneId, hostname, tunnelId);
842
780
  // Step 4: Persist alias domain for web server recognition
843
781
  cloudflared.saveAliasDomain(hostname);
844
782
  // Build response
@@ -860,6 +798,12 @@ server.tool("tunnel-add-hostname", "Add an alias hostname to an existing Cloudfl
860
798
  };
861
799
  }
862
800
  catch (err) {
801
+ if (err instanceof cloudflared.CloudflareRefusalError) {
802
+ return {
803
+ content: [{ type: "text", text: err.refusal.message }],
804
+ isError: true,
805
+ };
806
+ }
863
807
  return {
864
808
  content: [
865
809
  {
@@ -963,6 +907,59 @@ server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslo
963
907
  };
964
908
  }
965
909
  });
910
+ // ===================================================================
911
+ // cf-verify / cf-rebuild — declarative audit + reconstruction
912
+ // ===================================================================
913
+ server.tool("cf-verify", "Audit every Cloudflare artefact relevant to this brand and return a structured report tagging each as IN-SCOPE, OUT-OF-SCOPE, or MISSING against `brand.cloudflare.zones` and the recorded account binding. Non-mutating. Runs in all states including fresh-install-before-login (reports everything as MISSING with no error). Use this to diagnose any Cloudflare misbehaviour before reaching for `cf-rebuild`.", {}, async () => {
914
+ try {
915
+ const report = await cloudflared.cfVerifyCore();
916
+ return {
917
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
918
+ };
919
+ }
920
+ catch (err) {
921
+ return {
922
+ content: [
923
+ {
924
+ type: "text",
925
+ text: `cf-verify failed: ${err instanceof Error ? err.message : String(err)}`,
926
+ },
927
+ ],
928
+ isError: true,
929
+ };
930
+ }
931
+ });
932
+ server.tool("cf-rebuild", "Discard every OUT-OF-SCOPE Cloudflare artefact and deterministically reconstruct the declared state. Idempotent — running twice on a clean state is a no-op. Refuses to delete cert.pem when bound to the wrong account; instead halts with an actionable instruction to run tunnel-login force=true. Pass dryRun=true to plan without mutating.", {
933
+ dryRun: z
934
+ .boolean()
935
+ .optional()
936
+ .describe("If true, report what cf-rebuild WOULD do without performing any mutations. Use this to preview a rebuild before committing."),
937
+ }, async ({ dryRun }) => {
938
+ try {
939
+ const report = await cloudflared.cfRebuildCore({ dryRun: dryRun ?? false });
940
+ return {
941
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
942
+ isError: report.halted,
943
+ };
944
+ }
945
+ catch (err) {
946
+ if (err instanceof cloudflared.CloudflareRefusalError) {
947
+ return {
948
+ content: [{ type: "text", text: err.refusal.message }],
949
+ isError: true,
950
+ };
951
+ }
952
+ return {
953
+ content: [
954
+ {
955
+ type: "text",
956
+ text: `cf-rebuild failed: ${err instanceof Error ? err.message : String(err)}`,
957
+ },
958
+ ],
959
+ isError: true,
960
+ };
961
+ }
962
+ });
966
963
  const LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
967
964
  function sanitizedDomain(domain) {
968
965
  return domain.replace(/\./g, "-");
@@ -1015,32 +1012,32 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
1015
1012
  }
1016
1013
  // ── Step 2: Authentication ──────────────────────────────────────
1017
1014
  log("checking authentication");
1018
- const auth = await cloudflared.validateAuth();
1019
- if (auth.hasToken && auth.tokenValid) {
1020
- log("authenticated — token valid");
1015
+ const auth = cloudflared.validateAuth();
1016
+ if (auth.bound) {
1017
+ log(`authenticated — cert and binding agree on account ${auth.boundAccountId}`);
1021
1018
  }
1022
- else if (cloudflared.hasCert()) {
1023
- // cert.pem exists — derive credentials
1024
- log("cert.pem found deriving credentials");
1019
+ else if (auth.hasCert) {
1020
+ // cert.pem exists — materialize binding (migration path) or surface
1021
+ // drift if the binding disagrees with the cert.
1025
1022
  const creds = cloudflared.parseCertPem();
1026
1023
  if (!creds) {
1027
1024
  log("cert.pem exists but could not extract credentials");
1028
1025
  return result({
1029
1026
  status: "error",
1030
- message: "Found a Cloudflare certificate but could not extract credentials from it. Try signing in again — I'll clear the old certificate and start fresh.",
1027
+ message: "Found a Cloudflare certificate but could not extract credentials from it. Sign in again — I'll clear the old certificate and start fresh.",
1031
1028
  });
1032
1029
  }
1033
- const validation = await cloudflared.validateToken(creds.apiToken);
1034
- if (!validation.valid) {
1035
- log(`cert-derived token rejected: ${validation.error}`);
1030
+ if (!auth.hasBinding) {
1031
+ const binding = cloudflared.materializeBinding("migration");
1032
+ log(`materialized binding from existing cert account ${binding.accountId}`);
1033
+ }
1034
+ else if (auth.certAccountId !== auth.boundAccountId) {
1035
+ log(`account drift: cert=${auth.certAccountId} binding=${auth.boundAccountId}`);
1036
1036
  return result({
1037
1037
  status: "error",
1038
- message: `The credentials from your Cloudflare certificate were rejected: ${validation.error}. Try signing in again.`,
1038
+ message: `Your Cloudflare certificate is bound to a different account than this device's recorded binding. ${cloudflared.recoveryMessage()}`,
1039
1039
  });
1040
1040
  }
1041
- const accountId = validation.accountId ?? creds.accountId;
1042
- cloudflared.writeToken(creds.apiToken, accountId);
1043
- log(`credentials derived and stored — account: ${validation.accountName ?? accountId}`);
1044
1041
  }
1045
1042
  else {
1046
1043
  // No cert, no token — need login
@@ -1310,6 +1307,30 @@ server.tool("cloudflare-setup", "Deterministic, UI-driven state machine for Clou
1310
1307
  const adminHostname = `${resolvedAdminLabel}.${domain}`;
1311
1308
  const publicHostname = resolvedPublicLabel ? `${resolvedPublicLabel}.${domain}` : null;
1312
1309
  const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
1310
+ // Manifest-scope pre-flight: domain came from live account state
1311
+ // (zone selection, auto-only-active zone, or persisted state). The
1312
+ // account may hold zones outside this brand's declared scope; refuse
1313
+ // before creating any tunnel/state so we don't write orphan local
1314
+ // state. routeDnsCli will refuse later anyway, but only after the
1315
+ // tunnel is created and config.yml is written.
1316
+ const setupBrand = cloudflared.loadBrand();
1317
+ for (const h of hostnames) {
1318
+ const scopeCheck = cloudflared.matchManifestZone(h, setupBrand.cloudflare.zones);
1319
+ if (!scopeCheck.ok) {
1320
+ const detail = {
1321
+ reason: "scope-mismatch",
1322
+ message: `Cannot set up ${h} — its registrable parent is not in this brand's declared ` +
1323
+ `Cloudflare zones (${setupBrand.cloudflare.zones.join(", ")}). ` +
1324
+ `The selected domain ${domain} is on the bound Cloudflare account but outside ` +
1325
+ `the brand's manifest. Either pick a domain inside the declared scope, or add ` +
1326
+ `${domain} to brand.json and republish.`,
1327
+ fields: { requestedHostname: h, requestedDomain: domain, manifestZones: setupBrand.cloudflare.zones },
1328
+ };
1329
+ cloudflared.logRefuse(detail);
1330
+ log(`scope-mismatch: hostname=${h} not in declared zones`);
1331
+ return result({ status: "error", message: detail.message });
1332
+ }
1333
+ }
1313
1334
  // ── Step 5: Resolve or create tunnel (idempotent on re-entry) ───
1314
1335
  let tunnelId = existingState?.tunnelId ?? null;
1315
1336
  let tunnelName = existingState?.tunnelName ?? null;