@rubytech/create-maxy-code 0.1.449 → 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.
- package/dist/__tests__/preserve-house-cloudflare.test.js +59 -0
- package/dist/index.js +14 -0
- package/dist/preserve-house-cloudflare.js +37 -0
- package/package.json +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +62 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +9 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -3
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +69 -1
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +39 -3
- package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +67 -0
- package/payload/platform/plugins/cloudflare/bin/cf-token.sh +36 -9
- package/payload/platform/plugins/cloudflare/references/api.md +1 -1
- package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -1
- package/payload/platform/plugins/web-designer/bin/resolve-cf.sh +7 -0
- package/payload/server/{chunk-B57PVHEX.js → chunk-MUHCRGUF.js} +17 -3
- package/payload/server/server.js +2 -2
- package/payload/server/{src-V6RKHCLM.js → src-OYNQ3ISB.js} +1 -1
|
@@ -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
|
@@ -15,6 +15,19 @@ 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);
|
|
@@ -45,6 +58,55 @@ function recordingRun(stdout) {
|
|
|
45
58
|
strict_1.default.equal(calls[0].cmd, "npx");
|
|
46
59
|
strict_1.default.deepEqual(calls[0].args, ["wrangler", "r2", "bucket", "create", "gls-assets"]);
|
|
47
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/);
|
|
109
|
+
});
|
|
48
110
|
(0, node_test_1.default)("non-JSON output throws with the raw body", async () => {
|
|
49
111
|
const { run } = recordingRun("not json");
|
|
50
112
|
const cf = (0, cf_exec_js_1.makeCfExec)(cred, run);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cf-exec.test.js","sourceRoot":"","sources":["../../src/__tests__/cf-exec.test.ts"],"names":[],"mappings":";;;;;AAAA,0DAA6B;AAC7B,gEAAwC;AACxC,
|
|
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;
|
|
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,7 +23,7 @@ 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,
|
|
@@ -45,8 +46,26 @@ function makeCfExec(cred, run = defaultRun) {
|
|
|
45
46
|
return parseJson(stdout, "d1 execute");
|
|
46
47
|
},
|
|
47
48
|
async r2List() {
|
|
48
|
-
|
|
49
|
-
|
|
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 }));
|
|
50
69
|
},
|
|
51
70
|
async r2Create(name) {
|
|
52
71
|
await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cf-exec.js","sourceRoot":"","sources":["../src/cf-exec.ts"],"names":[],"mappings":";AAAA,yEAAyE;AACzE,iFAAiF;AACjF,iFAAiF;;
|
|
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,6 +13,21 @@ 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);
|
|
@@ -47,6 +62,59 @@ test("r2Create shells wrangler r2 bucket create", async () => {
|
|
|
47
62
|
assert.deepEqual(calls[0].args, ["wrangler", "r2", "bucket", "create", "gls-assets"]);
|
|
48
63
|
});
|
|
49
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/);
|
|
116
|
+
});
|
|
117
|
+
|
|
50
118
|
test("non-JSON output throws with the raw body", async () => {
|
|
51
119
|
const { run } = recordingRun("not json");
|
|
52
120
|
const cf = makeCfExec(cred, run);
|
|
@@ -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,7 +48,11 @@ function parseJson(raw: string, context: string): unknown {
|
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
export function makeCfExec(
|
|
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,
|
|
@@ -66,8 +80,30 @@ export function makeCfExec(cred: HouseCredential, run: RunFn = defaultRun): CfEx
|
|
|
66
80
|
return parseJson(stdout, "d1 execute");
|
|
67
81
|
},
|
|
68
82
|
async r2List() {
|
|
69
|
-
|
|
70
|
-
|
|
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 }));
|
|
71
107
|
},
|
|
72
108
|
async r2Create(name) {
|
|
73
109
|
await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
|
|
@@ -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
|
|
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
|
|
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,
|
|
@@ -109,8 +110,21 @@ function makeCfExec(cred, run = defaultRun) {
|
|
|
109
110
|
return parseJson(stdout, "d1 execute");
|
|
110
111
|
},
|
|
111
112
|
async r2List() {
|
|
112
|
-
const
|
|
113
|
-
|
|
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
130
|
await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
|
package/payload/server/server.js
CHANGED
|
@@ -118,7 +118,7 @@ import {
|
|
|
118
118
|
readHouseCredential,
|
|
119
119
|
registerResource,
|
|
120
120
|
resolveOwner
|
|
121
|
-
} from "./chunk-
|
|
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-
|
|
8682
|
+
const { reconcileStorage } = await import("./src-OYNQ3ISB.js");
|
|
8683
8683
|
const session = getSession();
|
|
8684
8684
|
try {
|
|
8685
8685
|
const cf = makeCfExec(readHouseCredential(root));
|