@rubytech/create-maxy-code 0.1.457 → 0.1.459

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 (138) 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 +322 -2
  8. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  9. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts +2 -0
  10. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts.map +1 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +59 -0
  12. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -0
  13. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +12 -1
  14. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  15. package/payload/platform/lib/storage-broker/dist/cf-exec.js +156 -26
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  17. package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/house-credential.js +8 -2
  19. package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  21. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  23. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +53 -0
  25. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -0
  26. package/payload/platform/lib/storage-broker/dist/object-limits.js +72 -0
  27. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -0
  28. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +362 -3
  29. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +76 -0
  30. package/payload/platform/lib/storage-broker/src/cf-exec.ts +230 -44
  31. package/payload/platform/lib/storage-broker/src/house-credential.ts +8 -2
  32. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  33. package/payload/platform/lib/storage-broker/src/object-limits.ts +75 -0
  34. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -5
  35. package/payload/platform/plugins/cloudflare/bin/__tests__/cf-token.test.sh +145 -4
  36. package/payload/platform/plugins/cloudflare/bin/cf-token.sh +61 -3
  37. package/payload/platform/plugins/cloudflare/references/api.md +1 -1
  38. package/payload/platform/plugins/cloudflare/skills/cloudflare/SKILL.md +1 -1
  39. package/payload/platform/plugins/docs/references/outlook-guide.md +4 -4
  40. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  41. package/payload/platform/plugins/outlook/PLUGIN.md +2 -2
  42. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts +2 -0
  43. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts.map +1 -0
  44. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js +138 -0
  45. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js.map +1 -0
  46. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts +2 -0
  47. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts.map +1 -0
  48. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js +121 -0
  49. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js.map +1 -0
  50. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts +2 -0
  51. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts.map +1 -0
  52. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js +102 -0
  53. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js.map +1 -0
  54. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +16 -3
  55. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  56. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +108 -1
  57. package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
  58. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts +2 -0
  59. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts.map +1 -0
  60. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js +209 -0
  61. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js.map +1 -0
  62. package/payload/platform/plugins/outlook/mcp/dist/index.js +10 -7
  63. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  64. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts +37 -0
  65. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts.map +1 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js +79 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js.map +1 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +42 -15
  69. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
  70. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +32 -1
  71. package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
  72. package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts +8 -0
  73. package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts.map +1 -1
  74. package/payload/platform/plugins/outlook/mcp/dist/lib/message.js +4 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/lib/message.js.map +1 -1
  76. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +31 -8
  77. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  78. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js +40 -13
  79. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  80. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts +9 -5
  81. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts.map +1 -1
  82. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js +12 -8
  83. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js.map +1 -1
  84. package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.d.ts.map +1 -1
  85. package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js +3 -5
  86. package/payload/platform/plugins/outlook/mcp/dist/tools/calendar-event.js.map +1 -1
  87. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +36 -4
  88. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  89. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +56 -9
  90. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  91. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +13 -2
  92. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +18 -4
  94. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  95. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  96. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +3 -4
  97. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  98. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
  99. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +2 -4
  100. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
  101. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +3 -20
  103. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
  104. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts +27 -3
  105. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
  106. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +53 -8
  107. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
  108. package/payload/platform/plugins/outlook/references/graph-surfaces.md +41 -6
  109. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +22 -5
  110. package/payload/platform/plugins/storage-broker/PLUGIN.md +41 -2
  111. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts +2 -0
  112. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts.map +1 -0
  113. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js +85 -0
  114. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js.map +1 -0
  115. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts +2 -0
  116. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts.map +1 -0
  117. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js +77 -0
  118. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js.map +1 -0
  119. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts +12 -0
  120. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts.map +1 -0
  121. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js +107 -0
  122. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js.map +1 -0
  123. package/payload/platform/plugins/storage-broker/mcp/dist/index.js +86 -0
  124. package/payload/platform/plugins/storage-broker/mcp/dist/index.js.map +1 -1
  125. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts +11 -0
  126. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts.map +1 -0
  127. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js +35 -0
  128. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js.map +1 -0
  129. package/payload/platform/scripts/logs-read-jsonl.test.sh +118 -0
  130. package/payload/platform/scripts/logs-read.sh +63 -9
  131. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  132. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +4 -0
  133. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  134. package/payload/server/{chunk-F4D35LKL.js → chunk-POBXIHOF.js} +34 -0
  135. package/payload/server/{chunk-SDYRKIYY.js → chunk-Q6W4U6HL.js} +153 -27
  136. package/payload/server/maxy-edge.js +1 -1
  137. package/payload/server/server.js +206 -6
  138. package/payload/server/{src-554BYJMN.js → src-3I2RYZFB.js} +9 -1
