@rubytech/create-realagent 1.0.615 → 1.0.617

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +23 -13
  3. package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/mcp-stderr-tee/dist/index.js +86 -89
  5. package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
  6. package/payload/platform/lib/mcp-stderr-tee/src/index.ts +86 -101
  7. package/payload/platform/package-lock.json +1547 -1
  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 +195 -0
  13. package/payload/platform/plugins/cloudflare/mcp/dist/index.js +160 -214
  14. package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
  15. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +203 -42
  16. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
  17. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +623 -195
  18. package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
  19. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  20. package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +10 -0
  21. package/payload/platform/plugins/cloudflare/references/setup-guide.md +26 -30
  22. package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +28 -4
  23. package/payload/platform/plugins/docs/PLUGIN.md +2 -0
  24. package/payload/platform/plugins/docs/references/cloudflare.md +51 -0
  25. package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
  26. package/payload/platform/scripts/logs-read.sh +114 -54
  27. package/payload/platform/templates/specialists/agents/personal-assistant.md +12 -8
  28. package/payload/server/server.js +387 -70
@@ -23,6 +23,13 @@ export function loadBrand() {
23
23
  };
24
24
  return cachedBrand;
25
25
  }
26
+ /**
27
+ * Test-only: reset the cached brand so a subsequent loadBrand() re-reads
28
+ * the manifest. Production code never calls this.
29
+ */
30
+ export function _resetBrandCache() {
31
+ cachedBrand = null;
32
+ }
26
33
  // ---------------------------------------------------------------------------
27
34
  // Binary detection (unchanged — cloudflared binary still needed for daemon)
28
35
  // ---------------------------------------------------------------------------
@@ -73,69 +80,118 @@ export function version() {
73
80
  return null;
74
81
  }
