@rubytech/create-maxy-code 0.1.456 → 0.1.458

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 (104) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/mcp-lifeline/dist/index.d.ts.map +1 -1
  3. package/payload/platform/lib/mcp-lifeline/dist/index.js +22 -1
  4. package/payload/platform/lib/mcp-lifeline/dist/index.js.map +1 -1
  5. package/payload/platform/lib/mcp-lifeline/src/__tests__/lifeline.test.ts +26 -0
  6. package/payload/platform/lib/mcp-lifeline/src/index.ts +24 -1
  7. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +135 -0
  8. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  9. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.js +61 -28
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
  13. package/payload/platform/lib/storage-broker/dist/house-credential.js +6 -2
  14. package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
  15. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +153 -0
  16. package/payload/platform/lib/storage-broker/src/cf-exec.ts +85 -41
  17. package/payload/platform/lib/storage-broker/src/house-credential.ts +6 -2
  18. package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +145 -4
  19. package/payload/platform/plugins/cloudflare/bin/cf-token.sh +60 -3
  20. package/payload/platform/plugins/cloudflare/references/api.md +1 -1
  21. package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -1
  22. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-erase.test.d.ts +2 -0
  23. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-erase.test.d.ts.map +1 -0
  24. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-erase.test.js +108 -0
  25. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-erase.test.js.map +1 -0
  26. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-export.test.d.ts +2 -0
  27. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-export.test.d.ts.map +1 -0
  28. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-export.test.js +57 -0
  29. package/payload/platform/plugins/contacts/mcp/dist/tools/__tests__/contact-export.test.js.map +1 -0
  30. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.js +2 -2
  31. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-export.js +1 -1
  32. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +16 -3
  33. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  34. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +108 -1
  35. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
  36. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +42 -15
  37. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
  38. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +32 -1
  39. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
  40. package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.d.ts.map +1 -1
  41. package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js +3 -5
  42. package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js.map +1 -1
  43. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +7 -0
  44. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  45. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +17 -3
  46. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  47. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +6 -1
  48. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  49. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +6 -1
  50. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  51. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  52. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +3 -4
  53. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  54. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
  55. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +2 -4
  56. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
  57. package/payload/platform/plugins/outlook/references/graph-surfaces.md +14 -2
  58. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  59. package/payload/platform/services/claude-session-manager/dist/index.js +1 -1
  60. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  61. package/payload/platform/services/claude-session-manager/dist/stuck-turn-recover.d.ts +22 -2
  62. package/payload/platform/services/claude-session-manager/dist/stuck-turn-recover.d.ts.map +1 -1
  63. package/payload/platform/services/claude-session-manager/dist/stuck-turn-recover.js +37 -10
  64. package/payload/platform/services/claude-session-manager/dist/stuck-turn-recover.js.map +1 -1
  65. package/payload/server/{chunk-SDYRKIYY.js → chunk-64FGYKNZ.js} +51 -29
  66. package/payload/server/public/assets/{AdminLoginScreens-Cl7VBbPS.js → AdminLoginScreens-BFbpkEUS.js} +1 -1
  67. package/payload/server/public/assets/{AdminShell-lBaQG3aT.js → AdminShell-CdOGObaZ.js} +1 -1
  68. package/payload/server/public/assets/{Checkbox-P8PNtn8n.js → Checkbox-RFD_F0c0.js} +1 -1
  69. package/payload/server/public/assets/{admin-CB6VgLUJ.js → admin-CYGdL-1O.js} +1 -1
  70. package/payload/server/public/assets/{browser-CKH8YoIg.js → browser-DVfa_plj.js} +1 -1
  71. package/payload/server/public/assets/{calendar-xQ-OEVti.js → calendar-JC_Dxa4n.js} +1 -1
  72. package/payload/server/public/assets/chat-Yf8Nkop8.js +1 -0
  73. package/payload/server/public/assets/chevron-left-CAiAUBxO.js +1 -0
  74. package/payload/server/public/assets/data-hqdYXaEn.js +1 -0
  75. package/payload/server/public/assets/graph-hBUrRSkr.js +51 -0
  76. package/payload/server/public/assets/{graph-labels-DbL7Cxod.js → graph-labels-CHh6zs8t.js} +1 -1
  77. package/payload/server/public/assets/{maximize-2-Up8UP3Iu.js → maximize-2-DjxE4rm9.js} +1 -1
  78. package/payload/server/public/assets/{operator-BE366HH-.js → operator-BqU9SQuu.js} +1 -1
  79. package/payload/server/public/assets/{page-Wvb1S03b.js → page-B4c4_4HP.js} +1 -1
  80. package/payload/server/public/assets/{page-CQjjQz0V.js → page-DiQhU6lD.js} +1 -1
  81. package/payload/server/public/assets/{public-ZkXB-CHy.js → public-Cu_eALgY.js} +1 -1
  82. package/payload/server/public/assets/{rotate-ccw-TTz_MVO0.js → rotate-ccw-F9DsSTSJ.js} +1 -1
  83. package/payload/server/public/assets/{tasks-DNCoSRkP.js → tasks-CE-D0f50.js} +1 -1
  84. package/payload/server/public/assets/{time-entry-format-BooKdDRT.js → time-entry-format-D2rsWhov.js} +1 -1
  85. package/payload/server/public/assets/{useCopyFeedback-C23_n92I.js → useCopyFeedback-QxOmoobV.js} +1 -1
  86. package/payload/server/public/assets/{useSelectionMode-c4oZYAW0.js → useSelectionMode-RIzNqFjV.js} +1 -1
  87. package/payload/server/public/assets/{useSubAccountSwitcher-CHJo5leK.css → useSubAccountSwitcher-BW_fK1Nt.css} +1 -1
  88. package/payload/server/public/assets/{useVoiceRecorder-BkxW-GL1.js → useVoiceRecorder-WrWurSYI.js} +1 -1
  89. package/payload/server/public/browser.html +5 -5
  90. package/payload/server/public/calendar.html +6 -6
  91. package/payload/server/public/chat.html +12 -12
  92. package/payload/server/public/data.html +10 -10
  93. package/payload/server/public/graph.html +11 -11
  94. package/payload/server/public/index.html +13 -13
  95. package/payload/server/public/operator.html +14 -14
  96. package/payload/server/public/public.html +12 -12
  97. package/payload/server/public/tasks.html +5 -5
  98. package/payload/server/server.js +2 -2
  99. package/payload/server/{src-554BYJMN.js → src-S7C4P6TT.js} +1 -1
  100. package/payload/server/public/assets/chat-DH8K5TeZ.js +0 -1
  101. package/payload/server/public/assets/chevron-left-Ceky-ebO.js +0 -1
  102. package/payload/server/public/assets/data-Cvv8Kg9J.js +0 -1
  103. package/payload/server/public/assets/graph-CrEmAyVE.js +0 -51
  104. /package/payload/server/public/assets/{useSubAccountSwitcher-6UoTftbJ.js → useSubAccountSwitcher-wo_qGW-I.js} +0 -0