@@ -14,14 +14,32 @@ export type RunFn = (
14
14
  env: Record<string, string>,
15
15
  ) => Promise<{ stdout: string }>;
16
16
 
17
- // The HTTP seam for the Cloudflare API. Injectable so r2List and d1Create are
17
+ // The HTTP seam for the Cloudflare API. Injectable so the API-backed methods are
18
18
  // unit-testable without a network. Minimal subset of the global fetch: callers
19
19
  // read the body once via text() and branch on ok/status. `body` is set for the
20
- // POST paths (d1Create); GET paths (r2List) omit it.
20
+ // POST/PUT paths (d1Create, r2ObjectPut); GET/DELETE paths omit it.
21
+ //
22
+ // Task 1691 widened both halves: `body` carries Uint8Array for the object-put
23
+ // path, and `arrayBuffer` reads the raw object body for r2ObjectGet, which is
24
+ // the one method whose response is not a JSON envelope. arrayBuffer is required
25
+ // rather than optional: an optional member would force a fake-only fallback
26
+ // branch in r2ObjectGet that never runs against a real fetch.
21
27
  export type FetchFn = (
22
28
  url: string,
23
- init: { method: string; headers: Record<string, string>; body?: string },
24
- ) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
29
+ init: { method: string; headers: Record<string, string>; body?: string | Uint8Array },
30
+ ) => Promise<{
31
+ ok: boolean;
32
+ status: number;
33
+ text(): Promise<string>;
34
+ arrayBuffer(): Promise<ArrayBuffer>;
35
+ }>;
36
+
37
+ export interface R2Object {
38
+ key: string;
39
+ size: number;
40
+ etag: string;
41
+ lastModified: string;
42
+ }
25
43
 