75
82
  }
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() {
83
+ function bindingDir() {
83
84
  return join(homedir(), loadBrand().configDir, "cloudflare");
84
85
  }
85
- function tokenFile() {
86
- return join(tokenDir(), "api-token");
86
+ function bindingFile() {
87
+ return join(bindingDir(), "account-binding.json");
87
88
  }
88
- export function readToken() {
89
+ export function readAccountBinding() {
89
90
  try {
90
- const path = tokenFile();
91
+ const path = bindingFile();
91
92
  if (!existsSync(path))
92
93
  return null;
93
- return JSON.parse(readFileSync(path, "utf-8"));
94
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
95
+ if (typeof parsed?.accountId !== "string" || typeof parsed?.boundAt !== "string") {
96
+ console.error(`[cloudflare:binding] malformed account-binding.json at ${path} — treating as absent`);
97
+ return null;
98
+ }
99
+ return { accountId: parsed.accountId, boundAt: parsed.boundAt };
94
100
  }
95
- catch {
101
+ catch (err) {
102
+ console.error(`[cloudflare:binding] failed to read ${bindingFile()}: ${err}`);
96
103
  return null;
97
104
  }
98
105
  }
99
- export function writeToken(apiToken, accountId) {
100
- const dir = tokenDir();
106
+ /**
107
+ * @internal — production code MUST go through `materializeBinding` so the
108
+ * source-of-truth lifecycle (fresh-login vs migration) is preserved and
109
+ * logged. This function is exported only so unit tests can pre-seed
110
+ * bindings without re-implementing the file format. A new tool handler
111
+ * that calls `writeAccountBinding` directly is a code-review block —
112
+ * silent overwrites mask account drift, defeating the four-step guard.
113
+ */
114
+ export function writeAccountBinding(accountId) {
115
+ const dir = bindingDir();
101
116
  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;
117
+ const binding = { accountId, boundAt: new Date().toISOString() };
118
+ writeFileSync(bindingFile(), JSON.stringify(binding, null, 2), { mode: 0o600 });
119
+ return binding;
107
120
  }
108
121
  /**
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.
122
+ * Reset the binding by unlinking the file. Only force-reset paths
123
+ * (tunnel-login force=true, cf-rebuild after wrong-account detection)
124
+ * call this. Tools must never overwrite the binding on a write path —
125
+ * an overwrite would mask account drift, defeating the guard.
112
126
  */
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;
127
+ function resetAccountBinding() {
128
+ const path = bindingFile();
129
+ try {
130
+ unlinkSync(path);
131
+ console.error(`[cloudflare:binding] reset ${path}`);
132
+ return true;
133
+ }
134
+ catch (err) {
135
+ const code = err.code;
136
+ if (code === "ENOENT")
137
+ return false;
138
+ console.error(`[cloudflare:binding] failed to reset ${path}: ${err}`);
139
+ return false;
140
+ }
141
+ }
142
+ export function matchAccountZone(hostname, accountZones) {
143
+ const active = accountZones.filter((z) => z.status === "active").map((z) => z.name);
144
+ const lc = hostname.toLowerCase();
145
+ const candidates = active.filter((z) => lc === z.toLowerCase() || lc.endsWith("." + z.toLowerCase()));
146
+ if (candidates.length === 0) {
147
+ return { ok: false, matchedZone: null, accountZones: active };
148
+ }
149
+ candidates.sort((a, b) => b.length - a.length);
150
+ return { ok: true, matchedZone: candidates[0], accountZones: active };
151
+ }
152
+ export function logRefuse(detail) {
153
+ const brand = (() => {
154
+ try {
155
+ return loadBrand();
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ })();
161
+ const fields = {
162
+ reason: detail.reason,
163
+ brand: brand?.productName ?? "unknown",
164
+ accountZones: detail.fields.accountZones ?? null,
165
+ boundAccountId: detail.fields.boundAccountId ?? null,
166
+ certAccountId: detail.fields.certAccountId ?? null,
167
+ requestedDomain: detail.fields.requestedDomain ?? null,
168
+ requestedHostname: detail.fields.requestedHostname ?? null,
169
+ actualFqdn: detail.fields.actualFqdn ?? null,
170
+ tunnelId: detail.fields.tunnelId ?? null,
171
+ };
172
+ console.error(`[cloudflare:refuse] ${JSON.stringify(fields)}`);
120
173
  }
121
174
  /**
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.
175
+ * Single recovery instruction. Every refusal instructs the same path:
176
+ * tunnel-login under the Cloudflare account that owns the target zone.
131
177
  */
178
+ export function recoveryMessage() {
179
+ return (`Recovery: run tunnel-login while signed into the Cloudflare account that owns ` +
180
+ `the target zone. If you recently rotated the cert under a different account, ` +
181
+ `pass force=true to clear the existing cert.pem and account binding before ` +
182
+ `re-authenticating.`);
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Reset auth — unlinks cert.pem (both paths) and the account binding
186
+ //
187
+ // Kill order matters: terminate any in-flight `cloudflared tunnel login`
188
+ // process BEFORE deleting cert state, otherwise its browser handshake can
189
+ // complete between our unlink and return, writing a fresh cert.pem to the
190
+ // legacy path that findCert() would silently re-import on the next read.
191
+ // Same pattern Task 531 closed; preserved here.
192
+ // ---------------------------------------------------------------------------
132
193
  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.
194
+ const result = { deletedCert: false, deletedBinding: false };
139
195
  const loginState = readLoginState();
140
196
  if (loginState?.pid && isProcessAlive(loginState.pid)) {
141
197
  try {
@@ -147,21 +203,6 @@ export function resetAuth() {
147
203
  }
148
204
  }
149
205
  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
206
  const cp = certPath();
166
207
  try {
167
208
  unlinkSync(cp);
@@ -191,6 +232,7 @@ export function resetAuth() {
191
232
  console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
192
233
  }
193
234
  }
235
+ result.deletedBinding = resetAccountBinding();
194
236
  return result;
195
237
  }
196
238
  // ---------------------------------------------------------------------------
@@ -280,80 +322,117 @@ export function parseCertPem() {
280
322
  }
281
323
  // ---------------------------------------------------------------------------
282
324
  // SDK client
325
+ //
326
+ // Two entry points: getClient() for mutating operations enforces the auth
327
+ // pre-conditions (cert present, binding present, accountId-from-cert
328
+ // matches binding) — uncircumventable for any code path that needs an SDK
329
+ // instance. getReadOnlyClient() for cf-verify allows operating on an
330
+ // unbound device so the audit can report MISSING artefacts without
331
+ // throwing. Both rely on cert.pem as the single account identity source.
283
332
  // ---------------------------------------------------------------------------
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.");
333
+ /** Wraps a structured RefusalDetail so call sites can branch on `reason`. */
334
+ export class CloudflareRefusalError extends Error {
335
+ refusal;
336
+ constructor(detail) {
337
+ super(detail.message);
338
+ this.name = "CloudflareRefusalError";
339
+ this.refusal = detail;
288
340
  }
289
- return {
290
- client: new Cloudflare({ apiToken: token.apiToken }),
291
- accountId: token.accountId,
292
- };
293
341
  }
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.",
342
+ /**
343
+ * Build an SDK client for mutating operations. Throws CloudflareRefusalError
344
+ * with a structured `refusal` field if any auth pre-condition fails:
345
+ * - cert.pem absent or unparseable → unbound-device
346
+ * - account-binding.json absent → unbound-device
347
+ * - cert.pem accountId !== binding.accountId → account-drift
348
+ *
349
+ * The refusal is logged with [cloudflare:refuse] before the throw, so the
350
+ * single greppable signal exists regardless of how the caller handles the
351
+ * exception.
352
+ */
353
+ export function getClient() {
354
+ const creds = parseCertPem();
355
+ if (!creds) {
356
+ const detail = {
357
+ reason: "unbound-device",
358
+ message: `No cert.pem found — this device has not completed Cloudflare authentication. ` +
359
+ recoveryMessage(),
360
+ fields: { boundAccountId: readAccountBinding()?.accountId ?? null },
323
361
  };
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,
362
+ logRefuse(detail);
363
+ throw new CloudflareRefusalError(detail);
364
+ }
365
+ const binding = readAccountBinding();
366
+ if (!binding) {
367
+ const detail = {
368
+ reason: "unbound-device",
369
+ message: `No account binding recorded — tunnel-login must complete a fresh authentication ` +
370
+ `to bind this device to a Cloudflare account before any other tool can run. ` +
371
+ recoveryMessage(),
372
+ fields: { certAccountId: creds.accountId },
373
+ };
374
+ logRefuse(detail);
375
+ throw new CloudflareRefusalError(detail);
376
+ }
377
+ if (creds.accountId !== binding.accountId) {
378
+ const detail = {
379
+ reason: "account-drift",
380
+ message: `cert.pem is bound to account ${creds.accountId}, but this device's recorded ` +
381
+ `binding is account ${binding.accountId}. The cert was rotated under a different ` +
382
+ `Cloudflare account since the binding was established. ` +
383
+ recoveryMessage(),
384
+ fields: {
385
+ boundAccountId: binding.accountId,
386
+ certAccountId: creds.accountId,
387
+ },
340
388
  };
389
+ logRefuse(detail);
390
+ throw new CloudflareRefusalError(detail);
341
391
  }
392
+ return {
393
+ client: new Cloudflare({ apiToken: creds.apiToken }),
394
+ accountId: binding.accountId,
395
+ };
342
396
  }
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
- }
397
+ export function getReadOnlyClient() {
398
+ const creds = parseCertPem();
399
+ const binding = readAccountBinding();
400
+ const certAccountId = creds?.accountId ?? null;
401
+ const boundAccountId = binding?.accountId ?? null;
402
+ const client = creds ? new Cloudflare({ apiToken: creds.apiToken }) : null;
403
+ const bindingMatches = !!(creds && binding && creds.accountId === binding.accountId);
404
+ return { client, certAccountId, boundAccountId, bindingMatches };
405
+ }
406
+ export function validateAuth() {
407
+ const creds = parseCertPem();
408
+ const binding = readAccountBinding();
409
+ return {
410
+ hasCert: !!creds,
411
+ hasBinding: !!binding,
412
+ bound: !!(creds && binding && creds.accountId === binding.accountId),
413
+ certAccountId: creds?.accountId ?? null,
414
+ boundAccountId: binding?.accountId ?? null,
415
+ };
416
+ }
417
+ /**
418
+ * Establish the device-local account binding from cert.pem. Used by:
419
+ * (1) tunnel-login fresh-success path — first time creds are derived
420
+ * (2) migration path — when cert.pem exists from a prior install but no
421
+ * binding has yet been written (silent materialization, since the
422
+ * cert was already trust-established by the operator's prior login)
423
+ *
424
+ * Caller is responsible for declared-zone visibility validation BEFORE
425
+ * calling this — bindings should never be written to an account that
426
+ * doesn't own the brand's declared zones (Task 201 write-after-confirm).
427
+ */
428
+ export function materializeBinding(source) {
429
+ const creds = parseCertPem();
430
+ if (!creds) {
431
+ throw new Error("Cannot materialize binding — cert.pem absent or unparseable");
432
+ }
433
+ const binding = writeAccountBinding(creds.accountId);
434
+ console.error(`[cloudflare:binding-materialized] source=${source} accountId=${creds.accountId} boundAt=${binding.boundAt}`);
435
+ return binding;
357
436
  }
358
437
  export async function listZones() {
359
438
  const { client } = getClient();
@@ -372,17 +451,6 @@ export async function listZones() {
372
451
  }
373
452
  return zones;
374
453
  }
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 };
385
- }
386
454
  export async function getZoneId(domain) {
387
455
  const { client } = getClient();
388
456
  const zones = await client.zones.list({ name: domain });
@@ -396,9 +464,6 @@ export async function getZoneId(domain) {
396
464
  }
397
465
  export async function createZone(domain) {
398
466
  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
467
  // Idempotent — check if zone already exists on this account
403
468
  const existing = await client.zones.list({ name: domain });
404
469
  const match = existing.result?.[0];
@@ -710,11 +775,11 @@ export async function createDnsRecord(zoneId, subdomain, tunnelId) {
710
775
  return { created: true, existing: false, updated: false };
711
776
  }
712
777
  // ---------------------------------------------------------------------------
713
- // CLI-based tunnel operations — fallback for cert-derived tokens (cfut_*)
778
+ // CLI-based tunnel operations
714
779
  //
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.
780
+ // `cloudflared` CLI uses cert.pem directly for tunnel CRUD and DNS management.
781
+ // We use it (rather than the SDK) for tunnel-create / route-dns because the
782
+ // cert-bound credential carries the Argo Tunnel permissions needed in one step.
718
783
  // ---------------------------------------------------------------------------
719
784
  export function createTunnelCli(name) {
720
785
  const bin = findBinary();
@@ -809,68 +874,121 @@ export function writeLocalConfig(tunnelId, credentialsPath, hostnames, port) {
809
874
  return configPath;
810
875
  }
811
876
  // ---------------------------------------------------------------------------
812
- // CLI-based DNS routing
877
+ // CLI-based DNS routing — guarded by live account-zone check + post-flight
878
+ //
879
+ // `cloudflared tunnel route dns` resolves the target zone from cert.pem's
880
+ // account. If the hostname's registrable parent zone is not on that account,
881
+ // cloudflared silently writes a CNAME under a different zone (the original
882
+ // joelsmalley.xyz wrong-routing bug). Defence in depth:
813
883
  //
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.
884
+ // (1) Live account-zone check: list the bound account's zones, refuse if
885
+ // hostname's registrable parent is not active on that account.
886
+ // (2) Post-flight FQDN assertion: the FQDN cloudflared wrote must equal
887
+ // the requested hostname. Mismatch reverse-and-refuse.
818
888
  //
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.
889
+ // `getClient()` runs first to enforce auth pre-conditions.
823
890
  // ---------------------------------------------------------------------------
824
891
  export async function routeDnsCli(tunnelId, hostname) {
825
892
  const bin = findBinary();
826
893
  if (!bin)
827
894
  throw new Error("cloudflared is not installed");
895
+ const { client, accountId: boundAccountId } = getClient();
896
+ // (1) Live account-zone check.
897
+ const accountZones = await listZones();
898
+ const scope = matchAccountZone(hostname, accountZones);
899
+ if (!scope.ok) {
900
+ const detail = {
901
+ reason: "scope-mismatch",
902
+ message: `Cannot route ${hostname} — no active zone on the bound Cloudflare account ` +
903
+ `owns this hostname's registrable parent. Active zones on this account: ` +
904
+ `${scope.accountZones.join(", ") || "none"}. Add the parent zone to the account ` +
905
+ `(cf-add-zone or via the Cloudflare dashboard), or pick a hostname under one ` +
906
+ `of the existing zones.`,
907
+ fields: {
908
+ requestedHostname: hostname,
909
+ accountZones: scope.accountZones,
910
+ },
911
+ };
912
+ logRefuse(detail);
913
+ throw new CloudflareRefusalError(detail);
914
+ }
828
915
  const cert = findCert();
829
916
  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
- }
917
+ throw new Error("No cert.pem found — getClient() should have refused first");
918
+ let output;
844
919
  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 };
920
+ output = execFileSync(bin, ["tunnel", "--origincert", cert, "route", "dns", "--overwrite-dns", tunnelId, hostname], { encoding: "utf-8", timeout: 30000 });
863
921
  }
864
922
  catch (err) {
865
923
  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
924
  if (typeof msg === "string" && msg.includes("already exists")) {
869
925
  console.error(`[tunnel-route-dns] WARNING: ${hostname} "already exists" despite --overwrite-dns: ${msg}`);
870
- return { created: false, output: msg, fqdn: hostname, zone: match.zone };
926
+ return { created: false, output: msg, fqdn: hostname, zone: scope.matchedZone };
871
927
  }
872
928
  throw new Error(`cloudflared tunnel route dns failed: ${msg}`);
873
929
  }
930
+ // (2) Post-flight FQDN assertion.
931
+ const fqdnMatch = output.match(/Added CNAME\s+(\S+?)\s+which will route/);
932
+ if (!fqdnMatch) {
933
+ console.error(`[tunnel-route-dns] WARNING: could not parse CNAME FQDN from cloudflared output — format may have changed. Output: ${output.trim()}`);
934
+ return {
935
+ created: true,
936
+ output: output.trim(),
937
+ fqdn: hostname,
938
+ zone: scope.matchedZone,
939
+ };
940
+ }
941
+ const actualFqdn = fqdnMatch[1];
942
+ if (actualFqdn !== hostname) {
943
+ // cloudflared wrote a CNAME under a different FQDN than requested —
944
+ // somehow the live-zone check passed but the routing landed elsewhere.
945
+ // Log evidence, attempt to delete the wrong CNAME, refuse.
946
+ console.error(`[cloudflare:post-flight-mismatch] ${JSON.stringify({
947
+ requestedHostname: hostname,
948
+ actualFqdn,
949
+ tunnelId,
950
+ boundAccountId,
951
+ })}`);
952
+ let cleanupResult = "failed";
953
+ try {
954
+ const owningZone = accountZones.find((z) => actualFqdn === z.name || actualFqdn.endsWith("." + z.name));
955
+ if (owningZone) {
956
+ const records = await client.dns.records.list({
957
+ zone_id: owningZone.id,
958
+ name: { exact: actualFqdn },
959
+ type: "CNAME",
960
+ });
961
+ for (const r of records.result ?? []) {
962
+ if (r.id)
963
+ await client.dns.records.delete(r.id, { zone_id: owningZone.id });
964
+ }
965
+ cleanupResult = "ok";
966
+ }
967
+ }
968
+ catch (cleanupErr) {
969
+ console.error(`[cloudflare:post-flight-cleanup] error: ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`);
970
+ }
971
+ console.error(`[cloudflare:post-flight-cleanup] ${JSON.stringify({
972
+ requestedHostname: hostname,
973
+ actualFqdn,
974
+ result: cleanupResult,
975
+ })}`);
976
+ const detail = {
977
+ reason: "post-flight-fqdn-mismatch",
978
+ message: `cloudflared wrote the CNAME under ${actualFqdn} instead of the requested ${hostname}. ` +
979
+ `Cleanup ${cleanupResult === "ok" ? "succeeded" : "failed"}. Re-run cf-rebuild to reconcile.`,
980
+ fields: {
981
+ requestedHostname: hostname,
982
+ actualFqdn,
983
+ tunnelId,
984
+ boundAccountId,
985
+ },
986
+ };
987
+ logRefuse(detail);
988
+ throw new CloudflareRefusalError(detail);
989
+ }
990
+ console.error(`[tunnel-route-dns] CLI: routed ${hostname} → tunnel ${tunnelId} (overwrite) fqdn=${actualFqdn} zone=${scope.matchedZone}`);
991
+ return { created: true, output: output.trim(), fqdn: actualFqdn, zone: scope.matchedZone };
874
992
  }
875
993
  // ---------------------------------------------------------------------------
876
994
  // tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
@@ -1103,8 +1221,8 @@ export function getPersistedHostnames() {
1103
1221
  }
1104
1222
  return [];
1105
1223
  }
1106
- export async function getStatus(domain) {
1107
- const auth = await validateAuth();
1224
+ export function getStatus(domain) {
1225
+ const auth = validateAuth();
1108
1226
  const state = readState();
1109
1227
  const running = state !== null && isProcessAlive(state.pid);
1110
1228
  const effectiveDomain = domain ?? state?.domain ?? null;
@@ -1112,10 +1230,11 @@ export async function getStatus(domain) {
1112
1230
  return {
1113
1231
  installed: isInstalled(),
1114
1232
  version: version(),
1115
- hasCert: hasCert(),
1116
- hasToken: auth.hasToken,
1117
- tokenValid: auth.tokenValid,
1118
- authError: auth.error ?? null,
1233
+ hasCert: auth.hasCert,
1234
+ hasBinding: auth.hasBinding,
1235
+ bound: auth.bound,
1236
+ certAccountId: auth.certAccountId,
1237
+ boundAccountId: auth.boundAccountId,
1119
1238
  running,
1120
1239
  pid: running ? state.pid : null,
1121
1240
  tunnelId: state?.tunnelId ?? null,
@@ -1194,4 +1313,313 @@ export function stopTunnel() {
1194
1313
  // Preserve tunnel identity, clear process lifecycle
1195
1314
  writeState({ ...state, pid: null, startedAt: null });
1196
1315
  }
1316
+ /**
1317
+ * cf-verify: read everything, classify nothing as good or bad. The agent
1318
+ * decides what to keep based on what the user is establishing now.
1319
+ *
1320
+ * "Orphans" are simply account-side things the device's current
1321
+ * tunnel.state + alias-domains.json don't reference. They may be pollution
1322
+ * from a previous setup, or may belong to other devices on the same
1323
+ * account — the agent surfaces them and the user decides.
1324
+ */
1325
+ export async function cfVerifyCore() {
1326
+ const brand = loadBrand();
1327
+ console.error(`[cloudflare:verify-start] ${JSON.stringify({ brand: brand.productName })}`);
1328
+ const device = readDeviceSnapshot();
1329
+ const ro = getReadOnlyClient();
1330
+ let account = null;
1331
+ if (ro.client && ro.certAccountId) {
1332
+ try {
1333
+ const [zones, tunnels] = await Promise.all([
1334
+ listZonesViaClient(ro.client),
1335
+ listTunnelsViaClient(ro.client, ro.certAccountId),
1336
+ ]);
1337
+ const cnames = [];
1338
+ for (const z of zones) {
1339
+ if (z.status !== "active")
1340
+ continue;
1341
+ try {
1342
+ const recs = await listCnamesUnderZone(ro.client, z.id);
1343
+ for (const r of recs) {
1344
+ cnames.push({ zone: z.name, recordId: r.id, name: r.name, content: r.content });
1345
+ }
1346
+ }
1347
+ catch (err) {
1348
+ console.error(`[cloudflare:verify] failed to list CNAMEs under ${z.name}: ${err instanceof Error ? err.message : String(err)}`);
1349
+ }
1350
+ }
1351
+ account = {
1352
+ accountId: ro.certAccountId,
1353
+ zones: zones.map((z) => ({ id: z.id, name: z.name, status: z.status })),
1354
+ tunnels: tunnels.map((t) => ({ id: t.id, name: t.name })),
1355
+ cnames,
1356
+ };
1357
+ }
1358
+ catch (err) {
1359
+ console.error(`[cloudflare:verify] account read failed: ${err instanceof Error ? err.message : String(err)}`);
1360
+ }
1361
+ }
1362
+ const orphans = computeOrphans(account, device);
1363
+ console.error(`[cloudflare:verify-complete] ${JSON.stringify({
1364
+ brand: brand.productName,
1365
+ orphanTunnels: orphans.tunnels.length,
1366
+ orphanCnames: orphans.cnames.length,
1367
+ orphanZones: orphans.zones.length,
1368
+ })}`);
1369
+ return { brand: brand.productName, device, account, orphans };
1370
+ }
1371
+ function readDeviceSnapshot() {
1372
+ const auth = validateAuth();
1373
+ const state = readState();
1374
+ const aliases = [...loadAliasDomains()];
1375
+ return {
1376
+ certPath: certPath(),
1377
+ certPresent: auth.hasCert,
1378
+ bindingPath: bindingFile(),
1379
+ bindingPresent: auth.hasBinding,
1380
+ bindingMatchesCert: auth.bound,
1381
+ certAccountId: auth.certAccountId,
1382
+ boundAccountId: auth.boundAccountId,
1383
+ tunnelStatePath: statePath(),
1384
+ tunnelState: state,
1385
+ configYmlPath: state?.configPath ?? null,
1386
+ configYmlPresent: !!(state?.configPath && existsSync(state.configPath)),
1387
+ aliasDomains: aliases,
1388
+ };
1389
+ }
1390
+ function computeOrphans(account, device) {
1391
+ if (!account)
1392
+ return { tunnels: [], cnames: [], zones: [] };
1393
+ const intendedTunnelId = device.tunnelState?.tunnelId ?? null;
1394
+ const intendedHostnames = new Set();
1395
+ if (device.tunnelState?.adminHostname)
1396
+ intendedHostnames.add(device.tunnelState.adminHostname.toLowerCase());
1397
+ if (device.tunnelState?.publicHostname)
1398
+ intendedHostnames.add(device.tunnelState.publicHostname.toLowerCase());
1399
+ for (const a of device.aliasDomains)
1400
+ intendedHostnames.add(a.toLowerCase());
1401
+ // Zones the intended hostnames live under — those are "in use".
1402
+ const inUseZones = new Set();
1403
+ for (const h of intendedHostnames) {
1404
+ for (const z of account.zones) {
1405
+ if (h === z.name.toLowerCase() || h.endsWith("." + z.name.toLowerCase())) {
1406
+ inUseZones.add(z.name.toLowerCase());
1407
+ break;
1408
+ }
1409
+ }
1410
+ }
1411
+ const orphanTunnels = account.tunnels.filter((t) => t.id !== intendedTunnelId);
1412
+ const orphanCnames = account.cnames.filter((c) => !intendedHostnames.has(c.name.toLowerCase()));
1413
+ const orphanZones = account.zones.filter((z) => !inUseZones.has(z.name.toLowerCase()));
1414
+ return { tunnels: orphanTunnels, cnames: orphanCnames, zones: orphanZones };
1415
+ }
1416
+ async function listZonesViaClient(client) {
1417
+ const zones = [];
1418
+ for await (const zone of client.zones.list()) {
1419
+ zones.push({
1420
+ id: zone.id,
1421
+ name: zone.name,
1422
+ status: zone.status ?? "unknown",
1423
+ nameservers: zone.name_servers ?? [],
1424
+ account: { id: zone.account.id ?? "", name: zone.account.name ?? "" },
1425
+ });
1426
+ }
1427
+ return zones;
1428
+ }
1429
+ async function listTunnelsViaClient(client, accountId) {
1430
+ const summaries = [];
1431
+ const result = await client.zeroTrust.tunnels.cloudflared.list({
1432
+ account_id: accountId,
1433
+ is_deleted: false,
1434
+ });
1435
+ for (const t of result.result ?? []) {
1436
+ if (!t.id || !t.name)
1437
+ continue;
1438
+ summaries.push({ id: t.id, name: t.name, createdAt: t.created_at ?? null });
1439
+ }
1440
+ return summaries;
1441
+ }
1442
+ async function listCnamesUnderZone(client, zoneId) {
1443
+ const out = [];
1444
+ for await (const rec of client.dns.records.list({ zone_id: zoneId, type: "CNAME" })) {
1445
+ if (!rec.id || !rec.name)
1446
+ continue;
1447
+ out.push({ id: rec.id, name: rec.name, content: rec.content ?? "" });
1448
+ }
1449
+ return out;
1450
+ }
1451
+ export async function cfRebuildCore(opts = {}) {
1452
+ const dryRun = opts.dryRun ?? false;
1453
+ const brand = loadBrand();
1454
+ console.error(`[cloudflare:rebuild-start] ${JSON.stringify({ brand: brand.productName, dryRun })}`);
1455
+ // Auth gate. We need a client to mutate the account.
1456
+ let client;
1457
+ let accountId;
1458
+ try {
1459
+ const c = getClient();
1460
+ client = c.client;
1461
+ accountId = c.accountId;
1462
+ }
1463
+ catch (err) {
1464
+ if (err instanceof CloudflareRefusalError) {
1465
+ return {
1466
+ brand: brand.productName,
1467
+ dryRun,
1468
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1469
+ actions: [],
1470
+ halted: true,
1471
+ haltReason: err.refusal.message,
1472
+ };
1473
+ }
1474
+ throw err;
1475
+ }
1476
+ // Snapshot the account before mutating.
1477
+ const verify = await cfVerifyCore();
1478
+ if (!verify.account) {
1479
+ return {
1480
+ brand: brand.productName,
1481
+ dryRun,
1482
+ preserve: { zones: [], tunnelIds: [], cnames: null },
1483
+ actions: [],
1484
+ halted: true,
1485
+ haltReason: "Could not read account state (cf-verify returned no account snapshot).",
1486
+ };
1487
+ }
1488
+ // Resolve the preserve set. Defaults from the device's intended state.
1489
+ const intendedTunnelId = verify.device.tunnelState?.tunnelId ?? null;
1490
+ const intendedHostnames = [];
1491
+ if (verify.device.tunnelState?.adminHostname)
1492
+ intendedHostnames.push(verify.device.tunnelState.adminHostname);
1493
+ if (verify.device.tunnelState?.publicHostname)
1494
+ intendedHostnames.push(verify.device.tunnelState.publicHostname);
1495
+ for (const a of verify.device.aliasDomains)
1496
+ intendedHostnames.push(a);
1497
+ const inferredZones = new Set();
1498
+ for (const h of intendedHostnames) {
1499
+ const lc = h.toLowerCase();
1500
+ for (const z of verify.account.zones) {
1501
+ if (lc === z.name.toLowerCase() || lc.endsWith("." + z.name.toLowerCase())) {
1502
+ inferredZones.add(z.name);
1503
+ break;
1504
+ }
1505
+ }
1506
+ }
1507
+ const preserveZones = (opts.preserve?.zones ?? [...inferredZones]).map((s) => s.toLowerCase());
1508
+ const preserveTunnelIds = opts.preserve?.tunnelIds ?? (intendedTunnelId ? [intendedTunnelId] : []);
1509
+ const preserveCnames = opts.preserve?.cnames
1510
+ ? opts.preserve.cnames.map((c) => ({ zone: c.zone.toLowerCase(), name: c.name.toLowerCase() }))
1511
+ : null;
1512
+ const actions = [];
1513
+ // (a) Delete tunnels not in preserve.
1514
+ for (const t of verify.account.tunnels) {
1515
+ if (preserveTunnelIds.includes(t.id))
1516
+ continue;
1517
+ const action = {
1518
+ op: "delete-tunnel",
1519
+ type: "tunnel",
1520
+ id: t.id,
1521
+ name: t.name,
1522
+ result: dryRun ? "planned" : "ok",
1523
+ };
1524
+ if (!dryRun) {
1525
+ try {
1526
+ await client.zeroTrust.tunnels.cloudflared.delete(t.id, { account_id: accountId });
1527
+ }
1528
+ catch (err) {
1529
+ action.result = "failed";
1530
+ action.detail = err instanceof Error ? err.message : String(err);
1531
+ }
1532
+ }
1533
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "tunnel", id: t.id, name: t.name, planned: dryRun, result: action.result })}`);
1534
+ actions.push(action);
1535
+ }
1536
+ // (b) Delete CNAMEs not in preserve.
1537
+ // If preserve.cnames is set, ONLY those exact records survive.
1538
+ // Otherwise: any CNAME under a preserved zone is preserved.
1539
+ for (const c of verify.account.cnames) {
1540
+ const zoneLc = c.zone.toLowerCase();
1541
+ const nameLc = c.name.toLowerCase();
1542
+ let keep = false;
1543
+ if (preserveCnames) {
1544
+ keep = preserveCnames.some((p) => p.zone === zoneLc && p.name === nameLc);
1545
+ }
1546
+ else {
1547
+ keep = preserveZones.includes(zoneLc);
1548
+ }
1549
+ if (keep)
1550
+ continue;
1551
+ const action = {
1552
+ op: "delete-cname",
1553
+ type: "cname",
1554
+ id: c.recordId,
1555
+ name: c.name,
1556
+ result: dryRun ? "planned" : "ok",
1557
+ detail: `${c.name} → ${c.content} under zone ${c.zone}`,
1558
+ };
1559
+ if (!dryRun) {
1560
+ const zoneRec = verify.account.zones.find((z) => z.name.toLowerCase() === zoneLc);
1561
+ if (!zoneRec) {
1562
+ action.result = "failed";
1563
+ action.detail = `zone ${c.zone} not found in snapshot`;
1564
+ }
1565
+ else {
1566
+ try {
1567
+ await client.dns.records.delete(c.recordId, { zone_id: zoneRec.id });
1568
+ }
1569
+ catch (err) {
1570
+ action.result = "failed";
1571
+ action.detail = err instanceof Error ? err.message : String(err);
1572
+ }
1573
+ }
1574
+ }
1575
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "cname", id: c.recordId, name: c.name, zone: c.zone, planned: dryRun, result: action.result })}`);
1576
+ actions.push(action);
1577
+ }
1578
+ // (c) Delete zones not in preserve.
1579
+ for (const z of verify.account.zones) {
1580
+ if (preserveZones.includes(z.name.toLowerCase()))
1581
+ continue;
1582
+ const action = {
1583
+ op: "delete-zone",
1584
+ type: "zone",
1585
+ id: z.id,
1586
+ name: z.name,
1587
+ result: dryRun ? "planned" : "ok",
1588
+ };
1589
+ if (!dryRun) {
1590
+ try {
1591
+ await client.zones.delete({ zone_id: z.id });
1592
+ }
1593
+ catch (err) {
1594
+ // Zone deletion may fail (permissions, registrar lock). Surface but
1595
+ // do not halt — orphan zones are informational, not blocking.
1596
+ action.result = "failed";
1597
+ action.detail = err instanceof Error ? err.message : String(err);
1598
+ }
1599
+ }
1600
+ console.error(`[cloudflare:rebuild-discard] ${JSON.stringify({ type: "zone", id: z.id, name: z.name, planned: dryRun, result: action.result })}`);
1601
+ actions.push(action);
1602
+ }
1603
+ let finalVerify;
1604
+ if (!dryRun) {
1605
+ finalVerify = await cfVerifyCore();
1606
+ }
1607
+ console.error(`[cloudflare:rebuild-complete] ${JSON.stringify({
1608
+ brand: brand.productName,
1609
+ dryRun,
1610
+ actionCount: actions.length,
1611
+ })}`);
1612
+ return {
1613
+ brand: brand.productName,
1614
+ dryRun,
1615
+ preserve: {
1616
+ zones: preserveZones,
1617
+ tunnelIds: preserveTunnelIds,
1618
+ cnames: preserveCnames,
1619
+ },
1620
+ actions,
1621
+ finalVerify,
1622
+ halted: false,
1623
+ };
1624
+ }
1197
1625
  //# sourceMappingURL=cloudflared.js.map