@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.
Files changed (77) hide show
  1. package/dist/__tests__/preserve-house-cloudflare.test.js +81 -2
  2. package/dist/index.js +10 -1
  3. package/dist/preserve-house-cloudflare.js +49 -2
  4. package/package.json +1 -1
  5. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +63 -6
  6. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  7. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.d.ts +2 -0
  8. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.d.ts.map +1 -0
  9. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.js +48 -0
  10. package/payload/platform/lib/storage-broker/dist/__tests__/house-scoped-token.test.js.map +1 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/remediate.test.js +6 -0
  12. package/payload/platform/lib/storage-broker/dist/__tests__/remediate.test.js.map +1 -1
  13. package/payload/platform/lib/storage-broker/dist/bin/remediate-run.d.ts.map +1 -1
  14. package/payload/platform/lib/storage-broker/dist/bin/remediate-run.js +14 -4
  15. package/payload/platform/lib/storage-broker/dist/bin/remediate-run.js.map +1 -1
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +2 -0
  17. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/cf-exec.js +42 -2
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/house-scoped-token.d.ts +9 -0
  21. package/payload/platform/lib/storage-broker/dist/house-scoped-token.d.ts.map +1 -0
  22. package/payload/platform/lib/storage-broker/dist/house-scoped-token.js +60 -0
  23. package/payload/platform/lib/storage-broker/dist/house-scoped-token.js.map +1 -0
  24. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  25. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  27. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/remediate.d.ts.map +1 -1
  29. package/payload/platform/lib/storage-broker/dist/remediate.js +14 -1
  30. package/payload/platform/lib/storage-broker/dist/remediate.js.map +1 -1
  31. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +73 -8
  32. package/payload/platform/lib/storage-broker/src/__tests__/house-scoped-token.test.ts +48 -0
  33. package/payload/platform/lib/storage-broker/src/__tests__/remediate.test.ts +8 -0
  34. package/payload/platform/lib/storage-broker/src/bin/remediate-run.ts +17 -5
  35. package/payload/platform/lib/storage-broker/src/cf-exec.ts +55 -6
  36. package/payload/platform/lib/storage-broker/src/house-scoped-token.ts +72 -0
  37. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  38. package/payload/platform/lib/storage-broker/src/remediate.ts +14 -1
  39. package/payload/platform/plugins/admin/PLUGIN.md +1 -0
  40. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-gate.test.sh +107 -0
  41. package/payload/platform/plugins/admin/hooks/quote-render-gate.sh +74 -0
  42. package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +25 -1
  43. package/payload/platform/plugins/cloudflare/bin/cf-token.sh +9 -1
  44. package/payload/platform/plugins/cloudflare/references/api.md +3 -1
  45. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  46. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.d.ts +2 -0
  47. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.d.ts.map +1 -0
  48. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.js +36 -0
  49. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/house-scoped-token.test.js.map +1 -0
  50. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.d.ts +9 -0
  51. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.d.ts.map +1 -0
  52. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.js +51 -0
  53. package/payload/platform/plugins/scheduling/mcp/dist/lib/house-scoped-token.js.map +1 -0
  54. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +16 -2
  55. package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
  56. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +16 -2
  57. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  58. package/payload/platform/plugins/storage-broker/PLUGIN.md +4 -0
  59. package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
  60. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts +15 -0
  61. package/payload/platform/services/claude-session-manager/dist/fs-watcher.d.ts.map +1 -1
  62. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js +38 -3
  63. package/payload/platform/services/claude-session-manager/dist/fs-watcher.js.map +1 -1
  64. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  65. package/payload/platform/services/claude-session-manager/dist/http-server.js +21 -10
  66. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  67. package/payload/platform/services/claude-session-manager/dist/index.js +2 -0
  68. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  69. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  70. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -1
  71. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  72. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  73. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +7 -0
  74. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  75. package/payload/server/{chunk-MUHCRGUF.js → chunk-SDYRKIYY.js} +89 -5
  76. package/payload/server/server.js +8 -9
  77. 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;AAOD,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"}
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
- const STRIP_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
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;;AAYxE,0CAMC;AAED,wDAMC;AAnBD,yEAAyE;AACzE,0EAA0E;AAC1E,oDAAoD;AACpD,MAAM,UAAU,GAAG,CAAC,sBAAsB,EAAE,mBAAmB,EAAE,gBAAgB,CAAC,CAAC;AAEnF,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
+ {"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 { makeCfExec, type RunFn, type FetchFn } from "../cf-exec.js";
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 hits the Cloudflare R2 API, not wrangler, so it needs a fetch seam.
17
- // A run that throws proves r2List never shells wrangler.
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("r2List must not shell wrangler");
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<{ url: string; init: { method: string; headers: Record<string, string> } }> = [];
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 parses the created uuid", async () => {
52
- const { run } = recordingRun(JSON.stringify({ uuid: "new-db" }));
53
- const cf = makeCfExec(cred, run);
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(accountsDir: string): { apiToken: string; accountId: string } | null {
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
- const cf = makeCfExec(master);
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 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
+ // 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
- const { stdout } = await run("npx", ["wrangler", "d1", "create", name, "--json"], env);
72
- return parseJson(stdout, "d1 create") as { uuid: string };
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
+ }
@@ -10,5 +10,6 @@ export * from "./authorize.js";
10
10
  export * from "./registry.js";
11
11
  export * from "./house-credential.js";
12
12
  export * from "./cf-exec.js";
13
+ export * from "./house-scoped-token.js";
13
14
  export * from "./audit.js";
14
15
  export * from "./remediate.js";
@@ -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
- const STRIP_KEYS = ["CLOUDFLARE_API_TOKEN", "CF_PAGES_D1_TOKEN", "CF_PAGES_TOKEN"];
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.