@rubytech/create-maxy-code 0.1.450 → 0.1.452
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 +81 -2
- package/dist/index.js +10 -1
- package/dist/preserve-house-cloudflare.js +49 -2
- package/package.json +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +63 -6
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.js +48 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.js.map +1 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/remediate.test.js +6 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/remediate.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/bin/remediate-run.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/bin/remediate-run.js +14 -4
- package/payload/platform/lib/storage-broker/dist/bin/remediate-run.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +2 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +42 -2
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-scoped-token.d.ts +9 -0
- package/payload/platform/lib/storage-broker/dist/house-scoped-token.d.ts.map +1 -0
- package/payload/platform/lib/storage-broker/dist/house-scoped-token.js +60 -0
- package/payload/platform/lib/storage-broker/dist/house-scoped-token.js.map +1 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
- package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/index.js +1 -0
- package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/remediate.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/remediate.js +14 -1
- package/payload/platform/lib/storage-broker/dist/remediate.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +73 -8
- package/payload/platform/lib/storage-broker/src/__tests__/house-scoped-token.test.ts +48 -0
- package/payload/platform/lib/storage-broker/src/__tests__/remediate.test.ts +8 -0
- package/payload/platform/lib/storage-broker/src/bin/remediate-run.ts +17 -5
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +55 -6
- package/payload/platform/lib/storage-broker/src/house-scoped-token.ts +72 -0
- package/payload/platform/lib/storage-broker/src/index.ts +1 -0
- package/payload/platform/lib/storage-broker/src/remediate.ts +14 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +107 -0
- package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +74 -0
- package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +25 -1
- package/payload/platform/plugins/cloudflare/bin/cf-token.sh +9 -1
- package/payload/platform/plugins/cloudflare/references/api.md +3 -1
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.js +36 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.d.ts +9 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.js +51 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +16 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +16 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/plugins/storage-broker/PLUGIN.md +4 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +15 -0
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +38 -3
- package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +21 -10
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +2 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +7 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/server/{chunk-MUHCRGUF.js → chunk-SDYRKIYY.js} +89 -5
- package/payload/server/server.js +8 -9
- package/payload/server/{src-OYNQ3ISB.js → src-554BYJMN.js} +5 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Task 1670 — the house credential is a minter. Each broker/calendar consumer
|
|
3
|
+
// mints (and thereafter reuses) a scoped data token from it via cf-token.sh, the
|
|
4
|
+
// single Cloudflare mint/reuse state machine. This wrapper shells that script
|
|
5
|
+
// against the house env file and reads back the persisted key value. The mint
|
|
6
|
+
// logic itself is not duplicated here — it lives only in cf-token.sh.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.resolveHouseScopedToken = resolveHouseScopedToken;
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_child_process_1 = require("node:child_process");
|
|
11
|
+
const defaultRun = (cmd, args, env) => new Promise((resolve, reject) => {
|
|
12
|
+
(0, node_child_process_1.execFile)(cmd, args, { env }, (err, stdout, stderr) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}\n${stderr}`));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
resolve({ stdout: stdout.toString() });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
function readKey(path, key) {
|
|
21
|
+
for (const line of (0, node_fs_1.readFileSync)(path, "utf8").split("\n")) {
|
|
22
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
23
|
+
if (m && m[1] === key)
|
|
24
|
+
return m[2].trim();
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a scoped data token (e.g. "storage" = D1+R2) from the house minter.
|
|
30
|
+
* Shells cf-token.sh, which mints or reuses the scope and persists the key into
|
|
31
|
+
* the house env file, echoing the key name. Reads that key back and returns its
|
|
32
|
+
* value. Throws if the key is absent after the run (never returns the minter).
|
|
33
|
+
*/
|
|
34
|
+
async function resolveHouseScopedToken(scope, houseEnvPath, cfTokenShPath, run = defaultRun) {
|
|
35
|
+
const preexisting = readKey(houseEnvPath, keyForScope(scope)) !== undefined;
|
|
36
|
+
const { stdout } = await run("bash", [cfTokenShPath, scope, houseEnvPath], { ...process.env });
|
|
37
|
+
const keyName = stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
38
|
+
if (!keyName)
|
|
39
|
+
throw new Error(`storage-broker: cf-token.sh ${scope} returned no key name`);
|
|
40
|
+
const value = readKey(houseEnvPath, keyName);
|
|
41
|
+
if (!value) {
|
|
42
|
+
throw new Error(`storage-broker: scoped token ${keyName} absent in ${houseEnvPath} after mint`);
|
|
43
|
+
}
|
|
44
|
+
console.error(`[storage-broker] op=house-scoped-token src=house scope=${scope} action=${preexisting ? "reuse" : "mint"} result=active`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
// The persisted key name for each scope, mirroring cf-token.sh's scope table.
|
|
48
|
+
// Used only to derive the mint/reuse action for the observability line; the
|
|
49
|
+
// authoritative key is whatever cf-token.sh echoes.
|
|
50
|
+
function keyForScope(scope) {
|
|
51
|
+
switch (scope) {
|
|
52
|
+
case "storage":
|
|
53
|
+
return "CF_STORAGE_TOKEN";
|
|
54
|
+
case "calendar-d1":
|
|
55
|
+
return "CF_CALENDAR_D1_TOKEN";
|
|
56
|
+
default:
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=house-scoped-token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"house-scoped-token.js","sourceRoot":"","sources":["../src/house-scoped-token.ts"],"names":[],"mappings":";AAAA,8EAA8E;AAC9E,iFAAiF;AACjF,8EAA8E;AAC9E,8EAA8E;AAC9E,sEAAsE;;AA+BtE,0DAsBC;AAnDD,qCAAuC;AACvC,2DAA8C;AAG9C,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,OAAO,CAAC,IAAY,EAAE,GAAW;IACxC,KAAK,MAAM,IAAI,IAAI,IAAA,sBAAY,EAAC,IAAI,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1D,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,qBAAqB,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG;YAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC5C,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,uBAAuB,CAC3C,KAAa,EACb,YAAoB,EACpB,aAAqB,EACrB,MAAa,UAAU;IAEvB,MAAM,WAAW,GAAG,OAAO,CAAC,YAAY,EAAE,WAAW,CAAC,KAAK,CAAC,CAAC,KAAK,SAAS,CAAC;IAC5E,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,CAC1B,MAAM,EACN,CAAC,aAAa,EAAE,KAAK,EAAE,YAAY,CAAC,EACpC,EAAE,GAAG,OAAO,CAAC,GAAG,EAA4B,CAC7C,CAAC;IACF,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAC9D,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,+BAA+B,KAAK,uBAAuB,CAAC,CAAC;IAC3F,MAAM,KAAK,GAAG,OAAO,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IAC7C,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,gCAAgC,OAAO,cAAc,YAAY,aAAa,CAAC,CAAC;IAClG,CAAC;IACD,OAAO,CAAC,KAAK,CACX,0DAA0D,KAAK,WAAW,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,gBAAgB,CACzH,CAAC;IACF,OAAO,KAAK,CAAC;AACf,CAAC;AAED,8EAA8E;AAC9E,4EAA4E;AAC5E,oDAAoD;AACpD,SAAS,WAAW,CAAC,KAAa;IAChC,QAAQ,KAAK,EAAE,CAAC;QACd,KAAK,SAAS;YACZ,OAAO,kBAAkB,CAAC;QAC5B,KAAK,aAAa;YAChB,OAAO,sBAAsB,CAAC;QAChC;YACE,OAAO,EAAE,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
@@ -2,6 +2,7 @@ export * from "./authorize.js";
|
|
|
2
2
|
export * from "./registry.js";
|
|
3
3
|
export * from "./house-credential.js";
|
|
4
4
|
export * from "./cf-exec.js";
|
|
5
|
+
export * from "./house-scoped-token.js";
|
|
5
6
|
export * from "./audit.js";
|
|
6
7
|
export * from "./remediate.js";
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,cAAc,CAAC;AAC7B,cAAc,yBAAyB,CAAC;AACxC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC"}
|
|
@@ -25,6 +25,7 @@ __exportStar(require("./authorize.js"), exports);
|
|
|
25
25
|
__exportStar(require("./registry.js"), exports);
|
|
26
26
|
__exportStar(require("./house-credential.js"), exports);
|
|
27
27
|
__exportStar(require("./cf-exec.js"), exports);
|
|
28
|
+
__exportStar(require("./house-scoped-token.js"), exports);
|
|
28
29
|
__exportStar(require("./audit.js"), exports);
|
|
29
30
|
__exportStar(require("./remediate.js"), exports);
|
|
30
31
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,mEAAmE;AACnE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,2EAA2E;AAC3E,uEAAuE;AACvE,4DAA4D;;;;;;;;;;;;;;;;AAE5D,iDAA+B;AAC/B,gDAA8B;AAC9B,wDAAsC;AACtC,+CAA6B;AAC7B,6CAA2B;AAC3B,iDAA+B"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,mEAAmE;AACnE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,2EAA2E;AAC3E,uEAAuE;AACvE,4DAA4D;;;;;;;;;;;;;;;;AAE5D,iDAA+B;AAC/B,gDAA8B;AAC9B,wDAAsC;AACtC,+CAA6B;AAC7B,0DAAwC;AACxC,6CAA2B;AAC3B,iDAA+B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remediate.d.ts","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;
|
|
1
|
+
{"version":3,"file":"remediate.d.ts","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAoBD,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,EAAE,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,aAAa,CAGf;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM/D"}
|
|
@@ -10,7 +10,20 @@ exports.stripAccountWideTokens = stripAccountWideTokens;
|
|
|
10
10
|
// Account-wide credentials stripped from a sub-account's cloudflare.env.
|
|
11
11
|
// CLOUDFLARE_ACCOUNT_ID is intentionally kept: it is an identifier, not a
|
|
12
12
|
// credential, and does not grant access on its own.
|
|
13
|
-
|
|
13
|
+
// Account-wide-reach tokens that must never persist in a sub-account secrets
|
|
14
|
+
// file: the master and every scoped DATA token that reaches the whole shared
|
|
15
|
+
// account. CF_STORAGE_TOKEN (D1+R2) and CF_CALENDAR_D1_TOKEN (D1) are house-code
|
|
16
|
+
// data scopes (Task 1670) minted account-scoped, so a copy in a sub-account file
|
|
17
|
+
// would let that tenant reach every other tenant's D1/R2 — strip them too. The
|
|
18
|
+
// account's own zone/pages hosting tokens (CF_DNS_TOKEN, etc.) are narrow and
|
|
19
|
+
// stay.
|
|
20
|
+
const STRIP_KEYS = [
|
|
21
|
+
"CLOUDFLARE_API_TOKEN",
|
|
22
|
+
"CF_PAGES_D1_TOKEN",
|
|
23
|
+
"CF_PAGES_TOKEN",
|
|
24
|
+
"CF_STORAGE_TOKEN",
|
|
25
|
+
"CF_CALENDAR_D1_TOKEN",
|
|
26
|
+
];
|
|
14
27
|
function validateMapping(enumerated, mapping) {
|
|
15
28
|
const missing = enumerated.filter((name) => !(name in mapping));
|
|
16
29
|
return { ok: missing.length === 0, missing };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remediate.js","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":";AAAA,+EAA+E;AAC/E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,wEAAwE;;
|
|
1
|
+
{"version":3,"file":"remediate.js","sourceRoot":"","sources":["../src/remediate.ts"],"names":[],"mappings":";AAAA,+EAA+E;AAC/E,6EAA6E;AAC7E,+EAA+E;AAC/E,gFAAgF;AAChF,wEAAwE;;AAyBxE,0CAMC;AAED,wDAMC;AAhCD,yEAAyE;AACzE,0EAA0E;AAC1E,oDAAoD;AACpD,6EAA6E;AAC7E,6EAA6E;AAC7E,iFAAiF;AACjF,iFAAiF;AACjF,+EAA+E;AAC/E,8EAA8E;AAC9E,QAAQ;AACR,MAAM,UAAU,GAAG;IACjB,sBAAsB;IACtB,mBAAmB;IACnB,gBAAgB;IAChB,kBAAkB;IAClB,sBAAsB;CACvB,CAAC;AAEF,SAAgB,eAAe,CAC7B,UAAoB,EACpB,OAA+B;IAE/B,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,OAAO,CAAC,CAAC,CAAC;IAChE,OAAO,EAAE,EAAE,EAAE,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;AAC/C,CAAC;AAED,SAAgB,sBAAsB,CAAC,QAAgB;IACrD,MAAM,IAAI,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE;QAChD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,+BAA+B,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QAC7D,OAAO,CAAC,CAAC,GAAG,IAAI,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACzB,CAAC"}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { makeCfExec, makeHouseCfExec, type RunFn, type FetchFn } from "../cf-exec.js";
|
|
4
7
|
|
|
5
8
|
const cred = { apiToken: "cfat_x", accountId: "acc-h" };
|
|
6
9
|
|
|
@@ -13,14 +16,18 @@ function recordingRun(stdout: string) {
|
|
|
13
16
|
return { run, calls };
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
// r2List
|
|
17
|
-
// A run that throws proves
|
|
19
|
+
// r2List and d1Create hit the Cloudflare API, not wrangler, so they need a
|
|
20
|
+
// fetch seam. A run that throws proves they never shell wrangler — in
|
|
21
|
+
// particular that no `--json` reaches a `wrangler d1 create` invocation.
|
|
18
22
|
const throwingRun: RunFn = async () => {
|
|
19
|
-
throw new Error("
|
|
23
|
+
throw new Error("must not shell wrangler");
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
function recordingFetch(response: { ok: boolean; status: number; body: string }) {
|
|
23
|
-
const calls: Array<{
|
|
27
|
+
const calls: Array<{
|
|
28
|
+
url: string;
|
|
29
|
+
init: { method: string; headers: Record<string, string>; body?: string };
|
|
30
|
+
}> = [];
|
|
24
31
|
const fetchFn: FetchFn = async (url, init) => {
|
|
25
32
|
calls.push({ url, init });
|
|
26
33
|
return { ok: response.ok, status: response.status, text: async () => response.body };
|
|
@@ -48,10 +55,44 @@ test("d1Query runs execute --remote with the SQL", async () => {
|
|
|
48
55
|
]);
|
|
49
56
|
});
|
|
50
57
|
|
|
51
|
-
test("d1Create
|
|
52
|
-
const {
|
|
53
|
-
|
|
58
|
+
test("d1Create creates the database via the D1 API with the house token, never wrangler", async () => {
|
|
59
|
+
const { fetchFn, calls } = recordingFetch({
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 200,
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
success: true,
|
|
64
|
+
errors: [],
|
|
65
|
+
messages: [],
|
|
66
|
+
result: { uuid: "new-db", name: "gls-new", created_at: "2026-01-01T00:00:00Z" },
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
54
70
|
assert.deepEqual(await cf.d1Create("gls-new"), { uuid: "new-db" });
|
|
71
|
+
assert.equal(calls[0].url, "https://api.cloudflare.com/client/v4/accounts/acc-h/d1/database");
|
|
72
|
+
assert.equal(calls[0].init.method, "POST");
|
|
73
|
+
assert.equal(calls[0].init.headers.Authorization, "Bearer cfat_x");
|
|
74
|
+
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
|
|
75
|
+
assert.deepEqual(JSON.parse(calls[0].init.body ?? ""), { name: "gls-new" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("d1Create throws with the API error body on a non-2xx response", async () => {
|
|
79
|
+
const { fetchFn } = recordingFetch({
|
|
80
|
+
ok: false,
|
|
81
|
+
status: 403,
|
|
82
|
+
body: JSON.stringify({ success: false, errors: [{ code: 10000, message: "Authentication error" }] }),
|
|
83
|
+
});
|
|
84
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
85
|
+
await assert.rejects(() => cf.d1Create("gls-new"), /403[\s\S]*Authentication error/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("d1Create throws on a 200 whose body is not a success envelope, retaining the body", async () => {
|
|
89
|
+
const { fetchFn } = recordingFetch({
|
|
90
|
+
ok: true,
|
|
91
|
+
status: 200,
|
|
92
|
+
body: JSON.stringify({ unexpected: "shape" }),
|
|
93
|
+
});
|
|
94
|
+
const cf = makeCfExec(cred, throwingRun, fetchFn);
|
|
95
|
+
await assert.rejects(() => cf.d1Create("gls-new"), /unexpected/);
|
|
55
96
|
});
|
|
56
97
|
|
|
57
98
|
test("r2Create shells wrangler r2 bucket create", async () => {
|
|
@@ -120,3 +161,27 @@ test("non-JSON output throws with the raw body", async () => {
|
|
|
120
161
|
const cf = makeCfExec(cred, run);
|
|
121
162
|
await assert.rejects(() => cf.d1List(), /not json/);
|
|
122
163
|
});
|
|
164
|
+
|
|
165
|
+
// Task 1670 — makeHouseCfExec resolves a scoped storage token from the house
|
|
166
|
+
// minter via cf-token.sh and uses THAT token (never the minter) for wrangler.
|
|
167
|
+
test("makeHouseCfExec runs wrangler with the scoped token, not the minter", async () => {
|
|
168
|
+
const platformRoot = mkdtempSync(join(tmpdir(), "house-cfexec-"));
|
|
169
|
+
const cfg = join(platformRoot, "config");
|
|
170
|
+
mkdirSync(cfg, { recursive: true });
|
|
171
|
+
const houseEnv = join(cfg, "cloudflare-house.env");
|
|
172
|
+
writeFileSync(houseEnv, "CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc-h\n");
|
|
173
|
+
|
|
174
|
+
let sawToken = "";
|
|
175
|
+
const run: RunFn = async (_cmd, args, env) => {
|
|
176
|
+
if (args.some((a) => a.endsWith("cf-token.sh"))) {
|
|
177
|
+
appendFileSync(houseEnv, "CF_STORAGE_TOKEN=scoped_tok\n");
|
|
178
|
+
return { stdout: "CF_STORAGE_TOKEN\n" };
|
|
179
|
+
}
|
|
180
|
+
sawToken = env.CLOUDFLARE_API_TOKEN;
|
|
181
|
+
return { stdout: "[]" };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const cf = await makeHouseCfExec(platformRoot, run);
|
|
185
|
+
await cf.d1List();
|
|
186
|
+
assert.equal(sawToken, "scoped_tok", "wrangler saw the scoped token, not the minter");
|
|
187
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Task 1670 — resolveHouseScopedToken shells cf-token.sh against the house env
|
|
2
|
+
// file and reads back the persisted key value. The cf-token.sh invocation is
|
|
3
|
+
// mocked via the injected RunFn (it appends the key and echoes the key name).
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtempSync, writeFileSync, appendFileSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { resolveHouseScopedToken } from "../house-scoped-token.js";
|
|
10
|
+
import type { RunFn } from "../cf-exec.js";
|
|
11
|
+
|
|
12
|
+
function houseFile(contents: string): string {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "hst-"));
|
|
14
|
+
const p = join(dir, "cloudflare-house.env");
|
|
15
|
+
writeFileSync(p, contents);
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("mints and reads back the scoped token value", async () => {
|
|
20
|
+
const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\n");
|
|
21
|
+
const run: RunFn = async (_cmd, args) => {
|
|
22
|
+
assert.equal(args[1], "storage", "scope passed to cf-token.sh");
|
|
23
|
+
appendFileSync(p, "CF_STORAGE_TOKEN=minted_value\n");
|
|
24
|
+
return { stdout: "CF_STORAGE_TOKEN\n" };
|
|
25
|
+
};
|
|
26
|
+
const tok = await resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run);
|
|
27
|
+
assert.equal(tok, "minted_value");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("reuse: a pre-existing key resolves to its value", async () => {
|
|
31
|
+
const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\nCF_STORAGE_TOKEN=already_here\n");
|
|
32
|
+
// cf-token.sh reuse path writes nothing new, still echoes the key.
|
|
33
|
+
const run: RunFn = async () => ({ stdout: "CF_STORAGE_TOKEN\n" });
|
|
34
|
+
const tok = await resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run);
|
|
35
|
+
assert.equal(tok, "already_here");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("throws when the persisted key is absent after the run", async () => {
|
|
39
|
+
const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\n");
|
|
40
|
+
const run: RunFn = async () => ({ stdout: "CF_STORAGE_TOKEN\n" }); // writes nothing
|
|
41
|
+
await assert.rejects(() => resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("throws when cf-token.sh returns no key name", async () => {
|
|
45
|
+
const p = houseFile("CLOUDFLARE_API_TOKEN=cfat_minter\nCLOUDFLARE_ACCOUNT_ID=acc\n");
|
|
46
|
+
const run: RunFn = async () => ({ stdout: "\n" });
|
|
47
|
+
await assert.rejects(() => resolveHouseScopedToken("storage", p, "/x/cf-token.sh", run));
|
|
48
|
+
});
|
|
@@ -19,3 +19,11 @@ test("strips only account-wide token lines, keeps the account id and scoped toke
|
|
|
19
19
|
"CLOUDFLARE_ACCOUNT_ID=acc\nCLOUDFLARE_API_TOKEN=cfat_x\nCF_PAGES_D1_TOKEN=y\nCF_DNS_TOKEN=keep\n";
|
|
20
20
|
assert.equal(stripAccountWideTokens(before), "CLOUDFLARE_ACCOUNT_ID=acc\nCF_DNS_TOKEN=keep\n");
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
// Task 1670 — the house-code data scopes reach the whole shared account, so a
|
|
24
|
+
// stray copy in a sub-account file is a cross-tenant leak and must be stripped.
|
|
25
|
+
test("strips the account-wide data scopes (storage, calendar-d1), keeps zone hosting tokens", () => {
|
|
26
|
+
const before =
|
|
27
|
+
"CLOUDFLARE_ACCOUNT_ID=acc\nCF_STORAGE_TOKEN=cfat_d1r2\nCF_CALENDAR_D1_TOKEN=cfat_d1\nCF_DNS_TOKEN=keep\n";
|
|
28
|
+
assert.equal(stripAccountWideTokens(before), "CLOUDFLARE_ACCOUNT_ID=acc\nCF_DNS_TOKEN=keep\n");
|
|
29
|
+
});
|
|
@@ -14,6 +14,7 @@ import { validateMapping, stripAccountWideTokens } from "../remediate.js";
|
|
|
14
14
|
import { registerResource, type StorageResource } from "../registry.js";
|
|
15
15
|
import { readHouseCredential } from "../house-credential.js";
|
|
16
16
|
import { makeCfExec } from "../cf-exec.js";
|
|
17
|
+
import { resolveHouseScopedToken } from "../house-scoped-token.js";
|
|
17
18
|
|
|
18
19
|
export interface RemediationDeps {
|
|
19
20
|
cfD1(): Promise<Array<{ name: string; cfResourceId: string }>>;
|
|
@@ -77,7 +78,9 @@ export async function runRemediation(deps: RemediationDeps): Promise<Remediation
|
|
|
77
78
|
|
|
78
79
|
// --- on-device CLI (untested glue) ----------------------------------------
|
|
79
80
|
|
|
80
|
-
function findMasterFromAccounts(
|
|
81
|
+
function findMasterFromAccounts(
|
|
82
|
+
accountsDir: string,
|
|
83
|
+
): { apiToken: string; accountId: string; path: string } | null {
|
|
81
84
|
if (!existsSync(accountsDir)) return null;
|
|
82
85
|
for (const accountId of readdirSync(accountsDir)) {
|
|
83
86
|
const p = join(accountsDir, accountId, "secrets", "cloudflare.env");
|
|
@@ -88,7 +91,7 @@ function findMasterFromAccounts(accountsDir: string): { apiToken: string; accoun
|
|
|
88
91
|
if (m) env[m[1]] = m[2].trim();
|
|
89
92
|
}
|
|
90
93
|
if (env.CLOUDFLARE_API_TOKEN && env.CLOUDFLARE_ACCOUNT_ID) {
|
|
91
|
-
return { apiToken: env.CLOUDFLARE_API_TOKEN, accountId: env.CLOUDFLARE_ACCOUNT_ID };
|
|
94
|
+
return { apiToken: env.CLOUDFLARE_API_TOKEN, accountId: env.CLOUDFLARE_ACCOUNT_ID, path: p };
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
return null;
|
|
@@ -110,10 +113,13 @@ async function main(): Promise<void> {
|
|
|
110
113
|
const mapping = JSON.parse(readFileSync(mappingFile, "utf8")) as Record<string, string>;
|
|
111
114
|
|
|
112
115
|
// The master is wherever it lives today: the house file if already relocated,
|
|
113
|
-
// else the first sub-account secrets file that still carries it.
|
|
116
|
+
// else the first sub-account secrets file that still carries it. masterFile is
|
|
117
|
+
// the file cf-token.sh mints the scoped enumeration token against.
|
|
114
118
|
let master: { apiToken: string; accountId: string };
|
|
119
|
+
let masterFile: string;
|
|
115
120
|
try {
|
|
116
121
|
master = readHouseCredential(platformRoot);
|
|
122
|
+
masterFile = configPath;
|
|
117
123
|
} catch {
|
|
118
124
|
const found = findMasterFromAccounts(accountsDir);
|
|
119
125
|
if (!found) {
|
|
@@ -121,10 +127,16 @@ async function main(): Promise<void> {
|
|
|
121
127
|
process.exit(2);
|
|
122
128
|
return;
|
|
123
129
|
}
|
|
124
|
-
master = found;
|
|
130
|
+
master = { apiToken: found.apiToken, accountId: found.accountId };
|
|
131
|
+
masterFile = found.path;
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
|
|
134
|
+
// Task 1670 — the master is a minter; enumerate D1/R2 through a scoped storage
|
|
135
|
+
// token minted from it, never the minter directly. `master` (the raw minter)
|
|
136
|
+
// is still what runRemediation relocates into the house file below.
|
|
137
|
+
const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
|
|
138
|
+
const scoped = await resolveHouseScopedToken("storage", masterFile, cfTokenSh);
|
|
139
|
+
const cf = makeCfExec({ apiToken: scoped, accountId: master.accountId });
|
|
128
140
|
const neo4j = (await import("neo4j-driver")) as any;
|
|
129
141
|
const driver = neo4j.default.driver(
|
|
130
142
|
process.env.NEO4J_URI,
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// wrangler. `run` is injectable so the wiring is unit-testable without wrangler.
|
|
4
4
|
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
+
import { join } from "node:path";
|
|
6
7
|
import type { HouseCredential } from "./house-credential.js";
|
|
8
|
+
import { readHouseCredential } from "./house-credential.js";
|
|
9
|
+
import { resolveHouseScopedToken } from "./house-scoped-token.js";
|
|
7
10
|
|
|
8
11
|
export type RunFn = (
|
|
9
12
|
cmd: string,
|
|
@@ -11,12 +14,13 @@ export type RunFn = (
|
|
|
11
14
|
env: Record<string, string>,
|
|
12
15
|
) => Promise<{ stdout: string }>;
|
|
13
16
|
|
|
14
|
-
// The HTTP seam for the Cloudflare API. Injectable so r2List
|
|
15
|
-
// without a network. Minimal subset of the global fetch:
|
|
16
|
-
// once via text() and
|
|
17
|
+
// The HTTP seam for the Cloudflare API. Injectable so r2List and d1Create are
|
|
18
|
+
// unit-testable without a network. Minimal subset of the global fetch: callers
|
|
19
|
+
// read the body once via text() and branch on ok/status. `body` is set for the
|
|
20
|
+
// POST paths (d1Create); GET paths (r2List) omit it.
|
|
17
21
|
export type FetchFn = (
|
|
18
22
|
url: string,
|
|
19
|
-
init: { method: string; headers: Record<string, string
|
|
23
|
+
init: { method: string; headers: Record<string, string>; body?: string },
|
|
20
24
|
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
|
|
21
25
|
|
|
22
26
|
export interface CfExec {
|
|
@@ -68,8 +72,34 @@ export function makeCfExec(
|
|
|
68
72
|
return parseJson(stdout, "d1 list") as { name: string; uuid: string }[];
|
|
69
73
|
},
|
|
70
74
|
async d1Create(name) {
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
// `wrangler d1 create` has no --json flag (unlike `d1 list`/`d1 execute`),
|
|
76
|
+
// so shelling it with --json is always rejected. The D1 API creates the
|
|
77
|
+
// database and returns its record — incl. the uuid — as JSON directly.
|
|
78
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/d1/database`;
|
|
79
|
+
const res = await fetchFn(url, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
Authorization: `Bearer ${cred.apiToken}`,
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ name }),
|
|
86
|
+
});
|
|
87
|
+
const body = await res.text();
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new Error(`storage-broker: d1 create failed: ${res.status}\n${body}`);
|
|
90
|
+
}
|
|
91
|
+
const parsed = parseJson(body, "d1 create") as {
|
|
92
|
+
success?: boolean;
|
|
93
|
+
errors?: unknown;
|
|
94
|
+
result?: { uuid?: string };
|
|
95
|
+
};
|
|
96
|
+
// Require an explicit success envelope with a uuid. A 2xx whose body is
|
|
97
|
+
// not a well-formed `success:true` response is an anomaly to surface with
|
|
98
|
+
// its body, never a silently uuid-less create.
|
|
99
|
+
if (parsed.success !== true || typeof parsed.result?.uuid !== "string") {
|
|
100
|
+
throw new Error(`storage-broker: d1 create API error: ${body}`);
|
|
101
|
+
}
|
|
102
|
+
return { uuid: parsed.result.uuid };
|
|
73
103
|
},
|
|
74
104
|
async d1Query(name, sql) {
|
|
75
105
|
const { stdout } = await run(
|
|
@@ -110,3 +140,22 @@ export function makeCfExec(
|
|
|
110
140
|
},
|
|
111
141
|
};
|
|
112
142
|
}
|
|
143
|
+
|
|
144
|
+
// Task 1670 — build a CfExec whose token is a D1+R2 "storage" scope minted from
|
|
145
|
+
// the house minter, never the minter used directly (a minter cannot run
|
|
146
|
+
// wrangler d1/r2 or hit the D1/R2 API — it returns 10000). cf-token.sh
|
|
147
|
+
// mints-or-reuses and persists the scope house-side; the resolved value is used
|
|
148
|
+
// for both the wrangler run env and the API Bearer. The two UI-route broker
|
|
149
|
+
// sites and remediate-run construct their CfExec through the house minter this
|
|
150
|
+
// way instead of `makeCfExec(readHouseCredential(...))`.
|
|
151
|
+
export async function makeHouseCfExec(
|
|
152
|
+
platformRoot: string,
|
|
153
|
+
run: RunFn = defaultRun,
|
|
154
|
+
fetchFn: FetchFn = defaultFetch,
|
|
155
|
+
): Promise<CfExec> {
|
|
156
|
+
const cred = readHouseCredential(platformRoot);
|
|
157
|
+
const houseEnvPath = join(platformRoot, "config", "cloudflare-house.env");
|
|
158
|
+
const cfTokenSh = join(platformRoot, "plugins/cloudflare/bin/cf-token.sh");
|
|
159
|
+
const scoped = await resolveHouseScopedToken("storage", houseEnvPath, cfTokenSh, run);
|
|
160
|
+
return makeCfExec({ apiToken: scoped, accountId: cred.accountId }, run, fetchFn);
|
|
161
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Task 1670 — the house credential is a minter. Each broker/calendar consumer
|
|
2
|
+
// mints (and thereafter reuses) a scoped data token from it via cf-token.sh, the
|
|
3
|
+
// single Cloudflare mint/reuse state machine. This wrapper shells that script
|
|
4
|
+
// against the house env file and reads back the persisted key value. The mint
|
|
5
|
+
// logic itself is not duplicated here — it lives only in cf-token.sh.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import type { RunFn } from "./cf-exec.js";
|
|
10
|
+
|
|
11
|
+
const defaultRun: RunFn = (cmd, args, env) =>
|
|
12
|
+
new Promise((resolve, reject) => {
|
|
13
|
+
execFile(cmd, args, { env }, (err, stdout, stderr) => {
|
|
14
|
+
if (err) {
|
|
15
|
+
reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}\n${stderr}`));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
resolve({ stdout: stdout.toString() });
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function readKey(path: string, key: string): string | undefined {
|
|
23
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
24
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
25
|
+
if (m && m[1] === key) return m[2].trim();
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a scoped data token (e.g. "storage" = D1+R2) from the house minter.
|
|
32
|
+
* Shells cf-token.sh, which mints or reuses the scope and persists the key into
|
|
33
|
+
* the house env file, echoing the key name. Reads that key back and returns its
|
|
34
|
+
* value. Throws if the key is absent after the run (never returns the minter).
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveHouseScopedToken(
|
|
37
|
+
scope: string,
|
|
38
|
+
houseEnvPath: string,
|
|
39
|
+
cfTokenShPath: string,
|
|
40
|
+
run: RunFn = defaultRun,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const preexisting = readKey(houseEnvPath, keyForScope(scope)) !== undefined;
|
|
43
|
+
const { stdout } = await run(
|
|
44
|
+
"bash",
|
|
45
|
+
[cfTokenShPath, scope, houseEnvPath],
|
|
46
|
+
{ ...process.env } as Record<string, string>,
|
|
47
|
+
);
|
|
48
|
+
const keyName = stdout.trim().split("\n").pop()?.trim() ?? "";
|
|
49
|
+
if (!keyName) throw new Error(`storage-broker: cf-token.sh ${scope} returned no key name`);
|
|
50
|
+
const value = readKey(houseEnvPath, keyName);
|
|
51
|
+
if (!value) {
|
|
52
|
+
throw new Error(`storage-broker: scoped token ${keyName} absent in ${houseEnvPath} after mint`);
|
|
53
|
+
}
|
|
54
|
+
console.error(
|
|
55
|
+
`[storage-broker] op=house-scoped-token src=house scope=${scope} action=${preexisting ? "reuse" : "mint"} result=active`,
|
|
56
|
+
);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The persisted key name for each scope, mirroring cf-token.sh's scope table.
|
|
61
|
+
// Used only to derive the mint/reuse action for the observability line; the
|
|
62
|
+
// authoritative key is whatever cf-token.sh echoes.
|
|
63
|
+
function keyForScope(scope: string): string {
|
|
64
|
+
switch (scope) {
|
|
65
|
+
case "storage":
|
|
66
|
+
return "CF_STORAGE_TOKEN";
|
|
67
|
+
case "calendar-d1":
|
|
68
|
+
return "CF_CALENDAR_D1_TOKEN";
|
|
69
|
+
default:
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -12,7 +12,20 @@ export interface MappingResult {
|
|
|
12
12
|
// Account-wide credentials stripped from a sub-account's cloudflare.env.
|
|
13
13
|
// CLOUDFLARE_ACCOUNT_ID is intentionally kept: it is an identifier, not a
|
|
14
14
|
// credential, and does not grant access on its own.
|
|
15
|
-
|
|
15
|
+
// Account-wide-reach tokens that must never persist in a sub-account secrets
|
|
16
|
+
// file: the master and every scoped DATA token that reaches the whole shared
|
|
17
|
+
// account. CF_STORAGE_TOKEN (D1+R2) and CF_CALENDAR_D1_TOKEN (D1) are house-code
|
|
18
|
+
// data scopes (Task 1670) minted account-scoped, so a copy in a sub-account file
|
|
19
|
+
// would let that tenant reach every other tenant's D1/R2 — strip them too. The
|
|
20
|
+
// account's own zone/pages hosting tokens (CF_DNS_TOKEN, etc.) are narrow and
|
|
21
|
+
// stay.
|
|
22
|
+
const STRIP_KEYS = [
|
|
23
|
+
"CLOUDFLARE_API_TOKEN",
|
|
24
|
+
"CF_PAGES_D1_TOKEN",
|
|
25
|
+
"CF_PAGES_TOKEN",
|
|
26
|
+
"CF_STORAGE_TOKEN",
|
|
27
|
+
"CF_CALENDAR_D1_TOKEN",
|
|
28
|
+
];
|
|
16
29
|
|
|
17
30
|
export function validateMapping(
|
|
18
31
|
enumerated: string[],
|
|
@@ -180,6 +180,7 @@ Tools are available via the `admin` MCP server.
|
|
|
180
180
|
|
|
181
181
|
## Hooks
|
|
182
182
|
|
|
183
|
+
- `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. Blocks (exit 2) a `browser-pdf-save` whose output path is a client-quote artefact (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) when no render receipt exists at `quoting/jobs/<jobId>/render-receipt.json` — the receipt `quote-render` writes only after its reconcile / margin-leak / link-liveness gates all pass. A hand-authored quote (the SiteDesk 2026-07-14 bypass: `Write` HTML → `browser-pdf-save`, `quote-render` never called, an internal back-test caveat naming another client folded into the free-text region) has no receipt, so the print is refused rather than delivered. **Block message:** `Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> detail=client quote PDF has no render receipt` (or `reason=bad-jobid` when the filename does not resolve to a job). Every non-quote PDF — brochures, invoices, the admin-scope internal view — and the deterministic path itself (receipt present) pass at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
|
|
183
184
|
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
184
185
|
- `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
|
|
185
186
|
- `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your channel reply tool and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook.
|