@@ -35,6 +35,33 @@ function recordingFetch(response: { ok: boolean; status: number; body: string })
35
35
  return { fetchFn, calls };
36
36
  }
37
37
 
38
+ // r2List follows the R2 pagination cursor, so its tests need a fetch that
39
+ // returns a different response per call. recordingFetch returns one fixed
40
+ // response for every call and stays as-is for the single-request paths.
41
+ function sequenceFetch(responses: Array<{ ok: boolean; status: number; body: string }>) {
42
+ const calls: Array<{
43
+ url: string;
44
+ init: { method: string; headers: Record<string, string>; body?: string };
45
+ }> = [];
46
+ const fetchFn: FetchFn = async (url, init) => {
47
+ calls.push({ url, init });
48
+ const r = responses[calls.length - 1];
49
+ if (!r) throw new Error(`unexpected fetch call #${calls.length} to ${url}`);
50
+ return { ok: r.ok, status: r.status, text: async () => r.body };
51
+ };
52
+ return { fetchFn, calls };
53
+ }
54
+
55
+ function bucketPage(names: string[], cursor?: string) {
56
+ return JSON.stringify({
57
+ success: true,
58
+ errors: [],
59
+ messages: [],
60
+ result: { buckets: names.map((name) => ({ name, creation_date: "2026-01-01T00:00:00Z" })) },
61
+ result_info: cursor === undefined ? {} : { cursor, per_page: 20 },
62
+ });
63
+ }
64
+
38
65
  test("d1List parses --json output and injects the house token", async () => {
39
66
  const { run, calls } = recordingRun(JSON.stringify([{ name: "gls-leads", uuid: "u1" }]));
40
67
  const cf = makeCfExec(cred, run);
@@ -156,12 +183,138 @@ test("r2List throws on a 200 whose body is not a success envelope, retaining the
156
183
  await assert.rejects(() => cf.r2List(), /unexpected/);
157
184
  });
158
185
 
186
+ // `null` is valid JSON, so it survives parseJson and reaches the envelope
187
+ // guard as a null parsed value. It must still surface its body like every other
188
+ // malformed 2xx shape, never a bare TypeError with the body dropped.
189
+ test("a 2xx body of JSON null throws with the body, not a TypeError", async () => {
190
+ const { fetchFn } = recordingFetch({ ok: true, status: 200, body: "null" });
191
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
192
+ await assert.rejects(() => cf.r2List(), {
193
+ message: "storage-broker: r2 bucket list API error: null",
194
+ });
195
+ });
196
+
159
197
  test("non-JSON output throws with the raw body", async () => {
160
198
  const { run } = recordingRun("not json");
161
199
  const cf = makeCfExec(cred, run);
162
200
  await assert.rejects(() => cf.d1List(), /not json/);
163
201
  });
164
202
 
203
+ // Task 1665 — the R2 List-Buckets endpoint paginates by cursor. A single
204
+ // unpaginated request silently truncates the bucket set on a large house
205
+ // account, and the audit reads a truncated list as "those buckets are absent",
206
+ // under-reporting strays. per_page is never sent: its default and cap are
207
+ // unconfirmed, and omitting it makes the loop correct without guessing.
208
+ test("r2List follows the pagination cursor and assembles every page", async () => {
209
+ const { fetchFn, calls } = sequenceFetch([
210
+ { ok: true, status: 200, body: bucketPage(["gls-assets", "gls-docs"], "cur-1") },
211
+ { ok: true, status: 200, body: bucketPage(["acme-assets"]) },
212
+ ]);
213
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
214
+ assert.deepEqual(await cf.r2List(), [
215
+ { name: "gls-assets" },
216
+ { name: "gls-docs" },
217
+ { name: "acme-assets" },
218
+ ]);
219
+ assert.equal(calls.length, 2);
220
+ // The first request carries no query string; only follow-ups carry the cursor.
221
+ assert.equal(calls[0].url, "https://api.cloudflare.com/client/v4/accounts/acc-h/r2/buckets");
222
+ assert.equal(
223
+ calls[1].url,
224
+ "https://api.cloudflare.com/client/v4/accounts/acc-h/r2/buckets?cursor=cur-1",
225
+ );
226
+ assert.equal(calls[1].init.headers.Authorization, "Bearer cfat_x");
227
+ });
228
+
229
+ test("r2List never sends per_page", async () => {
230
+ const { fetchFn, calls } = sequenceFetch([
231
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "cur-1") },
232
+ { ok: true, status: 200, body: bucketPage(["gls-docs"]) },
233
+ ]);
234
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
235
+ await cf.r2List();
236
+ for (const c of calls) assert.equal(c.url.includes("per_page"), false);
237
+ });
238
+
239
+ test("r2List stops on an empty-string cursor", async () => {
240
+ const { fetchFn, calls } = sequenceFetch([
241
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "") },
242
+ ]);
243
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
244
+ assert.deepEqual(await cf.r2List(), [{ name: "gls-assets" }]);
245
+ assert.equal(calls.length, 1);
246
+ });
247
+
248
+ test("r2List percent-encodes the cursor", async () => {
249
+ const { fetchFn, calls } = sequenceFetch([
250
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "a b+c/d=") },
251
+ { ok: true, status: 200, body: bucketPage([]) },
252
+ ]);
253
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
254
+ await cf.r2List();
255
+ assert.equal(
256
+ calls[1].url,
257
+ "https://api.cloudflare.com/client/v4/accounts/acc-h/r2/buckets?cursor=a%20b%2Bc%2Fd%3D",
258
+ );
259
+ });
260
+
261
+ test("r2List stops on a null cursor", async () => {
262
+ const { fetchFn, calls } = sequenceFetch([
263
+ {
264
+ ok: true,
265
+ status: 200,
266
+ body: JSON.stringify({
267
+ success: true,
268
+ errors: [],
269
+ messages: [],
270
+ result: { buckets: [{ name: "gls-assets" }] },
271
+ result_info: { cursor: null },
272
+ }),
273
+ },
274
+ ]);
275
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
276
+ assert.deepEqual(await cf.r2List(), [{ name: "gls-assets" }]);
277
+ assert.equal(calls.length, 1);
278
+ });
279
+
280
+ test("r2List throws on a repeated cursor instead of looping forever", async () => {
281
+ const { fetchFn } = sequenceFetch([
282
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "cur-1") },
283
+ { ok: true, status: 200, body: bucketPage(["gls-docs"], "cur-1") },
284
+ ]);
285
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
286
+ await assert.rejects(() => cf.r2List(), /repeated pagination cursor: cur-1/);
287
+ });
288
+
289
+ // A cursor cycle of period >= 2 never repeats the immediately-previous cursor,
290
+ // so it would spin forever — re-appending the same buckets each pass — under a
291
+ // guard that only compares against the last cursor seen.
292
+ test("r2List throws on a cursor cycle that does not repeat consecutively", async () => {
293
+ const { fetchFn } = sequenceFetch([
294
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "cur-a") },
295
+ { ok: true, status: 200, body: bucketPage(["gls-docs"], "cur-b") },
296
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "cur-a") },
297
+ ]);
298
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
299
+ await assert.rejects(() => cf.r2List(), /repeated pagination cursor: cur-a/);
300
+ });
301
+
302
+ test("r2List surfaces a mid-loop failure with its body, never a partial list", async () => {
303
+ const { fetchFn } = sequenceFetch([
304
+ { ok: true, status: 200, body: bucketPage(["gls-assets"], "cur-1") },
305
+ {
306
+ ok: false,
307
+ status: 403,
308
+ body: JSON.stringify({
309
+ success: false,
310
+ errors: [{ code: 10000, message: "Authentication error" }],
311
+ }),
312
+ },
313
+ ]);
314
+ const cf = makeCfExec(cred, throwingRun, fetchFn);
315
+ await assert.rejects(() => cf.r2List(), /403[\s\S]*Authentication error/);
316
+ });
317
+
165
318
  // Task 1670 — makeHouseCfExec resolves a scoped storage token from the house
