@rubytech/create-maxy-code 0.1.457 → 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.
- package/package.json +1 -1
- package/payload/platform/lib/mcp-lifeline/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/mcp-lifeline/dist/index.js +22 -1
- package/payload/platform/lib/mcp-lifeline/dist/index.js.map +1 -1
- package/payload/platform/lib/mcp-lifeline/src/__tests__/lifeline.test.ts +26 -0
- package/payload/platform/lib/mcp-lifeline/src/index.ts +24 -1
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +135 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +61 -28
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/house-credential.js +6 -2
- package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +153 -0
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +85 -41
- package/payload/platform/lib/storage-broker/src/house-credential.ts +6 -2
- package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +145 -4
- package/payload/platform/plugins/cloudflare/bin/cf-token.sh +60 -3
- package/payload/platform/plugins/cloudflare/references/api.md +1 -1
- package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +16 -3
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +108 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +42 -15
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +32 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js +3 -5
- package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +7 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +17 -3
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +6 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +6 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +3 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +2 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
- package/payload/platform/plugins/outlook/references/graph-surfaces.md +14 -2
- package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
- package/payload/server/{chunk-SDYRKIYY.js → chunk-64FGYKNZ.js} +51 -29
- package/payload/server/server.js +2 -2
- package/payload/server/{src-554BYJMN.js → src-S7C4P6TT.js} +1 -1
|
@@ -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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
4
|
-
//
|
|
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.
|
|
331
|
-
#
|
|
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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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.
|
|
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
|
|
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
|
|