26
44
  export interface CfExec {
27
45
  d1List(): Promise<{ name: string; uuid: string }[]>;
@@ -29,9 +47,33 @@ export interface CfExec {
29
47
  d1Query(name: string, sql: string): Promise<unknown>;
30
48
  r2List(): Promise<{ name: string }[]>;
31
49
  r2Create(name: string): Promise<void>;
50
+ r2ObjectList(bucket: string, prefix?: string): Promise<R2Object[]>;
51
+ r2ObjectGet(bucket: string, key: string): Promise<Uint8Array>;
52
+ r2ObjectPut(bucket: string, key: string, body: Uint8Array): Promise<void>;
53
+ r2ObjectDelete(bucket: string, key: string): Promise<void>;
54
+ }
55
+
56
+ // An object key carries `/` as a real path separator (the data portal's
57
+ // per-person prefixes), so it is encoded segment-wise rather than whole:
58
+ // separators stay separators, and spaces and specials are escaped. This
59
+ // encoding is not doc-confirmed; it is chosen so that a wrong guess surfaces as
60
+ // a 404 carrying its body, never as a silently misfiled object.
61
+ function encodeObjectKey(key: string): string {
62
+ return key.split("/").map(encodeURIComponent).join("/");
32
63
  }
33
64
 
34
- const defaultFetch: FetchFn = (url, init) => fetch(url, init);
65
+ // The single-object endpoint, shared by get/put/delete so the three cannot drift
66
+ // apart on encoding.
67
+ function objectUrl(accountId: string, bucket: string, key: string): string {
68
+ return `https://api.cloudflare.com/client/v4/accounts/${accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects/${encodeObjectKey(key)}`;
69
+ }
70
+
71
+ // The cast is confined to this adapter. A Uint8Array is a valid fetch body at
72
+ // runtime, but the DOM lib's BodyInit does not admit TypeScript's generic
73
+ // Uint8Array<ArrayBufferLike>, so consumers compiled against DOM typings (the
74
+ // ui server) reject the call the Node typings accept. Casting here keeps the
75
+ // seam's own type honest for every caller and test.
76
+ const defaultFetch: FetchFn = (url, init) => fetch(url, init as RequestInit);
35
77
 
36
78
  const defaultRun: RunFn = (cmd, args, env) =>
37
79
  new Promise((resolve, reject) => {
@@ -52,6 +94,34 @@ function parseJson(raw: string, context: string): unknown {
52
94
  }
53
95
  }
54
96
 
97
+ // The Cloudflare-API envelope, in one place. Both API-backed methods (r2List,
98
+ // d1Create) share it: read the body exactly once, surface a non-2xx with its
99
+ // body, parse, then require an explicit `success:true`. A 2xx whose body is not
100
+ // a well-formed success envelope is an anomaly to surface with its body, never
101
+ // a silently empty/uuid-less result. The raw `body` is returned alongside the
102
+ // parsed value so callers can raise their own shaping errors on the original
103
+ // bytes rather than a re-serialised copy.
104
+ async function cfApiJson(
105
+ fetchFn: FetchFn,
106
+ url: string,
107
+ init: { method: string; headers: Record<string, string>; body?: string | Uint8Array },
108
+ context: string,
109
+ ): Promise<{ parsed: unknown; body: string }> {
110
+ const res = await fetchFn(url, init);
111
+ const body = await res.text();
112
+ if (!res.ok) {
113
+ throw new Error(`storage-broker: ${context} failed: ${res.status}\n${body}`);
114
+ }
115
+ // `null` is valid JSON and survives parseJson, so the guard is optional-
116
+ // chained: a null parsed value must surface its body like any other malformed
117
+ // 2xx shape, never a bare TypeError with the body dropped.
118
+ const parsed = parseJson(body, context) as { success?: boolean } | null;
119
+ if (parsed?.success !== true) {
120
+ throw new Error(`storage-broker: ${context} API error: ${body}`);
121
+ }
122
+ return { parsed, body };
123
+ }
124
+
55
125
  export function makeCfExec(
56
126
  cred: HouseCredential,
57
127
  run: RunFn = defaultRun,
@@ -76,30 +146,26 @@ export function makeCfExec(
76
146
  // so shelling it with --json is always rejected. The D1 API creates the
77
147
  // database and returns its record — incl. the uuid — as JSON directly.
78
148
  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",
149
+ const { parsed, body } = await cfApiJson(
150
+ fetchFn,
151
+ url,
152
+ {
153
+ method: "POST",
154
+ headers: {
155
+ Authorization: `Bearer ${cred.apiToken}`,
156
+ "Content-Type": "application/json",
157
+ },
158
+ body: JSON.stringify({ name }),
84
159
  },
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") {
160
+ "d1 create",
161
+ );
162
+ // The envelope guard lives in cfApiJson; a uuid-less 2xx success envelope
163
+ // is still an anomaly to surface with its body, never a silent create.
164
+ const uuid = (parsed as { result?: { uuid?: string } }).result?.uuid;
165
+ if (typeof uuid !== "string") {
100
166
  throw new Error(`storage-broker: d1 create API error: ${body}`);
101
167
  }
102
- return { uuid: parsed.result.uuid };
168
+ return { uuid };
103
169
  },
104
170
  async d1Query(name, sql) {
105
171
  const { stdout } = await run(
@@ -113,30 +179,150 @@ export function makeCfExec(
113
179
  // `wrangler r2 bucket list` has no --json flag and emits human prose, so
114
180
  // it can neither be parsed nor honour the "never grep a CLI's prose"
115
181
  // 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, {
182
+ //
183
+ // The endpoint paginates: a single request returns only the first page,
184
+ // and the audit reads the missing remainder as "absent", under-reporting
185
+ // strays. Follow result_info.cursor to exhaustion instead. per_page is
186
+ // deliberately never sent — its default and maximum are unconfirmed, and
187
+ // omitting it takes whatever page size Cloudflare serves, so the loop is
188
+ // complete without guessing a value that could 400 the request.
189
+ const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets`;
190
+ const buckets: { name: string }[] = [];
191
+ const seen = new Set<string>();
192
+ let cursor: string | undefined;
193
+ for (;;) {
194
+ const url = cursor === undefined ? base : `${base}?cursor=${encodeURIComponent(cursor)}`;
195
+ const { parsed } = await cfApiJson(
196
+ fetchFn,
197
+ url,
198
+ { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } },
199
+ "r2 bucket list",
200
+ );
201
+ const page = parsed as {
202
+ result?: { buckets?: { name: string }[] };
203
+ result_info?: { cursor?: string | null };
204
+ };
205
+ for (const b of page.result?.buckets ?? []) buckets.push({ name: b.name });
206
+ // A missing, null, or empty cursor all mean "last page" — accept every
207
+ // documented last-page signal rather than depend on which one is sent.
208
+ const next = page.result_info?.cursor;
209
+ if (!next) return buckets;
210
+ // Any cursor seen before means the API is cycling rather than advancing,
211
+ // which would loop forever and re-append the same buckets each pass.
212
+ // Tracking every cursor (not just the last) also catches a cycle of
213
+ // period >= 2, which never repeats consecutively. Surface it instead of
214
+ // hanging the broker on an unbounded wait.
215
+ if (seen.has(next)) {
216
+ throw new Error(
217
+ `storage-broker: r2 bucket list returned a repeated pagination cursor: ${next}`,
218
+ );
219
+ }
220
+ seen.add(next);
221
+ cursor = next;
222
+ }
223
+ },
224
+ async r2Create(name) {
225
+ await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
226
+ },
227
+
228
+ // --- R2 objects (Task 1691) -------------------------------------------
229
+ //
230
+ // The v4 REST API exposes the object surface under
231
+ // /accounts/{id}/r2/buckets/{bucket}/objects and accepts the same Bearer
232
+ // token as the bucket endpoints (account-level Workers R2 Storage Write).
233
+ // The r2/api/tokens/ note that "Object Read & Write" is S3-only constrains
234
+ // the bucket-item-scoped *permission types*, not these endpoints.
235
+ //
236
+ // Doc-confirmed, never measured: no house Cloudflare credential exists on
237
+ // the dev machine. The design keeps the unmeasured facts unable to affect
238
+ // correctness — per_page is never sent, and nothing fails silently.
239
+ async r2ObjectList(bucket, prefix) {
240
+ // Mirrors r2List: follow the cursor to exhaustion, never send per_page.
241
+ // A truncated object listing reads as "those objects are absent" to the
242
+ // data portal's standing audit, producing false phantom rows and hiding
243
+ // real orphans — the same bug r2List had, one endpoint over.
244
+ const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects`;
245
+ const objects: R2Object[] = [];
246
+ const seen = new Set<string>();
247
+ let cursor: string | undefined;
248
+ for (;;) {
249
+ const params = new URLSearchParams();
250
+ if (prefix !== undefined) params.set("prefix", prefix);
251
+ if (cursor !== undefined) params.set("cursor", cursor);
252
+ const qs = params.toString();
253
+ const { parsed, body } = await cfApiJson(
254
+ fetchFn,
255
+ qs ? `${base}?${qs}` : base,
256
+ { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } },
257
+ "r2 object list",
258
+ );
259
+ const page = parsed as {
260
+ result?: Array<{ key: string; size: number; etag: string; last_modified: string }>;
261
+ result_info?: { cursor?: string | null; is_truncated?: boolean };
262
+ };
263
+ for (const o of page.result ?? []) {
264
+ objects.push({ key: o.key, size: o.size, etag: o.etag, lastModified: o.last_modified });
265
+ }
266
+ const truncated = page.result_info?.is_truncated;
267
+ const next = page.result_info?.cursor;
268
+ // is_truncated is authoritative *when present*: a last page may still
269
+ // carry a cursor, and following it would issue a pointless request or
270
+ // trip the cycle guard below on a healthy response.
271
+ if (truncated === false) return objects;
272
+ // The API says there is more but names no cursor: the response is not
273
+ // the shape we understand. Surface it rather than return a partial
274
+ // list, which is the failure this loop exists to prevent.
275
+ if (truncated === true && !next) {
276
+ throw new Error(
277
+ `storage-broker: r2 object list reported truncated but gave no pagination cursor: ${body}`,
278
+ );
279
+ }
280
+ // is_truncated absent: it is itself an unmeasured fact, so it must not
281
+ // be what decides correctness. Fall back to cursor presence — the rule
282
+ // r2List uses on the same envelope — so an envelope without the flag
283
+ // paginates rather than truncating silently.
284
+ if (!next) return objects;
285
+ // A cursor seen before means the API is cycling rather than advancing:
286
+ // it would loop forever, re-appending the same objects each pass.
287
+ if (seen.has(next)) {
288
+ throw new Error(
289
+ `storage-broker: r2 object list returned a repeated pagination cursor: ${next}`,
290
+ );
291
+ }
292
+ seen.add(next);
293
+ cursor = next;
294
+ }
295
+ },
296
+ async r2ObjectGet(bucket, key) {
297
+ // The one method cfApiJson cannot wrap: get returns the raw object body,
298
+ // not a success envelope, so there is nothing to parse or assert
299
+ // success:true on. The error body is still retained on a non-2xx.
300
+ const res = await fetchFn(objectUrl(cred.accountId, bucket, key), {
118
301
  method: "GET",
119
302
  headers: { Authorization: `Bearer ${cred.apiToken}` },
120
303
  });
121
- const body = await res.text();
122
304
  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}`);
305
+ throw new Error(
306
+ `storage-broker: r2 object get failed: ${res.status}\n${await res.text()}`,
307
+ );
135
308
  }
136
- return (parsed.result?.buckets ?? []).map((b) => ({ name: b.name }));
309
+ return new Uint8Array(await res.arrayBuffer());
137
310
  },
138
- async r2Create(name) {
139
- await run("npx", ["wrangler", "r2", "bucket", "create", name], env);
311
+ async r2ObjectPut(bucket, key, body) {
312
+ await cfApiJson(
313
+ fetchFn,
314
+ objectUrl(cred.accountId, bucket, key),
315
+ { method: "PUT", headers: { Authorization: `Bearer ${cred.apiToken}` }, body },
316
+ "r2 object put",
317
+ );
318
+ },
319
+ async r2ObjectDelete(bucket, key) {
320
+ await cfApiJson(
321
+ fetchFn,
322
+ objectUrl(cred.accountId, bucket, key),
323
+ { method: "DELETE", headers: { Authorization: `Bearer ${cred.apiToken}` } },
324
+ "r2 object delete",
325
+ );
140
326
  },
141
327
  };
142
328
  }
@@ -1,7 +1,13 @@
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. That is a
6
+ // permanent property, not a pending gap: OS user separation was costed and
7
+ // declined as disproportionate (Task 1690, archived undone), so nothing tracks
8
+ // it. Do not cite this file's location or mode as a boundary. Parses
9
+ // KEY=value lines without polluting process.env (same discipline as
10
+ // reconcile-bookings' readEnvFile).
5
11
 
6
12
  import { readFileSync } from "node:fs";
7
13
  import { join } from "node:path";
@@ -7,6 +7,7 @@
7
7
  // isolation and the isolation logic auditable in one place.
8
8
 
9
9
  export * from "./authorize.js";
10
+ export * from "./object-limits.js";
10
11
  export * from "./registry.js";
11
12
  export * from "./house-credential.js";
12
13
  export * from "./cf-exec.js";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The byte cap on R2 object transfer, and the arithmetic the routes need to
3
+ * enforce it before they buffer anything.
4
+ *
5
+ * One number, one home (Task 1695). The storage-broker MCP tools and the house
6
+ * ui routes both read the cap from here, so the process that rejects a too-large
7
+ * file and the process that would otherwise buffer it cannot drift apart.
8
+ *
9
+ * Deliberately dependency-free: the MCP imports this module directly, without
10
+ * the rest of the lib (cf-exec, registry, audit, remediate), which it has no use
11
+ * for. The ui route reads it through the barrel.
12
+ */
13
+
14
+ /**
15
+ * The maximum size, in bytes, of an object moved through storage-r2-object-get
16
+ * or storage-r2-object-put.
17
+ *
18
+ * Set independently of the email plugin's OUTBOUND_ATTACHMENT_MAX_BYTES (25 MiB)
19
+ * even though the payload class is the same. The two exist for different reasons
20
+ * and must be free to move for different reasons: email's tracks an SMTP-side
21
+ * rejection threshold, this one tracks house-process memory. Do not couple them.
22
+ *
23
+ * Not bounded by R2. A single-part upload may be up to 5 GiB (doc-confirmed:
24
+ * developers.cloudflare.com/r2/platform/limits/), roughly fifty times this, and
25
+ * the v4 REST API states no separate object-size limit. This cap is the only
26
+ * bound on either transfer.
27
+ *
28
+ * The bound is not small: at the cap a get still leaves the house process
29
+ * holding the object and its base64 form, roughly 2.4x the object. That cost was
30
+ * weighed and accepted when the number was chosen.
31
+ */
32
+ export const R2_OBJECT_MAX_BYTES = 100 * 1024 * 1024;
33
+
34
+ /** base64 emits 4 characters per 3 input bytes, padded up to the next quantum. */
35
+ export function base64Ceiling(bytes: number): number {
36
+ return 4 * Math.ceil(bytes / 3);
37
+ }
38
+
39
+ /**
40
+ * Room for the put envelope's bucket, key and JSON punctuation, so a legal
41
+ * at-cap put is not rejected by its own key. Far above any realistic bucket+key
42
+ * length and far below the cap, so it cannot smuggle a meaningfully larger
43
+ * object.
44
+ */
45
+ const PUT_ENVELOPE_ALLOWANCE_BYTES = 64 * 1024;
46
+
47
+ /**
48
+ * The largest JSON body /r2/object/put can legitimately carry.
49
+ *
50
+ * This bounds house memory; it does not adjudicate the last kilobyte. The exact
51
+ * decoded-byte rule lives in the MCP, which knows the source's size exactly.
52
+ */
53
+ export const R2_OBJECT_PUT_MAX_BODY_BYTES =
54
+ base64Ceiling(R2_OBJECT_MAX_BYTES) + PUT_ENVELOPE_ALLOWANCE_BYTES;
55
+
56
+ function formatMiB(bytes: number): string {
57
+ return (bytes / 1024 / 1024).toFixed(1);
58
+ }
59
+
60
+ /**
61
+ * The over-cap message, worded once so the MCP and the routes agree. Names the
62
+ * offending object and the limit, which is what makes the failure actionable
63
+ * rather than an opaque out-of-memory kill.
64
+ *
65
+ * Exact bytes accompany the MiB figures because MiB alone rounds: an object one
66
+ * byte over the cap reads "100.0 MiB, which exceeds the 100.0 MiB limit", which
67
+ * is a message that appears to contradict itself and invites the reader to treat
68
+ * the rejection as a bug. The bytes are what make it legible; the MiB is what
69
+ * makes it quick.
70
+ */
71
+ export function tooLargeMessage(what: string, actualBytes: number): string {
72
+ return `${what} is ${formatMiB(actualBytes)} MiB (${actualBytes} bytes), which exceeds the ${formatMiB(
73
+ R2_OBJECT_MAX_BYTES,
74
+ )} MiB (${R2_OBJECT_MAX_BYTES} bytes) R2 object limit.`;
75
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:d31a178be753e371f5773e362173eb0090115ababe81563d2b5f38890098202c
4
+ content-hash: sha256:15c9a9d54bd8898a18c541ce95b9f5dc869646faad096eefc60741bb405a062d
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -2071,9 +2071,9 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
2071
2071
  | `outlook-contacts-list` | Top contacts. Default top=50. |
2072
2072
  | `outlook-mailbox-info` | Health probe for one mailbox — auth state, refresh-window, folder count. Name the mailbox with `mailbox` when several are attached. |
2073
2073
  | `outlook-mailbox-list` | Every connected mailbox with its email, scopes, and token health. Local only — no network — so it also flags a mailbox whose stored credentials cannot be read. |
2074
- | `outlook-mail-send` | Send a message (`POST /me/sendMail`). to/cc/bcc arrays, subject, body, isHtml. Success asserted on Graph 202. Needs `Mail.Send`. |
2075
- | `outlook-draft` | Create a draft in Drafts (`POST /me/messages`). Same body model. Returns the draft id. Needs `Mail.ReadWrite`. |
2076
- | `outlook-draft-edit` | Update an existing draft in place (`PATCH /me/messages/{draftId}`). Graph drafts are mutable — pass draftId plus the fields to change (to/cc/bcc/subject/body/isHtml). Sent messages cannot be edited. Needs `Mail.ReadWrite`. |
2074
+ | `outlook-mail-send` | Send a message. No attachments → `POST /me/sendMail`; with attachments → draft, attach, send (`/me/sendMail` carries attachments inline and cannot fit a large file). to/cc/bcc arrays, subject, body, isHtml, attachments (account-dir paths, 25 MB/file). Success asserted on Graph 202; no message id on either route. Needs `Mail.Send` (+ `Mail.ReadWrite` to attach). |
2075
+ | `outlook-draft` | Create a draft in Drafts (`POST /me/messages`). Same body model, including attachments — files are attached before the id is returned, so the draft is ready to send. Returns the draft id. Needs `Mail.ReadWrite`. |
2076
+ | `outlook-draft-edit` | Update an existing draft in place (`PATCH /me/messages/{draftId}`) and/or add attachments. Graph drafts are mutable — pass draftId plus the fields to change (to/cc/bcc/subject/body/isHtml/attachments). Attachments are additive; removal is not supported. Sent messages cannot be edited. Needs `Mail.ReadWrite`. |
2077
2077
  | `outlook-draft-send` | Send an existing draft by id (`POST /me/messages/{id}/send`). Graph consumes the draft, returns 202. Needs `Mail.Send`. |
2078
2078
 
2079
2079
  ## Observability
@@ -2148,7 +2148,7 @@ Latency triage: `mail-list count=0 elapsedMs<200` consistent → permissions iss
2148
2148
 
2149
2149
  ## Out of scope
2150
2150
 
2151
- Mail move to arbitrary folders / flag, hard (permanent) delete, ingesting reviewed mail into the business graph (the `email-ingest` analogue), contacts write, recurring-series occurrence editing, the `scheduling` Neo4j calendar and any auto-sync with it, OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. Mail read, full-body read, inbound attachment list/download, reply, delete (to Deleted Items), and compose are supported (`outlook-mail-fetch-body`, `outlook-mail-attachment`, `outlook-mail-reply`, `outlook-mail-delete`, `outlook-mail-otp-extract`, `outlook-mail-send`, `outlook-draft`, `outlook-draft-edit`, `outlook-draft-send`), and the calendar is read plus control. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.
2151
+ Mail move to arbitrary folders / flag, hard (permanent) delete, removing or replacing an attachment already on a draft, ingesting reviewed mail into the business graph (the `email-ingest` analogue), contacts write, recurring-series occurrence editing, the `scheduling` Neo4j calendar and any auto-sync with it, OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. Mail read, full-body read, inbound attachment list/download, reply, delete (to Deleted Items), and compose — all four outbound paths carrying attachments — are supported (`outlook-mail-fetch-body`, `outlook-mail-attachment`, `outlook-mail-reply`, `outlook-mail-delete`, `outlook-mail-otp-extract`, `outlook-mail-send`, `outlook-draft`, `outlook-draft-edit`, `outlook-draft-send`), and the calendar is read plus control. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.
2152
2152
 
2153
2153
  ---
2154
2154
  # QuickBooks Online
@@ -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 ]