@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.
- package/dist/index.js +42 -8
- package/package.json +1 -1
- package/payload/platform/config/brand.json +4 -0
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts +23 -13
- package/payload/platform/lib/mcp-stderr-tee/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js +86 -89
- package/payload/platform/lib/mcp-stderr-tee/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-stderr-tee/src/index.ts +86 -101
- package/payload/platform/plugins/admin/mcp/dist/index.js +33 -2
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.d.ts.map +1 -1
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js +2 -0
- package/payload/platform/plugins/admin/mcp/dist/lib/review-tools.js.map +1 -1
- package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +22 -8
- package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +196 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +81 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +65 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +70 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +124 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +232 -183
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +181 -30
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +938 -154
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
- package/payload/platform/plugins/cloudflare/mcp/vitest.config.ts +10 -0
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +32 -27
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +25 -3
- package/payload/platform/plugins/docs/PLUGIN.md +2 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +68 -0
- package/payload/platform/plugins/docs/references/plugins-guide.md +8 -6
- package/payload/platform/plugins/docs/references/troubleshooting.md +2 -0
- package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts +9 -2
- package/payload/platform/plugins/email/mcp/dist/lib/providers.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/providers.js +545 -92
- package/payload/platform/plugins/email/mcp/dist/lib/providers.js.map +1 -1
- package/payload/platform/scripts/logs-read.sh +114 -54
- package/payload/platform/templates/agents/admin/IDENTITY.md +6 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +1 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +4 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +16 -8
- package/payload/platform/templates/specialists/agents/project-manager.md +4 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +4 -0
- 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
|
|
86
|
-
return join(
|
|
102
|
+
function bindingFile() {
|
|
103
|
+
return join(bindingDir(), "account-binding.json");
|
|
87
104
|
}
|
|
88
|
-
export function
|
|
105
|
+
export function readAccountBinding() {
|
|
89
106
|
try {
|
|
90
|
-
const path =
|
|
107
|
+
const path = bindingFile();
|
|
91
108
|
if (!existsSync(path))
|
|
92
109
|
return null;
|
|
93
|
-
|
|
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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
128
|
-
const
|
|
129
|
-
const tf = tokenFile();
|
|
143
|
+
function resetAccountBinding() {
|
|
144
|
+
const path = bindingFile();
|
|
130
145
|
try {
|
|
131
|
-
unlinkSync(
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
248
|
+
else {
|
|
249
|
+
console.error(`[tunnel-login] failed to delete legacy cert.pem ${legacyCp}: ${err}`);
|
|
169
250
|
}
|
|
170
251
|
}
|
|
171
|
-
|
|
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
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
806
|
+
// CLI-based tunnel operations
|
|
660
807
|
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
// Argo Tunnel permissions
|
|
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
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
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 —
|
|
953
|
+
throw new Error("No cert.pem found — getClient() should have refused first");
|
|
954
|
+
let output;
|
|
772
955
|
try {
|
|
773
|
-
|
|
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
|
|
1020
|
-
const auth =
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|