166
319
  // minter via cf-token.sh and uses THAT token (never the minter) for wrangler.
167
320
  test("makeHouseCfExec runs wrangler with the scoped token, not the minter", async () => {
@@ -52,6 +52,34 @@ function parseJson(raw: string, context: string): unknown {
52
52
  }
53
53
  }
54
54
 
55
+ // The Cloudflare-API envelope, in one place. Both API-backed methods (r2List,
56
+ // d1Create) share it: read the body exactly once, surface a non-2xx with its
57
+ // body, parse, then require an explicit `success:true`. A 2xx whose body is not
58
+ // a well-formed success envelope is an anomaly to surface with its body, never
59
+ // a silently empty/uuid-less result. The raw `body` is returned alongside the
60
+ // parsed value so callers can raise their own shaping errors on the original
61
+ // bytes rather than a re-serialised copy.
62
+ async function cfApiJson(
63
+ fetchFn: FetchFn,
64
+ url: string,
65
+ init: { method: string; headers: Record<string, string>; body?: string },
66
+ context: string,
67
+ ): Promise<{ parsed: unknown; body: string }> {
68
+ const res = await fetchFn(url, init);
69
+ const body = await res.text();
70
+ if (!res.ok) {
71
+ throw new Error(`storage-broker: ${context} failed: ${res.status}\n${body}`);
72
+ }
73
+ // `null` is valid JSON and survives parseJson, so the guard is optional-
74
+ // chained: a null parsed value must surface its body like any other malformed
75
+ // 2xx shape, never a bare TypeError with the body dropped.
76
+ const parsed = parseJson(body, context) as { success?: boolean } | null;
77
+ if (parsed?.success !== true) {
78
+ throw new Error(`storage-broker: ${context} API error: ${body}`);
79
+ }
80
+ return { parsed, body };
81
+ }
82
+
55
83
  export function makeCfExec(
56
84
  cred: HouseCredential,
57
85
  run: RunFn = defaultRun,
@@ -76,30 +104,26 @@ export function makeCfExec(
76
104
  // so shelling it with --json is always rejected. The D1 API creates the
77
105
  // database and returns its record — incl. the uuid — as JSON directly.
78
106
  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",
107
+ const { parsed, body } = await cfApiJson(
108
+ fetchFn,
109
+ url,
110
+ {
111
+ method: "POST",
112
+ headers: {
113
+ Authorization: `Bearer ${cred.apiToken}`,
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify({ name }),
84
117
  },
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") {
118
+ "d1 create",
119
+ );
120
+ // The envelope guard lives in cfApiJson; a uuid-less 2xx success envelope
121
+ // is still an anomaly to surface with its body, never a silent create.
122
+ const uuid = (parsed as { result?: { uuid?: string } }).result?.uuid;
123
+ if (typeof uuid !== "string") {
100
124
  throw new Error(`storage-broker: d1 create API error: ${body}`);
101
125
  }
102
- return { uuid: parsed.result.uuid };
126
+ return { uuid };
103
127
  },
104
128
  async d1Query(name, sql) {
105
129
  const { stdout } = await run(
@@ -113,27 +137,47 @@ export function makeCfExec(
113
137
  // `wrangler r2 bucket list` has no --json flag and emits human prose, so
114
138
  // it can neither be parsed nor honour the "never grep a CLI's prose"
115
139
  // doctrine. The R2 API returns the bucket set as JSON directly.
116
- const url = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`;
117
- const res = await fetchFn(url, {
118
- method: "GET",
119
- headers: { Authorization: `Bearer ${cred.apiToken}` },
120
- });
121
- const body = await res.text();
122
- if (!res.ok) {
123
- throw new Error(`storage-broker: r2 bucket list failed: ${res.status}\n${body}`);
124
- }
125
- const parsed = parseJson(body, "r2 bucket list") as {
126
- success?: boolean;
127
- errors?: unknown;
128
- result?: { buckets?: { name: string }[] };
129
- };
130
- // Require an explicit success envelope. A 2xx whose body is not a
131
- // well-formed `success:true` response is an anomaly to surface with its
132
- // body, never a silent empty bucket set.
133
- if (parsed.success !== true) {
134
- throw new Error(`storage-broker: r2 bucket list API error: ${body}`);
140
+ //
141
+ // The endpoint paginates: a single request returns only the first page,
142
+ // and the audit reads the missing remainder as "absent", under-reporting
143
+ // strays. Follow result_info.cursor to exhaustion instead. per_page is
144
+ // deliberately never sent — its default and maximum are unconfirmed, and
145
+ // omitting it takes whatever page size Cloudflare serves, so the loop is
146
+ // complete without guessing a value that could 400 the request.
147
+ const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`;
148
+ const buckets: { name: string }[] = [];
149
+ const seen = new Set<string>();
150
+ let cursor: string | undefined;
151
+ for (;;) {
152
+ const url = cursor === undefined ? base : `${base}?cursor=${encodeURIComponent(cursor)}`;
153
+ const { parsed } = await cfApiJson(
154
+ fetchFn,
155
+ url,
156
+ { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } },
157
+ "r2 bucket list",
158
+ );
159
+ const page = parsed as {
160
+ result?: { buckets?: { name: string }[] };
161
+ result_info?: { cursor?: string | null };
162
+ };
163
+ for (const b of page.result?.buckets ?? []) buckets.push({ name: b.name });
164
+ // A missing, null, or empty cursor all mean "last page" — accept every
165
+ // documented last-page signal rather than depend on which one is sent.
166
+ const next = page.result_info?.cursor;
167
+ if (!next) return buckets;
168
+ // Any cursor seen before means the API is cycling rather than advancing,
169
+ // which would loop forever and re-append the same buckets each pass.
170
+ // Tracking every cursor (not just the last) also catches a cycle of
171
+ // period >= 2, which never repeats consecutively. Surface it instead of
172
+ // hanging the broker on an unbounded wait.
173
+ if (seen.has(next)) {
174
+ throw new Error(
175
+ `storage-broker: r2 bucket list returned a repeated pagination cursor: ${next}`,
176
+ );
177
+ }
178
+ seen.add(next);
179
+ cursor = next;
135
180
  }
136
- return (parsed.result?.buckets ?? []).map((b) => ({ name: b.name }));
137
181
  },
138
182
  async r2Create(name) {
139
183
  await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
@@ -1,7 +1,11 @@
1
1
  // Reads the single account-wide Cloudflare credential from the house-only
2
2
  // config directory. This file is the one place the account-wide capability
3
- // lives; sub-account spawns never source it. Parses KEY=value lines without
4
- // polluting process.env (same discipline as reconcile-bookings' readEnvFile).
3
+ // lives. House-only is a location convention plus the cf-token.sh caller gate,
4
+ // NOT an OS permission: every account's session runs as the same unix user, so
5
+ // a sub-account agent holding Bash can read this path directly. Task 1690
6
+ // tracks the OS user separation that would make this a real boundary. Parses
7
+ // KEY=value lines without polluting process.env (same discipline as
8
+ // reconcile-bookings' readEnvFile).
5
9
 
6
10
  import { readFileSync } from "node:fs";
7
11
  import { join } from "node:path";
@@ -298,8 +298,12 @@ setup "cfat_master_abc"
298
298
  HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
299
299
  HOUSE_ENV="$HOUSE_DIR/cloudflare-house.env"
300
300
  ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_ENV" )
301
+ # The caller gate (cases 18-22) admits only the house account, so this case
302
+ # spawns as the house account.
303
+ mkdir -p "$SANDBOX/acme-code/data/accounts/acc123"
304
+ printf '{"role":"house"}\n' > "$SANDBOX/acme-code/data/accounts/acc123/account.json"
301
305
  of=$(mktemp); ef=$(mktemp)
302
- PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
306
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
303
307
  RC=$?; OUT=$(cat "$of"); ERR=$(cat "$ef"); rm -f "$of" "$ef"
304
308
  [ "$RC" -eq 0 ] && ok "house fallback resolves (exit=0)" || { bad "house fallback (exit=$RC)"; echo " stderr: $ERR" >&2; }
305
309
  [ "$OUT" = "CF_PAGES_D1_TOKEN" ] && ok "house fallback stdout is canonical key" || bad "house out='$OUT'"
@@ -327,8 +331,9 @@ teardown
327
331
 
328
332
  # 15. fail-closed: stripped per-account file and NO house env reachable (no
329
333
  # 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.
334
+ # names the missing credential source. Reaching this branch means no house
335
+ # credential was found at all; the caller gate (cases 18-22) is what decides
336
+ # whether a reachable house credential may actually be used.
332
337
  setup "cfat_master_abc"
333
338
  ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
334
339
  of=$(mktemp); ef=$(mktemp)
@@ -345,8 +350,13 @@ setup "cfat_master_abc"
345
350
  ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
346
351
  OVERRIDE="$SANDBOX/acme-code/override-house.env"
347
352
  ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$OVERRIDE" )
353
+ # The caller gate admits only the house account, and locates the accounts dir
354
+ # from PLATFORM_ROOT. The PLATFORM_ROOT-derived house path does not exist here,
355
+ # so a pass still proves CF_HOUSE_ENV won.
356
+ mkdir -p "$SANDBOX/acme-code/data/accounts/acc123"
357
+ printf '{"role":"house"}\n' > "$SANDBOX/acme-code/data/accounts/acc123/account.json"
348
358
  of=$(mktemp); ef=$(mktemp)
349
- CF_HOUSE_ENV="$OVERRIDE" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
359
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_HOUSE_ENV="$OVERRIDE" CF_BACKOFF_SECONDS=0 bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
350
360
  RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
351
361
  [ "$RC" -eq 0 ] && ok "CF_HOUSE_ENV override resolves (exit=0)" || bad "override (exit=$RC): $ERR"
352
362
  grep -q "^CF_PAGES_D1_TOKEN=" "$OVERRIDE" && ok "override env received the minted token" || bad "override not persisted"
@@ -377,5 +387,136 @@ grep -q "^CF_CALENDAR_D1_TOKEN=" "$SECRETS" && ok "calendar-d1 token persisted"
377
387
  no_leak "calendar-d1 mint"
378
388
  teardown
379
389
 
390
+ # ---------------------------------------------------------------------------
391
+ # Caller gate (Task 1646). The house env carries an account-wide master, so the
392
+ # fallback must fire only for the house account. "House" mirrors
393
+ # resolveHouseOrSoleAccountId: account.json role:"house", or the sole account.
394
+ # The gate reads the caller's own account.json under
395
+ # $(dirname $PLATFORM_ROOT)/data/accounts/$ACCOUNT_ID/, which the sandbox
396
+ # layout already matches ($SANDBOX/acme-code/data/accounts/acc123).
397
+
398
+ # 18. a sub-account caller must NOT resolve the house master: deny, non-zero,
399
+ # no mint, and nothing persisted to either file.
400
+ setup "cfat_master_abc"
401
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
402
+ ADIR="$SANDBOX/acme-code/data/accounts"
403
+ mkdir -p "$ADIR/acc123" "$ADIR/house1"
404
+ printf '{"role":"client"}\n' > "$ADIR/acc123/account.json"
405
+ printf '{"role":"house"}\n' > "$ADIR/house1/account.json"
406
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
407
+ HOUSE_ENV="$HOUSE_DIR/cloudflare-house.env"
408
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_ENV" )
409
+ of=$(mktemp); ef=$(mktemp)
410
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
411
+ bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
412
+ RC=$?; OUT=$(cat "$of"); ERR=$(cat "$ef"); rm -f "$of" "$ef"
413
+ [ "$RC" -ne 0 ] && ok "sub-account denied the house master (exit=$RC)" || bad "sub-account NOT denied (exit=$RC)"
414
+ grep -q -- "-X POST" "$SANDBOX/curl.log" && bad "minted for a sub-account" || ok "no mint for a sub-account"
415
+ grep -q "^CF_PAGES_D1_TOKEN=" "$HOUSE_ENV" && bad "sub-account wrote a token to house env" || ok "house env untouched on deny"
416
+ grep -q "^CF_PAGES_D1_TOKEN=" "$SECRETS" && bad "sub-account wrote a token to its own file" || ok "per-account file untouched on deny"
417
+ echo "$ERR" | grep -q "action=deny" && ok "deny lifeline emitted" || bad "no deny lifeline: $ERR"
418
+ echo "$ERR" | grep -q "reason=not-house" && ok "deny lifeline names the reason" || bad "no reason: $ERR"
419
+ no_leak "sub-account deny"
420
+ teardown
421
+
422
+ # 19. the house account (role:"house") is allowed, preserving house-run hosting.
423
+ setup "cfat_master_abc"
424
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
425
+ ADIR="$SANDBOX/acme-code/data/accounts"; mkdir -p "$ADIR/acc123" "$ADIR/other1"
426
+ printf '{"role":"house"}\n' > "$ADIR/acc123/account.json"
427
+ printf '{"role":"client"}\n' > "$ADIR/other1/account.json"
428
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
429
+ HOUSE_ENV="$HOUSE_DIR/cloudflare-house.env"
430
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_ENV" )
431
+ of=$(mktemp); ef=$(mktemp)
432
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
433
+ bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
434
+ RC=$?; OUT=$(cat "$of"); ERR=$(cat "$ef"); rm -f "$of" "$ef"
435
+ [ "$RC" -eq 0 ] && ok "house caller allowed (exit=0)" || { bad "house caller denied (exit=$RC)"; echo " stderr: $ERR" >&2; }
436
+ [ "$OUT" = "CF_PAGES_D1_TOKEN" ] && ok "house caller stdout is canonical key" || bad "house caller out='$OUT'"
437
+ grep -q "^CF_PAGES_D1_TOKEN=" "$HOUSE_ENV" && ok "house caller persisted to house env" || bad "house caller not persisted"
438
+ teardown
439
+
440
+ # 20. the sole unlabelled account IS the house account (resolveHouseOrSoleAccountId's
441
+ # second arm) -> allowed.
442
+ setup "cfat_master_abc"
443
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
444
+ ADIR="$SANDBOX/acme-code/data/accounts"; mkdir -p "$ADIR/acc123"
445
+ printf '{"name":"only"}\n' > "$ADIR/acc123/account.json"
446
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
447
+ HOUSE_ENV="$HOUSE_DIR/cloudflare-house.env"
448
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_ENV" )
449
+ of=$(mktemp); ef=$(mktemp)
450
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
451
+ bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
452
+ RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
453
+ [ "$RC" -eq 0 ] && ok "sole unlabelled account allowed (exit=0)" || bad "sole account denied (exit=$RC): $ERR"
454
+ teardown
455
+
456
+ # 21. unset ACCOUNT_ID fails closed.
457
+ setup "cfat_master_abc"
458
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
459
+ ADIR="$SANDBOX/acme-code/data/accounts"; mkdir -p "$ADIR/acc123" "$ADIR/house1"
460
+ printf '{"role":"client"}\n' > "$ADIR/acc123/account.json"
461
+ printf '{"role":"house"}\n' > "$ADIR/house1/account.json"
462
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
463
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_DIR/cloudflare-house.env" )
464
+ of=$(mktemp); ef=$(mktemp)
465
+ env -u ACCOUNT_ID PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
466
+ bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
467
+ RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
468
+ [ "$RC" -ne 0 ] && ok "unset ACCOUNT_ID fails closed (exit=$RC)" || bad "unset ACCOUNT_ID allowed (exit=$RC)"
469
+ echo "$ERR" | grep -q "reason=no-account-id" && ok "no-account-id reason surfaced" || bad "reason: $ERR"
470
+ teardown
471
+
472
+ # 22. a caller whose account.json is absent fails closed.
473
+ setup "cfat_master_abc"
474
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
475
+ ADIR="$SANDBOX/acme-code/data/accounts"; mkdir -p "$ADIR/acc123" "$ADIR/house1"
476
+ printf '{"role":"house"}\n' > "$ADIR/house1/account.json"
477
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
478
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_DIR/cloudflare-house.env" )
479
+ of=$(mktemp); ef=$(mktemp)
480
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
481
+ bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
482
+ RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
483
+ [ "$RC" -ne 0 ] && ok "missing account.json fails closed (exit=$RC)" || bad "missing account.json allowed"
484
+ echo "$ERR" | grep -q "reason=no-account-json" && ok "no-account-json reason surfaced" || bad "reason: $ERR"
485
+ teardown
486
+
487
+ # 23. two role:"house" accounts is corruption: resolveAccountDir returns null
488
+ # rather than picking one, so the gate denies rather than trusting a
489
+ # self-declared house.
490
+ setup "cfat_master_abc"
491
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
492
+ ADIR="$SANDBOX/acme-code/data/accounts"; mkdir -p "$ADIR/acc123" "$ADIR/house2"
493
+ printf '{"role":"house"}\n' > "$ADIR/acc123/account.json"
494
+ printf '{"role":"house"}\n' > "$ADIR/house2/account.json"
495
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
496
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_DIR/cloudflare-house.env" )
497
+ of=$(mktemp); ef=$(mktemp)
498
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
499
+ bash "$HELPER" pages "$SECRETS" >"$of" 2>"$ef"
500
+ RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
501
+ [ "$RC" -ne 0 ] && ok "two houses fails closed (exit=$RC)" || bad "two houses allowed (exit=$RC)"
502
+ echo "$ERR" | grep -q "reason=multiple-house-accounts" && ok "multiple-house reason surfaced" || bad "reason: $ERR"
503
+ teardown
504
+
505
+ # 24. the deny message names the scope actually requested, not a fixed list.
506
+ setup "cfat_master_abc"
507
+ ( umask 077; printf 'CLOUDFLARE_ACCOUNT_ID=%s\n' "acc123" > "$SECRETS" )
508
+ ADIR="$SANDBOX/acme-code/data/accounts"; mkdir -p "$ADIR/acc123" "$ADIR/house1"
509
+ printf '{"role":"client"}\n' > "$ADIR/acc123/account.json"
510
+ printf '{"role":"house"}\n' > "$ADIR/house1/account.json"
511
+ HOUSE_DIR="$SANDBOX/acme-code/platform/config"; mkdir -p "$HOUSE_DIR"
512
+ ( umask 077; printf 'CLOUDFLARE_API_TOKEN=%s\nCLOUDFLARE_ACCOUNT_ID=%s\n' "cfat_master_abc" "acc123" > "$HOUSE_DIR/cloudflare-house.env" )
513
+ of=$(mktemp); ef=$(mktemp)
514
+ ACCOUNT_ID=acc123 PLATFORM_ROOT="$SANDBOX/acme-code/platform" CF_BACKOFF_SECONDS=0 \
515
+ bash "$HELPER" dns "$SECRETS" >"$of" 2>"$ef"
516
+ RC=$?; ERR=$(cat "$ef"); rm -f "$of" "$ef"
517
+ echo "$ERR" | grep -q "scope=dns" && ok "deny lifeline names scope=dns" || bad "lifeline scope: $ERR"
518
+ echo "$ERR" | grep -qF "'dns' scope" && ok "deny message names the dns scope" || bad "deny message scope: $ERR"
519
+ teardown
520
+
380
521
  echo "----"; echo "PASS=$PASS FAIL=$FAIL"
381
522
  [ "$FAIL" -eq 0 ]
@@ -32,10 +32,63 @@ case "$scope" in pages|d1|storage|calendar-d1|dns|access) ;; *) die "unknown sco
32
32
  # was used, surfaced on every lifeline so an operator can see hosting is on the
33
33
  # isolated path, not a stray per-account token. Single-tenant installs keep the
34
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.
35
+ #
36
+ # The fallback is gated on the caller being the house account: the master is
37
+ # account-wide (D1+R2 Edit and API Tokens Write), so handing it to a client
38
+ # sub-account would defeat the 1631 storage boundary as well as leak Pages/DNS.
39
+ # A specialist spawn is NOT a trust tier - it runs under whichever account owns
40
+ # the session, and carries PLATFORM_ROOT exactly as an admin spawn does
41
+ # (pty-spawner.ts:2174), so spawn shape cannot stand in for caller identity.
42
+ #
43
+ # WHAT THIS GATE IS AND IS NOT. It closes the SANCTIONED path: the platform no
44
+ # longer mints an account-wide token on a sub-account's behalf through its own
45
+ # supported seam. It is NOT a defence against a compromised or prompt-injected
46
+ # specialist. Every account's session runs as the SAME OS user (no uid/gid is
47
+ # dropped at spawn) and the house env path is deterministic, so an agent holding
48
+ # Bash can read that file directly or export a false ACCOUNT_ID. Real enforcement
49
+ # needs OS user separation; until that lands this is a boundary of intent, not of
50
+ # permission. Do not cite this gate as proof a sub-account cannot reach the
51
+ # master.
52
+ #
53
+ # "House" is an exact shell mirror of resolveAccountDir + isHouseAccount
54
+ # (platform/services/claude-session-manager/src/config.ts:238-323) -- the same
55
+ # predicate the spawn path uses to decide HOUSE_ADMIN_SCOPE, so the gate and
56
+ # cross-account authority can never disagree about which account is the house.
57
+ # Its three arms: exactly one role:"house" account (the caller must be it), no
58
+ # house plus a single account (the caller is it), and anything else -- including
59
+ # two or more role:"house" accounts -- is corruption and denies rather than
60
+ # picking one silently. An account dir counts when it holds a parseable
61
+ # account.json; like resolveAccountDir, the UUID shape of the dir name is not
62
+ # re-validated here (provisioning enforces it).
38
63
  file_has_master() { grep -Eq '^[[:space:]]*CLOUDFLARE_API_TOKEN=.+' "$1" 2>/dev/null; }
64
+
65
+ # deny_reason is deliberately global: the caller reads it for the lifeline.
66
+ # Every other variable is local, so this function cannot clobber the scope
67
+ # table's state (suffix/key/names/res/legacy/regexes/zone_scoped/mtag).
68
+ deny_reason=""
69
+ caller_is_house() {
70
+ local adir cfg role houses cands d r
71
+ deny_reason=""
72
+ [ -n "${ACCOUNT_ID:-}" ] || { deny_reason="no-account-id"; return 1; }
73
+ [ -n "${PLATFORM_ROOT:-}" ] || { deny_reason="no-platform-root"; return 1; }
74
+ adir="$(dirname "$PLATFORM_ROOT")/data/accounts"
75
+ cfg="${adir}/${ACCOUNT_ID}/account.json"
76
+ [ -r "$cfg" ] || { deny_reason="no-account-json"; return 1; }
77
+ role=$(jq -r '.role // empty' "$cfg" 2>/dev/null) || { deny_reason="unparseable-account-json"; return 1; }
78
+ houses=0; cands=0
79
+ for d in "$adir"/*/; do
80
+ [ -r "${d}account.json" ] || continue
81
+ cands=$((cands+1))
82
+ r=$(jq -r '.role // empty' "${d}account.json" 2>/dev/null) || r=""
83
+ [ "$r" = "house" ] && houses=$((houses+1))
84
+ done
85
+ if [ "$houses" -gt 1 ]; then deny_reason="multiple-house-accounts"; return 1; fi
86
+ if [ "$houses" -eq 1 ] && [ "$role" = "house" ]; then return 0; fi
87
+ if [ "$houses" -eq 0 ] && [ "$cands" -eq 1 ]; then return 0; fi
88
+ deny_reason="not-house"
89
+ return 1
90
+ }
91
+
39
92
  src=account
40
93
  if ! file_has_master "$secrets"; then
41
94
  house="${CF_HOUSE_ENV:-}"
@@ -43,6 +96,10 @@ if ! file_has_master "$secrets"; then
43
96
  house="${PLATFORM_ROOT}/config/cloudflare-house.env"
44
97
  fi
45
98
  if [ -n "$house" ] && [ -r "$house" ] && file_has_master "$house"; then
99
+ if ! caller_is_house; then
100
+ echo "[cf-token] op=resolve scope=${scope} master=house action=deny result=error code=0 src=house caller=${ACCOUNT_ID:-unset} reason=${deny_reason}" >&2
101
+ die "account '${ACCOUNT_ID:-unset}' is not the house account, so it cannot mint the '${scope}' scope from the account-wide Cloudflare master, which is house-only. Run this from the house admin session."
102
+ fi
46
103
  secrets="$house"; src=house
47
104
  else
48
105
  echo "[cf-token] op=resolve scope=${scope} master=none action=resolve result=error code=0 src=account" >&2
@@ -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 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.
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. `cf-token.sh` gates that fallback on the caller being the house account (its `account.json` carries `role:"house"`, or it is the sole account), so the sanctioned path never mints a hosting token for a client sub-account; a denied caller gets an `action=deny` lifeline naming the reason. This gate is a boundary of intent, not of permission: every account's session runs as the same unix user and the house env path is deterministic, so an agent holding `Bash` can read the master directly or export a false `ACCOUNT_ID`. Do not cite the gate as proof a sub-account cannot reach the master. OS user separation is tracked separately.
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 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. The house credential in `config/cloudflare-house.env` is a **minter**, not a data token, on **every** install, not only multi-tenant ones: the booking calendar and storage broker read it house-only fleet-wide (no per-account fallback), so they mint their own account-scoped data tokens from that minter — `CF_STORAGE_TOKEN` (D1 + R2) and `CF_CALENDAR_D1_TOKEN` (D1) — rather than use the minter directly, which returns `10000` on D1/R2. Multi-tenant installs add per-account isolation on top of this same file; single-tenant installs simply read their calendar/broker credential here. See `references/api.md` and `.docs/cloudflare-storage-isolation.md`.
32
+ On a multi-client install where client sub-accounts share one Cloudflare account, the master lives house-only at `config/cloudflare-house.env` and sub-account D1/R2 data work goes through the storage broker, not raw `wrangler`. This skill's tunnel operations are per-brand isolated and unchanged. The admin-run scopes (Pages, DNS, Access) stay admin/house-run: because the per-account secrets file no longer carries the master, `cf-token.sh` resolves and mints those scope tokens against the house credential (`config/cloudflare-house.env`) and persists them there, surfacing `src=house` on its `[cf-token]` lifeline. Hosting a client site to Pages is therefore an admin/house action: `cf-token.sh` mints the Pages/DNS/Access scopes only for the house account (or the sole account on a single-tenant install) and denies any other caller with an `action=deny` lifeline. Run deploys from the house admin session. The house credential in `config/cloudflare-house.env` is a **minter**, not a data token, on **every** install, not only multi-tenant ones: the booking calendar and storage broker read it house-only fleet-wide (no per-account fallback), so they mint their own account-scoped data tokens from that minter — `CF_STORAGE_TOKEN` (D1 + R2) and `CF_CALENDAR_D1_TOKEN` (D1) — rather than use the minter directly, which returns `10000` on D1/R2. Multi-tenant installs add per-account isolation on top of this same file; single-tenant installs simply read their calendar/broker credential here. 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
 
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=contact-erase.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contact-erase.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/contact-erase.test.ts"],"names":[],"mappings":""}