@rubytech/create-maxy-code 0.1.448 → 0.1.450

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.
@@ -0,0 +1,59 @@
1
+ // Task 1664 — acceptance for the installer preserving the storage-broker house
2
+ // credential (platform/config/cloudflare-house.env) across an upgrade. The
3
+ // upgrade path wipes platform/ and re-copies the payload; without a
4
+ // save-before-wipe / restore-after-deploy round trip the credential is lost on
5
+ // every upgrade. Filesystem fixtures under a tmp dir; no installer side effects.
6
+ // Runs under `node --test dist/__tests__/*`.
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { backupHouseCloudflare, restoreHouseCloudflare } from "../preserve-house-cloudflare.js";
13
+ function makeDirs() {
14
+ const root = mkdtempSync(join(tmpdir(), "house-1664-"));
15
+ const installDir = join(root, "install");
16
+ const persistentDir = join(root, "persist");
17
+ const configDir = join(installDir, "platform/config");
18
+ mkdirSync(configDir, { recursive: true });
19
+ mkdirSync(persistentDir, { recursive: true });
20
+ return { root, installDir, persistentDir, configDir };
21
+ }
22
+ // Reproduce the upgrade sequence the installer runs: back up the live config,
23
+ // wipe platform/ (rm the whole config dir), re-create the payload's config dir,
24
+ // then restore.
25
+ function simulateUpgrade(installDir, persistentDir) {
26
+ backupHouseCloudflare(installDir, persistentDir);
27
+ rmSync(join(installDir, "platform"), { recursive: true, force: true });
28
+ mkdirSync(join(installDir, "platform/config"), { recursive: true });
29
+ restoreHouseCloudflare(installDir, persistentDir);
30
+ }
31
+ test("an existing house credential survives an upgrade byte-identical", () => {
32
+ const { root, installDir, persistentDir, configDir } = makeDirs();
33
+ try {
34
+ const live = join(configDir, "cloudflare-house.env");
35
+ const contents = "CLOUDFLARE_API_TOKEN=abc123\nCLOUDFLARE_ACCOUNT_ID=deadbeef\n";
36
+ writeFileSync(live, contents);
37
+ assert.equal(backupHouseCloudflare(installDir, persistentDir), true, "backup reports a file was saved");
38
+ simulateUpgrade(installDir, persistentDir);
39
+ const after = join(installDir, "platform/config", "cloudflare-house.env");
40
+ assert.ok(existsSync(after), "house credential is present after the upgrade");
41
+ assert.equal(readFileSync(after, "utf-8"), contents, "house credential is byte-identical after the upgrade");
42
+ }
43
+ finally {
44
+ rmSync(root, { recursive: true, force: true });
45
+ }
46
+ });
47
+ test("a fresh install with no house credential creates nothing", () => {
48
+ const { root, installDir, persistentDir } = makeDirs();
49
+ try {
50
+ assert.equal(backupHouseCloudflare(installDir, persistentDir), false, "backup reports nothing to save");
51
+ simulateUpgrade(installDir, persistentDir);
52
+ assert.equal(restoreHouseCloudflare(installDir, persistentDir), false, "restore reports nothing to restore");
53
+ assert.ok(!existsSync(join(installDir, "platform/config", "cloudflare-house.env")), "no house credential file is fabricated on a fresh install");
54
+ assert.ok(!existsSync(join(persistentDir, "cloudflare-house.env")), "no persistent house credential file is fabricated on a fresh install");
55
+ }
56
+ finally {
57
+ rmSync(root, { recursive: true, force: true });
58
+ }
59
+ });
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { validateTierFlag, validateEntitlementBase64, applyTierToAccountConfig,
11
11
  import { parseOsRelease, isUbuntuLike as isUbuntuLikePure, parseAptCacheCandidate, decideAptResolution, } from "./apt-resolve.js";
12
12
  import { findPeerBrandOnDefaultNeo4jPort } from "./peer-brand-detect.js";
13
13
  import { removeClientAdminsKey } from "./converge-client-admins.js";
14
+ import { backupHouseCloudflare, restoreHouseCloudflare } from "./preserve-house-cloudflare.js";
14
15
  import { resolveNeo4jPidfileScrub } from "./neo4j-pidfile-scrub.js";
15
16
  import { runInitLogging } from "./init-logging.js";
16
17
  import { requireSupportedPlatform, detectPlatform } from "./platform-detect.js";
@@ -2513,6 +2514,13 @@ function deployPayload() {
2513
2514
  cpSync(liveEmailCredFile, persistentEmailCredFile);
2514
2515
  console.log(" Saved email credentials to persistent store.");
2515
2516
  }
2517
+ // Task 1664 — the storage-broker house credential (cloudflare-house.env) is a
2518
+ // live-only file under platform/config with no persistent source, same as the
2519
+ // email credentials above. Save it before the wipe so the upgrade does not
2520
+ // delete the token the 1631 remediation established.
2521
+ if (backupHouseCloudflare(INSTALL_DIR, persistentDir)) {
2522
+ console.log(" Saved Cloudflare house credential to persistent store.");
2523
+ }
2516
2524
  // Wipe deployment directories to prevent stale files.
2517
2525
  // data/ is NOT wiped — it contains user data (accounts, uploads) that must survive.
2518
2526
  for (const dir of ["platform", "server", "maxy", "premium-plugins", "docs", ".claude"]) {
@@ -2544,6 +2552,12 @@ function deployPayload() {
2544
2552
  chmodSync(join(configDir, "email-credentials.json"), 0o600);
2545
2553
  console.log(" Restored email credentials.");
2546
2554
  }
2555
+ // Task 1664 — restore the storage-broker house credential after deploy so it
2556
+ // is byte-identical to the pre-upgrade file. The named log line lets the
2557
+ // operator confirm the credential survived rather than inferring it.
2558
+ if (restoreHouseCloudflare(INSTALL_DIR, persistentDir)) {
2559
+ console.log(" Restored Cloudflare house credential (cloudflare-house.env preserved).");
2560
+ }
2547
2561
  // users.json is read directly from persistentDir by both
2548
2562
  // platform/ui/app/lib/paths.ts (USERS_FILE = MAXY_DIR/users.json) and the
2549
2563
  // admin MCP plugin (USERS_FILE = CONFIG_DIR/users.json). No copy into the
@@ -0,0 +1,37 @@
1
+ // Task 1664 — the storage-broker house credential lives only at
2
+ // platform/config/cloudflare-house.env (Task 1631), inside the installer's
3
+ // platform/ wipe zone, with no persistent source of truth. Like
4
+ // email-credentials.json it must be saved before the wipe and restored after
5
+ // the payload deploy, or every upgrade deletes it and the broker (plus the
6
+ // calendar reconcile/publish scripts) fails with "house credential not found"
7
+ // until an operator re-mints the token by hand. The persistent copy lives in
8
+ // the brand's persistent config dir so even a full reinstall restores it.
9
+ import { existsSync, cpSync, chmodSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ const HOUSE_CRED_FILE = "cloudflare-house.env";
12
+ /**
13
+ * Copy the live house credential (if any) into the persistent store before the
14
+ * platform/ wipe. Returns true when a file was saved, false when none existed
15
+ * (fresh install — nothing to preserve).
16
+ */
17
+ export function backupHouseCloudflare(installDir, persistentDir) {
18
+ const live = join(installDir, "platform/config", HOUSE_CRED_FILE);
19
+ if (!existsSync(live))
20
+ return false;
21
+ cpSync(live, join(persistentDir, HOUSE_CRED_FILE));
22
+ return true;
23
+ }
24
+ /**
25
+ * Restore the house credential from the persistent store after the payload
26
+ * deploy, chmod 600 (it is an account-wide token). Returns true when a file was
27
+ * restored, false when none was ever saved.
28
+ */
29
+ export function restoreHouseCloudflare(installDir, persistentDir) {
30
+ const saved = join(persistentDir, HOUSE_CRED_FILE);
31
+ if (!existsSync(saved))
32
+ return false;
33
+ const dest = join(installDir, "platform/config", HOUSE_CRED_FILE);
34
+ cpSync(saved, dest);
35
+ chmodSync(dest, 0o600);
36
+ return true;
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.448",
3
+ "version": "0.1.450",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -15,12 +15,25 @@ function recordingRun(stdout) {
15
15
  };
16
16
  return { run, calls };
17
17
  }
18
+ // r2List hits the Cloudflare R2 API, not wrangler, so it needs a fetch seam.
19
+ // A run that throws proves r2List never shells wrangler.
20
+ const throwingRun = async () => {
21
+ throw new Error("r2List must not shell wrangler");
22
+ };
23
+ function recordingFetch(response) {
24
+ const calls = [];
25
+ const fetchFn = async (url, init) => {
26
+ calls.push({ url, init });
27
+ return { ok: response.ok, status: response.status, text: async () => response.body };
28
+ };
29
+ return { fetchFn, calls };
30
+ }
18
31
  (0, node_test_1.default)("d1List parses --json output and injects the house token", async () => {
19
32
  const { run, calls } = recordingRun(JSON.stringify([{ name: "gls-leads", uuid: "u1" }]));
20
33
  const cf = (0, cf_exec_js_1.makeCfExec)(cred, run);
21
34
  strict_1.default.deepEqual(await cf.d1List(), [{ name: "gls-leads", uuid: "u1" }]);
22
- strict_1.default.equal(calls[0].cmd, "wrangler");
23
- strict_1.default.deepEqual(calls[0].args, ["d1", "list", "--json"]);
35
+ strict_1.default.equal(calls[0].cmd, "npx");
36
+ strict_1.default.deepEqual(calls[0].args, ["wrangler", "d1", "list", "--json"]);
24
37
  strict_1.default.equal(calls[0].env.CLOUDFLARE_API_TOKEN, "cfat_x");
25
38
  strict_1.default.equal(calls[0].env.CLOUDFLARE_ACCOUNT_ID, "acc-h");
26
39
  });
@@ -28,8 +41,9 @@ function recordingRun(stdout) {
28
41
  const { run, calls } = recordingRun(JSON.stringify([{ results: [] }]));
29
42
  const cf = (0, cf_exec_js_1.makeCfExec)(cred, run);
30
43
  await cf.d1Query("gls-leads", "SELECT 1");
44
+ strict_1.default.equal(calls[0].cmd, "npx");
31
45
  strict_1.default.deepEqual(calls[0].args, [
32
- "d1", "execute", "gls-leads", "--remote", "--json", "--command", "SELECT 1",
46
+ "wrangler", "d1", "execute", "gls-leads", "--remote", "--json", "--command", "SELECT 1",
33
47
  ]);
34
48
  });
35
49
  (0, node_test_1.default)("d1Create parses the created uuid", async () => {
@@ -41,7 +55,57 @@ function recordingRun(stdout) {
41
55
  const { run, calls } = recordingRun("");
42
56
  const cf = (0, cf_exec_js_1.makeCfExec)(cred, run);
43
57
  await cf.r2Create("gls-assets");
44
- strict_1.default.deepEqual(calls[0].args, ["r2", "bucket", "create", "gls-assets"]);
58
+ strict_1.default.equal(calls[0].cmd, "npx");
59
+ strict_1.default.deepEqual(calls[0].args, ["wrangler", "r2", "bucket", "create", "gls-assets"]);
60
+ });
61
+ (0, node_test_1.default)("r2List returns the account's buckets from the R2 API with the house token", async () => {
62
+ const { fetchFn, calls } = recordingFetch({
63
+ ok: true,
64
+ status: 200,
65
+ body: JSON.stringify({
66
+ success: true,
67
+ errors: [],
68
+ messages: [],
69
+ result: {
70
+ buckets: [
71
+ { name: "gls-assets", creation_date: "2026-01-01T00:00:00Z" },
72
+ { name: "gls-docs", creation_date: "2026-01-02T00:00:00Z" },
73
+ ],
74
+ },
75
+ }),
76
+ });
77
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
78
+ strict_1.default.deepEqual(await cf.r2List(), [{ name: "gls-assets" }, { name: "gls-docs" }]);
79
+ strict_1.default.equal(calls[0].url, "https://api.cloudflare.com/client/v4/accounts/acc-h/r2/buckets");
80
+ strict_1.default.equal(calls[0].init.method, "GET");
81
+ strict_1.default.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
82
+ });
83
+ (0, node_test_1.default)("r2List returns [] for an account with no buckets", async () => {
84
+ const { fetchFn } = recordingFetch({
85
+ ok: true,
86
+ status: 200,
87
+ body: JSON.stringify({ success: true, errors: [], messages: [], result: { buckets: [] } }),
88
+ });
89
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
90
+ strict_1.default.deepEqual(await cf.r2List(), []);
91
+ });
92
+ (0, node_test_1.default)("r2List throws with the API error body on a non-2xx response", async () => {
93
+ const { fetchFn } = recordingFetch({
94
+ ok: false,
95
+ status: 403,
96
+ body: JSON.stringify({ success: false, errors: [{ code: 10000, message: "Authentication error" }] }),
97
+ });
98
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
99
+ await strict_1.default.rejects(() => cf.r2List(), /403[\s\S]*Authentication error/);
100
+ });
101
+ (0, node_test_1.default)("r2List throws on a 200 whose body is not a success envelope, retaining the body", async () => {
102
+ const { fetchFn } = recordingFetch({
103
+ ok: true,
104
+ status: 200,
105
+ body: JSON.stringify({ unexpected: "shape" }),
106
+ });
107
+ const cf = (0, cf_exec_js_1.makeCfExec)(cred, throwingRun, fetchFn);
108
+ await strict_1.default.rejects(() => cf.r2List(), /unexpected/);
45
109
  });
46
110
  (0, node_test_1.default)("non-JSON output throws with the raw body", async () => {
47
111
  const { run } = recordingRun("not json");
@@ -1 +1 @@
1
- {"version":3,"file":"cf-exec.test.js","sourceRoot":"","sources":["../../src/__tests__/cf-exec.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cf-exec.test.js","sourceRoot":"","sources":["../../src/__tests__/cf-exec.test.ts"],"names":[],"mappings":"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"}
@@ -2,6 +2,14 @@ import type { HouseCredential } from "./house-credential.js";
2
2
  export type RunFn = (cmd: string, args: string[], env: Record<string, string>) => Promise<{
3
3
  stdout: string;
4
4
  }>;
5
+ export type FetchFn = (url: string, init: {
6
+ method: string;
7
+ headers: Record<string, string>;
8
+ }) => Promise<{
9
+ ok: boolean;
10
+ status: number;
11
+ text(): Promise<string>;
12
+ }>;
5
13
  export interface CfExec {
6
14
  d1List(): Promise<{
7
15
  name: string;
@@ -16,5 +24,5 @@ export interface CfExec {
16
24
  }[]>;
17
25
  r2Create(name: string): Promise<void>;
18
26
  }
19
- export declare function makeCfExec(cred: HouseCredential, run?: RunFn): CfExec;
27
+ export declare function makeCfExec(cred: HouseCredential, run?: RunFn, fetchFn?: FetchFn): CfExec;
20
28
  //# sourceMappingURL=cf-exec.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cf-exec.d.ts","sourceRoot":"","sources":["../src/cf-exec.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D,MAAM,MAAM,KAAK,GAAG,CAClB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KACxB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAEjC,MAAM,WAAW,MAAM;IACrB,MAAM,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClD,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrD,MAAM,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAqBD,wBAAgB,UAAU,CAAC,IAAI,EAAE,eAAe,EAAE,GAAG,GAAE,KAAkB,GAAG,MAAM,CAgCjF"}
1
+ {"version":3,"file":"cf-exec.d.ts","sourceRoot":"","sources":["../src/cf-exec.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D,MAAM,MAAM,KAAK,GAAG,CAClB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KACxB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAKjC,MAAM,MAAM,OAAO,GAAG,CACpB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,KACtD,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;CAAE,CAAC,CAAC;AAEvE,MAAM,WAAW,MAAM;IACrB,MAAM,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACpD,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClD,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrD,MAAM,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAuBD,wBAAgB,UAAU,CACxB,IAAI,EAAE,eAAe,EACrB,GAAG,GAAE,KAAkB,EACvB,OAAO,GAAE,OAAsB,GAC9B,MAAM,CAyDR"}
@@ -5,6 +5,7 @@
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.makeCfExec = makeCfExec;
7
7
  const node_child_process_1 = require("node:child_process");
8
+ const defaultFetch = (url, init) => fetch(url, init);
8
9
  const defaultRun = (cmd, args, env) => new Promise((resolve, reject) => {
9
10
  (0, node_child_process_1.execFile)(cmd, args, { env }, (err, stdout, stderr) => {
10
11
  if (err) {
@@ -22,31 +23,52 @@ function parseJson(raw, context) {
22
23
  throw new Error(`storage-broker: ${context} returned non-JSON output: ${raw}`);
23
24
  }
24
25
  }
25
- function makeCfExec(cred, run = defaultRun) {
26
+ function makeCfExec(cred, run = defaultRun, fetchFn = defaultFetch) {
26
27
  const env = {
27
28
  ...process.env,
28
29
  CLOUDFLARE_API_TOKEN: cred.apiToken,
29
30
  CLOUDFLARE_ACCOUNT_ID: cred.accountId,
30
31
  };
32
+ // Resolve wrangler via `npx`, never bare: the brand server PATH carries no
33
+ // global wrangler, only `npx wrangler` resolves — the same proven form the
34
+ // scheduling plugin's buildD1Command uses (Task 1661). Bare `wrangler` ENOENTs.
31
35
  return {
32
36
  async d1List() {
33
- const { stdout } = await run("wrangler", ["d1", "list", "--json"], env);
37
+ const { stdout } = await run("npx", ["wrangler", "d1", "list", "--json"], env);
34
38
  return parseJson(stdout, "d1 list");
35
39
  },
36
40
  async d1Create(name) {
37
- const { stdout } = await run("wrangler", ["d1", "create", name, "--json"], env);
41
+ const { stdout } = await run("npx", ["wrangler", "d1", "create", name, "--json"], env);
38
42
  return parseJson(stdout, "d1 create");
39
43
  },
40
44
  async d1Query(name, sql) {
41
- const { stdout } = await run("wrangler", ["d1", "execute", name, "--remote", "--json", "--command", sql], env);
45
+ const { stdout } = await run("npx", ["wrangler", "d1", "execute", name, "--remote", "--json", "--command", sql], env);
42
46
  return parseJson(stdout, "d1 execute");
43
47
  },
44
48
  async r2List() {
45
- const { stdout } = await run("wrangler", ["r2", "bucket", "list", "--json"], env);
46
- return parseJson(stdout, "r2 bucket list");
49
+ // `wrangler r2 bucket list` has no --json flag and emits human prose, so
50
+ // it can neither be parsed nor honour the "never grep a CLI's prose"
51
+ // doctrine. The R2 API returns the bucket set as JSON directly.
52
+ const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`;
53
+ const res = await fetchFn(url, {
54
+ method: "GET",
55
+ headers: { Authorization: `Bearer ${cred.apiToken}` },
56
+ });
57
+ const body = await res.text();
58
+ if (!res.ok) {
59
+ throw new Error(`storage-broker: r2 bucket list failed: ${res.status}\n${body}`);
60
+ }
61
+ const parsed = parseJson(body, "r2 bucket list");
62
+ // Require an explicit success envelope. A 2xx whose body is not a
63
+ // well-formed `success:true` response is an anomaly to surface with its
64
+ // body, never a silent empty bucket set.
65
+ if (parsed.success !== true) {
66
+ throw new Error(`storage-broker: r2 bucket list API error: ${body}`);
67
+ }
68
+ return (parsed.result?.buckets ?? []).map((b) => ({ name: b.name }));
47
69
  },
48
70
  async r2Create(name) {
49
- await run("wrangler", ["r2", "bucket", "create", name], env);
71
+ await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
50
72
  },
51
73
  };
52
74
  }
@@ -1 +1 @@
1
- {"version":3,"file":"cf-exec.js","sourceRoot":"","sources":["../src/cf-exec.ts"],"names":[],"mappings":";AAAA,yEAAyE;AACzE,iFAAiF;AACjF,iFAAiF;;AAsCjF,gCAgCC;AApED,2DAA8C;AAiB9C,MAAM,UAAU,GAAU,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,EAAE,CAC3C,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;IAC9B,IAAA,6BAAQ,EAAC,GAAG,EAAE,IAAI,EAAE,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE;QACnD,IAAI,GAAG,EAAE,CAAC;YACR,MAAM,CAAC,IAAI,KAAK,CAAC,GAAG,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,YAAY,GAAG,CAAC,OAAO,KAAK,MAAM,EAAE,CAAC,CAAC,CAAC;YAChF,OAAO;QACT,CAAC;QACD,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEL,SAAS,SAAS,CAAC,GAAW,EAAE,OAAe;IAC7C,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,IAAI,KAAK,CAAC,mBAAmB,OAAO,8BAA8B,GAAG,EAAE,CAAC,CAAC;IACjF,CAAC;AACH,CAAC;AAED,SAAgB,UAAU,CAAC,IAAqB,EAAE,MAAa,UAAU;IACvE,MAAM,GAAG,GAAG;QACV,GAAG,OAAO,CAAC,GAAG;QACd,oBAAoB,EAAE,IAAI,CAAC,QAAQ;QACnC,qBAAqB,EAAE,IAAI,CAAC,SAAS;KACZ,CAAC;IAE5B,OAAO;QACL,KAAK,CAAC,MAAM;YACV,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,CAAC;YACxE,OAAO,SAAS,CAAC,MAAM,EAAE,SAAS,CAAqC,CAAC;QAC1E,CAAC;QACD,KAAK,CAAC,QAAQ,CAAC,IAAI;YACjB,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,CAAC;YAChF,OAAO,SAAS,CAAC,MAAM,EAAE,WAAW,CAAqB,CAAC;QAC5D,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG;YACrB,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAC1B,UAAU,EACV,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,GAAG,CAAC,EAC/D,GAAG,CACJ,CAAC;YACF,OAAO,SAAS,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;QACzC,CAAC;QACD,KAAK,CAAC,MAAM;YACV,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAC,CAAC;YAClF,OAAO,SAAS,CAAC,MAAM,EAAE,gBAAgB,CAAuB,CAAC;QACnE,CAAC;QACD,KAAK,CAAC,QAAQ,CAAC,IAAI;YACjB,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;QAC/D,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"cf-exec.js","sourceRoot":"","sources":["../src/cf-exec.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { makeCfExec, type RunFn } from "../cf-exec.js";
3
+ import { makeCfExec, type RunFn, type FetchFn } from "../cf-exec.js";
4
4
 
5
5
  const cred = { apiToken: "cfat_x", accountId: "acc-h" };
6
6
 
@@ -13,12 +13,27 @@ function recordingRun(stdout: string) {
13
13
  return { run, calls };
14
14
  }
15
15
 
16
+ // r2List hits the Cloudflare R2 API, not wrangler, so it needs a fetch seam.
17
+ // A run that throws proves r2List never shells wrangler.
18
+ const throwingRun: RunFn = async () => {
19
+ throw new Error("r2List must not shell wrangler");
20
+ };
21
+
22
+ function recordingFetch(response: { ok: boolean; status: number; body: string }) {
23
+ const calls: Array<{ url: string; init: { method: string; headers: Record<string, string> } }> = [];
24
+ const fetchFn: FetchFn = async (url, init) => {
25
+ calls.push({ url, init });
26
+ return { ok: response.ok, status: response.status, text: async () => response.body };
27
+ };
28
+ return { fetchFn, calls };
29
+ }
30
+
16
31
  test("d1List parses --json output and injects the house token", async () => {
17
32
  const { run, calls } = recordingRun(JSON.stringify([{ name: "gls-leads", uuid: "u1" }]));
18
33
  const cf = makeCfExec(cred, run);
19
34
  assert.deepEqual(await cf.d1List(), [{ name: "gls-leads", uuid: "u1" }]);
20
- assert.equal(calls[0].cmd, "wrangler");
21
- assert.deepEqual(calls[0].args, ["d1", "list", "--json"]);
35
+ assert.equal(calls[0].cmd, "npx");
36
+ assert.deepEqual(calls[0].args, ["wrangler", "d1", "list", "--json"]);
22
37
  assert.equal(calls[0].env.CLOUDFLARE_API_TOKEN, "cfat_x");
23
38
  assert.equal(calls[0].env.CLOUDFLARE_ACCOUNT_ID, "acc-h");
24
39
  });
@@ -27,8 +42,9 @@ test("d1Query runs execute --remote with the SQL", async () => {
27
42
  const { run, calls } = recordingRun(JSON.stringify([{ results: [] }]));
28
43
  const cf = makeCfExec(cred, run);
29
44
  await cf.d1Query("gls-leads", "SELECT 1");
45
+ assert.equal(calls[0].cmd, "npx");
30
46
  assert.deepEqual(calls[0].args, [
31
- "d1", "execute", "gls-leads", "--remote", "--json", "--command", "SELECT 1",
47
+ "wrangler", "d1", "execute", "gls-leads", "--remote", "--json", "--command", "SELECT 1",
32
48
  ]);
33
49
  });
34
50
 
@@ -42,7 +58,61 @@ test("r2Create shells wrangler r2 bucket create", async () => {
42
58
  const { run, calls } = recordingRun("");
43
59
  const cf = makeCfExec(cred, run);
44
60
  await cf.r2Create("gls-assets");
45
- assert.deepEqual(calls[0].args, ["r2", "bucket", "create", "gls-assets"]);
61
+ assert.equal(calls[0].cmd, "npx");
62
+ assert.deepEqual(calls[0].args, ["wrangler", "r2", "bucket", "create", "gls-assets"]);
63
+ });
64
+
65
+ test("r2List returns the account's buckets from the R2 API with the house token", async () => {
66
+ const { fetchFn, calls } = recordingFetch({
67
+ ok: true,
68
+ status: 200,
69
+ body: JSON.stringify({
70
+ success: true,
71
+ errors: [],
72
+ messages: [],
73
+ result: {
74
+ buckets: [
75
+ { name: "gls-assets", creation_date: "2026-01-01T00:00:00Z" },
76
+ { name: "gls-docs", creation_date: "2026-01-02T00:00:00Z" },
77
+ ],
78
+ },
79
+ }),
80
+ });
81
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
82
+ assert.deepEqual(await cf.r2List(), [{ name: "gls-assets" }, { name: "gls-docs" }]);
83
+ assert.equal(calls[0].url, "https://api.cloudflare.com/client/v4/accounts/acc-h/r2/buckets");
84
+ assert.equal(calls[0].init.method, "GET");
85
+ assert.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
86
+ });
87
+
88
+ test("r2List returns [] for an account with no buckets", async () => {
89
+ const { fetchFn } = recordingFetch({
90
+ ok: true,
91
+ status: 200,
92
+ body: JSON.stringify({ success: true, errors: [], messages: [], result: { buckets: [] } }),
93
+ });
94
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
95
+ assert.deepEqual(await cf.r2List(), []);
96
+ });
97
+
98
+ test("r2List throws with the API error body on a non-2xx response", async () => {
99
+ const { fetchFn } = recordingFetch({
100
+ ok: false,
101
+ status: 403,
102
+ body: JSON.stringify({ success: false, errors: [{ code: 10000, message: "Authentication error" }] }),
103
+ });
104
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
105
+ await assert.rejects(() => cf.r2List(), /403[\s\S]*Authentication error/);
106
+ });
107
+
108
+ test("r2List throws on a 200 whose body is not a success envelope, retaining the body", async () => {
109
+ const { fetchFn } = recordingFetch({
110
+ ok: true,
111
+ status: 200,
112
+ body: JSON.stringify({ unexpected: "shape" }),
113
+ });
114
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
115
+ await assert.rejects(() => cf.r2List(), /unexpected/);
46
116
  });
47
117
 
48
118
  test("non-JSON output throws with the raw body", async () => {
@@ -11,6 +11,14 @@ export type RunFn = (
11
11
  env: Record<string, string>,
12
12
  ) => Promise<{ stdout: string }>;
13
13
 
14
+ // The HTTP seam for the Cloudflare API. Injectable so r2List is unit-testable
15
+ // without a network. Minimal subset of the global fetch: r2List reads the body
16
+ // once via text() and branches on ok/status.
17
+ export type FetchFn = (
18
+ url: string,
19
+ init: { method: string; headers: Record<string, string> },
20
+ ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
21
+
14
22
  export interface CfExec {
15
23
  d1List(): Promise<{ name: string; uuid: string }[]>;
16
24
  d1Create(name: string): Promise<{ uuid: string }>;
@@ -19,6 +27,8 @@ export interface CfExec {
19
27
  r2Create(name: string): Promise<void>;
20
28
  }
21
29
 
30
+ const defaultFetch: FetchFn = (url, init) => fetch(url, init);
31
+
22
32
  const defaultRun: RunFn = (cmd, args, env) =>
23
33
  new Promise((resolve, reject) => {
24
34
  execFile(cmd, args, { env }, (err, stdout, stderr) => {
@@ -38,36 +48,65 @@ function parseJson(raw: string, context: string): unknown {
38
48
  }
39
49
  }
40
50
 
41
- export function makeCfExec(cred: HouseCredential, run: RunFn = defaultRun): CfExec {
51
+ export function makeCfExec(
52
+ cred: HouseCredential,
53
+ run: RunFn = defaultRun,
54
+ fetchFn: FetchFn = defaultFetch,
55
+ ): CfExec {
42
56
  const env = {
43
57
  ...process.env,
44
58
  CLOUDFLARE_API_TOKEN: cred.apiToken,
45
59
  CLOUDFLARE_ACCOUNT_ID: cred.accountId,
46
60
  } as Record<string, string>;
47
61
 
62
+ // Resolve wrangler via `npx`, never bare: the brand server PATH carries no
63
+ // global wrangler, only `npx wrangler` resolves — the same proven form the
64
+ // scheduling plugin's buildD1Command uses (Task 1661). Bare `wrangler` ENOENTs.
48
65
  return {
49
66
  async d1List() {
50
- const { stdout } = await run("wrangler", ["d1", "list", "--json"], env);
67
+ const { stdout } = await run("npx", ["wrangler", "d1", "list", "--json"], env);
51
68
  return parseJson(stdout, "d1 list") as { name: string; uuid: string }[];
52
69
  },
53
70
  async d1Create(name) {
54
- const { stdout } = await run("wrangler", ["d1", "create", name, "--json"], env);
71
+ const { stdout } = await run("npx", ["wrangler", "d1", "create", name, "--json"], env);
55
72
  return parseJson(stdout, "d1 create") as { uuid: string };
56
73
  },
57
74
  async d1Query(name, sql) {
58
75
  const { stdout } = await run(
59
- "wrangler",
60
- ["d1", "execute", name, "--remote", "--json", "--command", sql],
76
+ "npx",
77
+ ["wrangler", "d1", "execute", name, "--remote", "--json", "--command", sql],
61
78
  env,
62
79
  );
63
80
  return parseJson(stdout, "d1 execute");
64
81
  },
65
82
  async r2List() {
66
- const { stdout } = await run("wrangler", ["r2", "bucket", "list", "--json"], env);
67
- return parseJson(stdout, "r2 bucket list") as { name: string }[];
83
+ // `wrangler r2 bucket list` has no --json flag and emits human prose, so
84
+ // it can neither be parsed nor honour the "never grep a CLI's prose"
85
+ // doctrine. The R2 API returns the bucket set as JSON directly.
86
+ const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`;
87
+ const res = await fetchFn(url, {
88
+ method: "GET",
89
+ headers: { Authorization: `Bearer ${cred.apiToken}` },
90
+ });
91
+ const body = await res.text();
92
+ if (!res.ok) {
93
+ throw new Error(`storage-broker: r2 bucket list failed: ${res.status}\n${body}`);
94
+ }
95
+ const parsed = parseJson(body, "r2 bucket list") as {
96
+ success?: boolean;
97
+ errors?: unknown;
98
+ result?: { buckets?: { name: string }[] };
99
+ };
100
+ // Require an explicit success envelope. A 2xx whose body is not a
101
+ // well-formed `success:true` response is an anomaly to surface with its
102
+ // body, never a silent empty bucket set.
103
+ if (parsed.success !== true) {
104
+ throw new Error(`storage-broker: r2 bucket list API error: ${body}`);
105
+ }
106
+ return (parsed.result?.buckets ?? []).map((b) => ({ name: b.name }));
68
107
  },
69
108
  async r2Create(name) {
70
- await run("wrangler", ["r2", "bucket", "create", name], env);
109
+ await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
71
110
  },
72
111
  };
73
112
  }
@@ -286,5 +286,72 @@ grep -q -- "-X POST" "$SANDBOX/curl.log" && bad "minted despite no exact group m
286
286
  echo "$ERR" | grep -qF "no permission group named 'DNS Write'" && ok "die names the missing exact group" || bad "die message not named: $ERR"
287
287
  teardown
288
288
 
289
+ # 13. house-env fallback (multi-tenant): the per-account file has NO master
290
+ # (stripped by the 1631 remediation) but the house env carries it. cf-token
291
+ # must resolve/mint/persist against the HOUSE env, report src=house, and
292
+ # leave the stripped per-account file untouched. PLATFORM_ROOT locates the
293
+ # house env at $ROOT/config/cloudflare-house.env.
294
+ setup "cfat_master_abc"
295
+ # Strip the master from the per-account file (post-remediation state): keep only
296
+ # the non-credential account-id line.
297
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
298
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
299
+ HOUSE_ENV="$HOUSE_DIR/cloudflare-house.env"
300
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_ENV" )
301
+ of=$(mktemp); ef=$(mktemp)
302
+ PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
303
+ RC=$?; OUT=$(cat "$of"); ERR=$(cat "$ef"); rm -f "$of" "$ef"
304
+ [ "$RC" -eq 0 ] && ok "house fallback resolves (exit=0)" || { bad "house fallback (exit=$RC)"; echo " stderr: $ERR" >&2; }
305
+ [ "$OUT" = "CF_PAGES_D1_TOKEN" ] && ok "house fallback stdout is canonical key" || bad "house out='$OUT'"
306
+ grep -q "^CF_PAGES_D1_TOKEN=" "$HOUSE_ENV" && ok "minted token persisted to HOUSE env" || bad "not persisted to house env"
307
+ grep -q "^CF_PAGES_D1_TOKEN=" "$SECRETS" && bad "token wrongly written to stripped per-account file" || ok "stripped per-account file untouched"
308
+ grep -q "^CLOUDFLARE_API_TOKEN=" "$SECRETS" && bad "master reappeared in per-account file" || ok "no master in per-account file"
309
+ echo "$ERR" | grep -q "src=house" && ok "lifeline reports src=house" || bad "no src=house in lifeline: $ERR"
310
+ no_leak "house fallback"
311
+ teardown
312
+
313
+ # 14. single-tenant unchanged: the per-account file still carries the master, so
314
+ # NO fallback happens even with a house env present. src=account, and the
315
+ # token persists to the per-account file exactly as before.
316
+ setup "cfat_master_abc"
317
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
318
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_HOUSE_should_not_be_used" "acc123" > "$HOUSE_DIR/cloudflare-house.env" )
319
+ of=$(mktemp); ef=$(mktemp)
320
+ PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
321
+ RC=$?; OUT=$(cat "$of"); ERR=$(cat "$ef"); rm -f "$of" "$ef"
322
+ [ "$RC" -eq 0 ] && ok "single-tenant resolves (exit=0)" || bad "single-tenant (exit=$RC): $ERR"
323
+ grep -q "^CF_PAGES_D1_TOKEN=" "$SECRETS" && ok "single-tenant persists to per-account file" || bad "not persisted to per-account file"
324
+ grep -q "^CF_PAGES_D1_TOKEN=" "$HOUSE_DIR/cloudflare-house.env" && bad "single-tenant wrongly wrote to house env" || ok "house env untouched in single-tenant"
325
+ echo "$ERR" | grep -q "src=account" && ok "lifeline reports src=account" || bad "no src=account in lifeline: $ERR"
326
+ teardown
327
+
328
+ # 15. fail-closed: stripped per-account file and NO house env reachable (no
329
+ # PLATFORM_ROOT, no CF_HOUSE_ENV) -> non-zero, no mint, and a message that
330
+ # names the missing credential source. This is the sub-account posture: a
331
+ # sub-account spawn carries no PLATFORM_ROOT, so it can never self-resolve.
332
+ setup "cfat_master_abc"
333
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
334
+ of=$(mktemp); ef=$(mktemp)
335
+ env -u PLATFORM_ROOT -u MAXY_PLATFORM_ROOT -u CF_HOUSE_ENV \
336
+ CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
337
+ RC=$?; OUT=$(cat "$of"); ERR=$(cat "$ef"); rm -f "$of" "$ef"
338
+ [ "$RC" -ne 0 ] && ok "no-master no-house fails closed (exit=$RC)" || bad "did not fail closed (exit=$RC)"
339
+ grep -q -- "-X POST" "$SANDBOX/curl.log" && bad "minted despite no credential source" || ok "no mint without a credential source"
340
+ echo "$ERR" | grep -qi "no cloudflare master" && ok "die names missing master source" || bad "die message not named: $ERR"
341
+ teardown
342
+
343
+ # 16. explicit CF_HOUSE_ENV override wins over PLATFORM_ROOT derivation.
344
+ setup "cfat_master_abc"
345
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
346
+ OVERRIDE="$SANDBOX/acme-code/override-house.env"
347
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$OVERRIDE" )
348
+ of=$(mktemp); ef=$(mktemp)
349
+ CF_HOUSE_ENV="$OVERRIDE" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
350
+ RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
351
+ [ "$RC" -eq 0 ] && ok "CF_HOUSE_ENV override resolves (exit=0)" || bad "override (exit=$RC): $ERR"
352
+ grep -q "^CF_PAGES_D1_TOKEN=" "$OVERRIDE" && ok "override env received the minted token" || bad "override not persisted"
353
+ echo "$ERR" | grep -q "src=house" && ok "override reports src=house" || bad "override src: $ERR"
354
+ teardown
355
+
289
356
  echo "----"; echo "PASS=$PASS FAIL=$FAIL"
290
357
  [ "$FAIL" -eq 0 ]
@@ -23,6 +23,33 @@ scope="${1:-}"; secrets="${2:-}"
23
23
  case "$scope" in pages|d1|dns|access) ;; *) die "unknown scope '${scope}'; use pages|d1|dns|access";; esac
24
24
  [ -n "$secrets" ] && [ -r "$secrets" ] || die "secrets file not readable: '${secrets}'"
25
25
 
26
+ # House-credential fallback (Task 1662). After the 1631 storage-isolation
27
+ # remediation, a sub-account's cloudflare.env carries NO account-wide master; the
28
+ # sole master lives house-only at ${PLATFORM_ROOT}/config/cloudflare-house.env.
29
+ # The admin-run scopes (pages/dns/access) still mint from that master. When the
30
+ # passed file has no master and the house env is reachable AND carries one,
31
+ # resolve/mint/persist against the house env instead. src records which source
32
+ # was used, surfaced on every lifeline so an operator can see hosting is on the
33
+ # isolated path, not a stray per-account token. Single-tenant installs keep the
34
+ # master in the per-account file, so no fallback fires and behaviour is unchanged.
35
+ # The house env is locatable only from an admin/specialist spawn (PLATFORM_ROOT is
36
+ # stamped there, never on a plain sub-account spawn), so this grants a sub-account
37
+ # no self-hosting capability.
38
+ file_has_master() { grep -Eq '^[[:space:]]*CLOUDFLARE_API_TOKEN=.+' "$1" 2>/dev/null; }
39
+ src=account
40
+ if ! file_has_master "$secrets"; then
41
+ house="${CF_HOUSE_ENV:-}"
42
+ if [ -z "$house" ] && [ -n "${PLATFORM_ROOT:-}" ]; then
43
+ house="${PLATFORM_ROOT}/config/cloudflare-house.env"
44
+ fi
45
+ if [ -n "$house" ] && [ -r "$house" ] && file_has_master "$house"; then
46
+ secrets="$house"; src=house
47
+ else
48
+ echo "[cf-token] op=resolve scope=${scope} master=none action=resolve result=error code=0 src=account" >&2
49
+ die "no Cloudflare master token: '${secrets}' carries none and no house credential is reachable (set CF_HOUSE_ENV or PLATFORM_ROOT)"
50
+ fi
51
+ fi
52
+
26
53
  # Load credentials (api.md load-credentials pre-flight). :? aborts if unset/empty,
27
54
  # so a credential-unloaded call can never reach the API.
28
55
  set -a; . "$secrets"; set +a
@@ -66,7 +93,7 @@ if [ -z "$existing" ] && [ -n "$legacy" ]; then
66
93
  existing="$(read_key "$legacy")"; [ -n "$existing" ] && used_key="$legacy"
67
94
  fi
68
95
  if [ -n "$existing" ]; then
69
- echo "[cf-token] op=resolve scope=${scope} master=$(prefix_tag) action=reuse result=active code=0" >&2
96
+ echo "[cf-token] op=resolve scope=${scope} master=$(prefix_tag) action=reuse result=active code=0 src=${src}" >&2
70
97
  printf '%s\n' "$used_key"; exit 0
71
98
  fi
72
99
 
@@ -74,7 +101,7 @@ fi
74
101
  case "$CLOUDFLARE_API_TOKEN" in
75
102
  cfat_*) base="$API/accounts/$ACC"; mtag=cfat;;
76
103
  cfut_*) base="$API/user"; mtag=cfut;;
77
- *) echo "[cf-token] op=resolve scope=${scope} master=other action=mint result=error code=0" >&2
104
+ *) echo "[cf-token] op=resolve scope=${scope} master=other action=mint result=error code=0 src=${src}" >&2
78
105
  die "unrecognised master token prefix (not cfat_/cfut_); refusing to guess an endpoint";;
79
106
  esac
80
107
 
@@ -95,7 +122,7 @@ api_get() { curl -sS -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" "$1"; }
95
122
  pg_json="$(api_get "${base}/tokens/permission_groups")"
96
123
  echo "$pg_json" | jq -e '.success==true' >/dev/null 2>&1 || {
97
124
  code=$(echo "$pg_json" | jq -r '.errors[0].code // "?"')
98
- echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=${code}" >&2
125
+ echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=${code} src=${src}" >&2
99
126
  die "permission_groups failed at ${base}/tokens/permission_groups code=${code}"
100
127
  }
101
128
 
@@ -126,7 +153,7 @@ verify_token() { # $1 token -> echoes "active" or the failing code; retries tran
126
153
 
127
154
  finish() { # $1 token value -> persist under canonical key, report, exit 0
128
155
  ( umask 077; printf '%s=%s\n' "$key" "$1" >> "$secrets" )
129
- echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=active code=0" >&2
156
+ echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=active code=0 src=${src}" >&2
130
157
  printf '%s\n' "$key"; exit 0
131
158
  }
132
159
 
@@ -136,7 +163,7 @@ if [ "$scope" = "access" ]; then
136
163
  # Try each candidate, mint+verify, then a functional read; keep the one that works.
137
164
  candidates="$(collect_ids "$regexes")"
138
165
  [ -n "$candidates" ] || {
139
- echo "[cf-token] op=resolve scope=access master=${mtag} action=mint result=error code=0" >&2
166
+ echo "[cf-token] op=resolve scope=access master=${mtag} action=mint result=error code=0 src=${src}" >&2
140
167
  die "no permission group matched /${regexes}/"; }
141
168
  for cid in $candidates; do
142
169
  tok=$(mint_with "$(jq -cn --arg id "$cid" '[{id:$id}]')")
@@ -147,7 +174,7 @@ if [ "$scope" = "access" ]; then
147
174
  [ "$fn" = "true" ] && finish "$tok"
148
175
  # else poison id - try the next candidate
149
176
  done
150
- echo "[cf-token] op=resolve scope=access master=${mtag} action=mint result=error code=10000" >&2
177
+ echo "[cf-token] op=resolve scope=access master=${mtag} action=mint result=error code=10000 src=${src}" >&2
151
178
  die "every Access permission-group candidate returned 10000 on a functional read (poison id)"
152
179
  else
153
180
  pg_array='[]'
@@ -156,18 +183,18 @@ else
156
183
  id=$(collect_exact "$nm" | head -1)
157
184
  [ -n "$id" ] || {
158
185
  IFS=$OLDIFS
159
- echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=0" >&2
186
+ echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=0 src=${src}" >&2
160
187
  die "no permission group named '${nm}'"; }
161
188
  pg_array=$(echo "$pg_array" | jq -c --arg id "$id" '. + [{id:$id}]')
162
189
  done
163
190
  IFS=$OLDIFS
164
191
  tok=$(mint_with "$pg_array")
165
192
  [ -n "$tok" ] || {
166
- echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=0" >&2
193
+ echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=0 src=${src}" >&2
167
194
  die "mint returned no token value at ${base}/tokens"; }
168
195
  st=$(verify_token "$tok")
169
196
  [ "$st" = "active" ] || {
170
- echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=${st}" >&2
197
+ echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=${st} src=${src}" >&2
171
198
  die "verify failed code=${st} at ${base}/tokens/verify"; }
172
199
  finish "$tok"
173
200
  fi
@@ -53,7 +53,7 @@ chmod 600 "${SECRETS_DIR}/cloudflare.env"
53
53
 
54
54
  ### Multi-tenant installs — the master lives house-only
55
55
 
56
- The account-scoped storage above is correct for a single-tenant install (one Cloudflare account, one client). On a **multi-client install** (many client sub-accounts sharing one Cloudflare account, for example SiteDesk), an account-wide token in a sub-account's `cloudflare.env` lets that sub-account reach every other client's D1 and R2. There, the master lives house-only at `${PLATFORM_ROOT}/config/cloudflare-house.env` (a location no sub-account spawn sources), per-account `cloudflare.env` files carry no account-wide token, and a sub-account reaches D1/R2 only through the storage broker, which scopes every operation to the caller's account. See [`../../../../.docs/cloudflare-storage-isolation.md`](../../../../.docs/cloudflare-storage-isolation.md). The per-scope DNS/Pages/Access minting below is unchanged and remains admin-run.
56
+ The account-scoped storage above is correct for a single-tenant install (one Cloudflare account, one client). On a **multi-client install** (many client sub-accounts sharing one Cloudflare account, for example SiteDesk), an account-wide token in a sub-account's `cloudflare.env` lets that sub-account reach every other client's D1 and R2. There, the master lives house-only at `${PLATFORM_ROOT}/config/cloudflare-house.env` (a location no sub-account spawn sources), per-account `cloudflare.env` files carry no account-wide token, and a sub-account reaches D1/R2 only through the storage broker, which scopes every operation to the caller's account. See [`../../../../.docs/cloudflare-storage-isolation.md`](../../../../.docs/cloudflare-storage-isolation.md). The per-scope DNS/Pages/Access minting below stays admin-run, and its mint-or-reuse mechanism is unchanged; the one difference on a multi-tenant install is the source file. Because the per-account `cloudflare.env` no longer carries the master, `cf-token.sh` falls back to the house credential (`${PLATFORM_ROOT}/config/cloudflare-house.env`) for these scopes, mints and persists the scope token there, and reports `src=house` on its `[cf-token]` lifeline. That fallback is reachable only from an admin/specialist spawn (which alone carries `PLATFORM_ROOT`), so a sub-account still cannot self-resolve a hosting token.
57
57
 
58
58
  Load the master for a mint call:
59
59
 
@@ -29,7 +29,7 @@ This is the entry point for every Cloudflare task on the install. Pick the opera
29
29
 
30
30
  Neither the master token nor any per-scope token is ever written into a project tree, committed, or echoed into chat — the per-scope tokens persist only in the account-scoped secrets file, never in a deployable project tree.
31
31
 
32
- On a multi-client install where client sub-accounts share one Cloudflare account, the master lives house-only at `config/cloudflare-house.env` and sub-account D1/R2 data work goes through the storage broker, not raw `wrangler`. This skill's tunnel, DNS, and Pages operations stay admin-run and unchanged. See `references/api.md` and `.docs/cloudflare-storage-isolation.md`.
32
+ On a multi-client install where client sub-accounts share one Cloudflare account, the master lives house-only at `config/cloudflare-house.env` and sub-account D1/R2 data work goes through the storage broker, not raw `wrangler`. This skill's tunnel operations are per-brand isolated and unchanged. The admin-run scopes (Pages, DNS, Access) stay admin/house-run: because the per-account secrets file no longer carries the master, `cf-token.sh` resolves and mints those scope tokens against the house credential (`config/cloudflare-house.env`) and persists them there, surfacing `src=house` on its `[cf-token]` lifeline. Hosting a client site to Pages is therefore an admin/house action, never a sub-account one, consistent with the isolation posture. See `references/api.md` and `.docs/cloudflare-storage-isolation.md`.
33
33
 
34
34
  ### Resolve the per-scope token before any `wrangler` / API call (binding)
35
35
 
@@ -9,6 +9,13 @@
9
9
  # standalone-> CLOUDFLARE_API_TOKEN (read from the environment)
10
10
  # NOTE: token-key in-repo is not a pure read — cf-token.sh mints the
11
11
  # scope token once if absent and persists it to the secrets file.
12
+ # On a multi-tenant install (post-1631 storage isolation) the
13
+ # per-account secrets file carries no account-wide master; cf-token.sh
14
+ # then resolves Pages against the house credential
15
+ # (${PLATFORM_ROOT}/config/cloudflare-house.env) and persists the
16
+ # minted token there. This resolver hands cf-token.sh the per-account
17
+ # file unchanged; the house fallback is internal to cf-token.sh, so
18
+ # hosting is house-run without this seam knowing the edition.
12
19
  # site-root : the root that holds pages/<project>/. Consumers append the single
13
20
  # `pages/<project>` segment, matching the <accountDir>/pages/<project>/
14
21
  # convention, so this prints the PARENT of pages, never pages itself.
@@ -68,6 +68,7 @@ function readHouseCredential(platformRoot) {
68
68
 
69
69
  // ../lib/storage-broker/src/cf-exec.ts
70
70
  import { execFile } from "child_process";
71
+ var defaultFetch = (url, init) => fetch(url, init);
71
72
  var defaultRun = (cmd, args, env) => new Promise((resolve, reject) => {
72
73
  execFile(cmd, args, { env }, (err, stdout, stderr) => {
73
74
  if (err) {
@@ -85,7 +86,7 @@ function parseJson(raw, context) {
85
86
  throw new Error(`storage-broker: ${context} returned non-JSON output: ${raw}`);
86
87
  }
87
88
  }
88
- function makeCfExec(cred, run = defaultRun) {
89
+ function makeCfExec(cred, run = defaultRun, fetchFn = defaultFetch) {
89
90
  const env = {
90
91
  ...process.env,
91
92
  CLOUDFLARE_API_TOKEN: cred.apiToken,
@@ -93,27 +94,40 @@ function makeCfExec(cred, run = defaultRun) {
93
94
  };
94
95
  return {
95
96
  async d1List() {
96
- const { stdout } = await run("wrangler", ["d1", "list", "--json"], env);
97
+ const { stdout } = await run("npx", ["wrangler", "d1", "list", "--json"], env);
97
98
  return parseJson(stdout, "d1 list");
98
99
  },
99
100
  async d1Create(name) {
100
- const { stdout } = await run("wrangler", ["d1", "create", name, "--json"], env);
101
+ const { stdout } = await run("npx", ["wrangler", "d1", "create", name, "--json"], env);
101
102
  return parseJson(stdout, "d1 create");
102
103
  },
103
104
  async d1Query(name, sql) {
104
105
  const { stdout } = await run(
105
- "wrangler",
106
- ["d1", "execute", name, "--remote", "--json", "--command", sql],
106
+ "npx",
107
+ ["wrangler", "d1", "execute", name, "--remote", "--json", "--command", sql],
107
108
  env
108
109
  );
109
110
  return parseJson(stdout, "d1 execute");
110
111
  },
111
112
  async r2List() {
112
- const { stdout } = await run("wrangler", ["r2", "bucket", "list", "--json"], env);
113
- return parseJson(stdout, "r2 bucket list");
113
+ const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`;
114
+ const res = await fetchFn(url, {
115
+ method: "GET",
116
+ headers: { Authorization: `Bearer ${cred.apiToken}` }
117
+ });
118
+ const body = await res.text();
119
+ if (!res.ok) {
120
+ throw new Error(`storage-broker: r2 bucket list failed: ${res.status}
121
+ ${body}`);
122
+ }
123
+ const parsed = parseJson(body, "r2 bucket list");
124
+ if (parsed.success !== true) {
125
+ throw new Error(`storage-broker: r2 bucket list API error: ${body}`);
126
+ }
127
+ return (parsed.result?.buckets ?? []).map((b) => ({ name: b.name }));
114
128
  },
115
129
  async r2Create(name) {
116
- await run("wrangler", ["r2", "bucket", "create", name], env);
130
+ await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
117
131
  }
118
132
  };
119
133
  }
@@ -118,7 +118,7 @@ import {
118
118
  readHouseCredential,
119
119
  registerResource,
120
120
  resolveOwner
121
- } from "./chunk-4W6IEUFE.js";
121
+ } from "./chunk-MUHCRGUF.js";
122
122
  import {
123
123
  __commonJS,
124
124
  __toESM
@@ -8679,7 +8679,7 @@ app3.post("/r2/create", async (c) => {
8679
8679
  });
8680
8680
  var storage_broker_default = app3;
8681
8681
  async function runStorageAudit(root) {
8682
- const { reconcileStorage } = await import("./src-BQ2NYMOT.js");
8682
+ const { reconcileStorage } = await import("./src-OYNQ3ISB.js");
8683
8683
  const session = getSession();
8684
8684
  try {
8685
8685
  const cf = makeCfExec(readHouseCredential(root));
@@ -9,7 +9,7 @@ import {
9
9
  resolveOwner,
10
10
  stripAccountWideTokens,
11
11
  validateMapping
12
- } from "./chunk-4W6IEUFE.js";
12
+ } from "./chunk-MUHCRGUF.js";
13
13
  import "./chunk-PFF6I7KP.js";
14
14
  export {
15
15
  authorizeAccess,