@rubytech/create-maxy-code 0.1.459 → 0.1.461

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 (189) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
  9. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  13. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  15. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  17. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.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 +27 -0
  25. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
  27. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  29. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  30. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  31. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  32. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
  33. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
  34. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  35. package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
  36. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  37. package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
  38. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  39. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
  40. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +220 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +204 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  51. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  52. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  53. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +125 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +119 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
  64. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
  65. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
  66. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
  67. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
  68. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
  69. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +73 -0
  70. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
  71. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
  72. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  73. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
  74. package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
  75. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
  76. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
  77. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
  78. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
  79. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  80. package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
  81. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  91. package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
  92. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  94. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  95. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  96. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  97. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  98. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  99. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  100. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  101. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  103. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  104. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
  105. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  106. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
  107. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  108. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  109. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  110. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  111. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  112. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  113. package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
  114. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
  115. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  116. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  117. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  118. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  119. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  120. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  121. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  122. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  123. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  124. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  125. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +111 -12
  126. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  127. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  128. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  129. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  130. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  131. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  132. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  133. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  134. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  135. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  136. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  137. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  138. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  139. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  140. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +444 -0
  141. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +18 -2
  142. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  143. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  144. package/payload/platform/scripts/logs-rotate.sh +204 -0
  145. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  146. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  147. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  148. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  149. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  151. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  153. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  155. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  156. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  157. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  158. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  159. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  160. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  161. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  162. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  163. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  164. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  165. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  166. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  167. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  168. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +36 -24
  169. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  170. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  171. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  172. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  173. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  174. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  175. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  176. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  177. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  178. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  179. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  180. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  181. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  182. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  183. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  184. package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
  185. package/payload/server/server.js +268 -73
  186. package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
  187. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  188. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  189. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
@@ -48,6 +48,7 @@ export interface CfExec {
48
48
  r2List(): Promise<{ name: string }[]>;
49
49
  r2Create(name: string): Promise<void>;
50
50
  r2ObjectList(bucket: string, prefix?: string): Promise<R2Object[]>;
51
+ r2ObjectFind(bucket: string, key: string): Promise<R2Object | null>;
51
52
  r2ObjectGet(bucket: string, key: string): Promise<Uint8Array>;
52
53
  r2ObjectPut(bucket: string, key: string, body: Uint8Array): Promise<void>;
53
54
  r2ObjectDelete(bucket: string, key: string): Promise<void>;
@@ -122,6 +123,81 @@ async function cfApiJson(
122
123
  return { parsed, body };
123
124
  }
124
125
 
126
+ // The one pagination loop for the object listing, shared by r2ObjectList and
127
+ // r2ObjectFind so the two cannot drift apart on cursor discipline — the same
128
+ // reason objectUrl is shared by get/put/delete.
129
+ //
130
+ // 1665's rules live here and nowhere else: follow the cursor to exhaustion,
131
+ // is_truncated is authoritative when present, a truncated page naming no cursor
132
+ // is an anomaly to surface rather than a partial list, and a repeated cursor is
133
+ // a cycle rather than progress. per_page is deliberately never sent — its
134
+ // default is unconfirmed, and omitting it takes whatever page size Cloudflare
135
+ // serves rather than guessing a value.
136
+ //
137
+ // It yields pages rather than returning a list, which is what lets r2ObjectFind
138
+ // stop early: an async generator is pull-based, so a consumer that stops
139
+ // iterating never triggers the next request.
140
+ async function* r2ObjectPages(
141
+ fetchFn: FetchFn,
142
+ cred: HouseCredential,
143
+ bucket: string,
144
+ prefix?: string,
145
+ ): AsyncGenerator<R2Object[]> {
146
+ const base = `https://api.cloudflare.com/client/v4/accounts/${cred.accountId}/r2/buckets/${encodeURIComponent(bucket)}/objects`;
147
+ const seen = new Set<string>();
148
+ let cursor: string | undefined;
149
+ for (;;) {
150
+ const params = new URLSearchParams();
151
+ if (prefix !== undefined) params.set("prefix", prefix);
152
+ if (cursor !== undefined) params.set("cursor", cursor);
153
+ const qs = params.toString();
154
+ const { parsed, body } = await cfApiJson(
155
+ fetchFn,
156
+ qs ? `${base}?${qs}` : base,
157
+ { method: "GET", headers: { Authorization: `Bearer ${cred.apiToken}` } },
158
+ "r2 object list",
159
+ );
160
+ const page = parsed as {
161
+ result?: Array<{ key: string; size: number; etag: string; last_modified: string }>;
162
+ result_info?: { cursor?: string | null; is_truncated?: boolean };
163
+ };
164
+ const objects: R2Object[] = [];
165
+ for (const o of page.result ?? []) {
166
+ objects.push({ key: o.key, size: o.size, etag: o.etag, lastModified: o.last_modified });
167
+ }
168
+ yield objects;
169
+ const truncated = page.result_info?.is_truncated;
170
+ const next = page.result_info?.cursor;
171
+ // is_truncated is authoritative *when present*: a last page may still carry
172
+ // a cursor, and following it would issue a pointless request or trip the
173
+ // cycle guard below on a healthy response.
174
+ if (truncated === false) return;
175
+ // The API says there is more but names no cursor: the response is not the
176
+ // shape we understand. Surface it rather than stop, which would silently
177
+ // truncate the audit's listing — the failure this loop exists to prevent.
178
+ if (truncated === true && !next) {
179
+ throw new Error(
180
+ `storage-broker: r2 object list reported truncated but gave no pagination cursor: ${body}`,
181
+ );
182
+ }
183
+ // is_truncated absent: it is itself an unmeasured fact, so it must not be
184
+ // what decides correctness. Fall back to cursor presence — the rule r2List
185
+ // uses on the same envelope — so an envelope without the flag paginates
186
+ // rather than truncating silently.
187
+ if (!next) return;
188
+ // A cursor seen before means the API is cycling rather than advancing: it
189
+ // would loop forever, re-yielding the same objects each pass. Tracking every
190
+ // cursor (not just the last) also catches a cycle of period >= 2.
191
+ if (seen.has(next)) {
192
+ throw new Error(
193
+ `storage-broker: r2 object list returned a repeated pagination cursor: ${next}`,
194
+ );
195
+ }
196
+ seen.add(next);
197
+ cursor = next;
198
+ }
199
+ }
200
+
125
201
  export function makeCfExec(
126
202
  cred: HouseCredential,
127
203
  run: RunFn = defaultRun,
@@ -233,65 +309,62 @@ export function makeCfExec(
233
309
  // The r2/api/tokens/ note that "Object Read & Write" is S3-only constrains
234
310
  // the bucket-item-scoped *permission types*, not these endpoints.
235
311
  //
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.
312
+ // The endpoint's existence and token class are doc-confirmed. Its behaviour
313
+ // was unmeasured until Task 1697, which probed the live v4 API on 2026-07-16
314
+ // from the laptop's own maxy-code install (no house Cloudflare credential
315
+ // exists on the dev Mac, which is why the tests fake the fetch seam). What
316
+ // that probe established, so nobody re-guesses it:
317
+ //
318
+ // - The listing is NOT in ascending UTF-8 binary order. `prefix=a/photo.jpg`
319
+ // returns `a/photo.jpg.bak` before `a/photo.jpg`, so the exact key is not
320
+ // the first element and `per_page=1` returns a neighbour. S3's
321
+ // ListObjectsV2 ordering guarantee does not transfer to this endpoint.
322
+ // - HEAD is routed only for single-segment keys: any key containing "/"
323
+ // returns 405 while GET on the identical URL returns 404.
324
+ // - A ranged GET is honoured at 2 MB and ignored at 12 B, so it cannot size
325
+ // an object without an undocumented size threshold deciding correctness.
326
+ // - per_page is bounded 0..1000 (error 10029), and per_page=0 returns zero
327
+ // objects with is_truncated:true — a loop trap. It is still never sent.
328
+ //
329
+ // The design keeps every unmeasured fact unable to affect correctness:
330
+ // per_page is never sent, order is never assumed, and nothing fails
331
+ // silently. Evidence:
332
+ // docs/superpowers/specs/2026-07-16-storage-broker-get-sizing-early-exit-design.md
239
333
  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`;
334
+ // The standing audit needs the complete set: a truncated object listing
335
+ // reads as "those objects are absent", producing false phantom rows and
336
+ // hiding real orphans the same bug r2List had, one endpoint over. So
337
+ // this drains every page. r2ObjectFind is the caller that needs one
338
+ // record and must not pay for this.
245
339
  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;
340
+ for await (const page of r2ObjectPages(fetchFn, cred, bucket, prefix)) {
341
+ for (const o of page) objects.push(o);
342
+ }
343
+ return objects;
344
+ },
345
+ async r2ObjectFind(bucket, key) {
346
+ // Task 1697 — the size source for a get. Scans each page for the exact key
347
+ // and returns on the first match, so it holds one page rather than every
348
+ // object sharing the key as a prefix, and issues no request past the page
349
+ // carrying the match.
350
+ //
351
+ // Exact match, not prefix: a prefix of `a/photo.jpg` also matches
352
+ // `a/photo.jpg.bak`, and sizing the neighbour would either reject a legal
353
+ // get or admit an oversized one. Exiting on the match rather than on
354
+ // position is what makes this independent of listing order — measured
355
+ // 2026-07-16 against the live v4 API, the listing is NOT in ascending
356
+ // UTF-8 binary order, so the exact key is not the first element and
357
+ // `per_page=1` returns a neighbour.
358
+ //
359
+ // An absent key still drains every page, because proving absence requires
360
+ // seeing them all. That residual is accepted and permanent; nothing on
361
+ // this endpoint sizes one object in bounded work. See
362
+ // docs/superpowers/specs/2026-07-16-storage-broker-get-sizing-early-exit-design.md
363
+ for await (const page of r2ObjectPages(fetchFn, cred, bucket, key)) {
364
+ const hit = page.find((o) => o.key === key);
365
+ if (hit) return hit;
294
366
  }
367
+ return null;
295
368
  },
296
369
  async r2ObjectGet(bucket, key) {
297
370
  // The one method cfApiJson cannot wrap: get returns the raw object body,
@@ -9,6 +9,7 @@
9
9
  export * from "./authorize.js";
10
10
  export * from "./object-limits.js";
11
11
  export * from "./registry.js";
12
+ export * from "./resource-limits.js";
12
13
  export * from "./house-credential.js";
13
14
  export * from "./cf-exec.js";
14
15
  export * from "./house-scoped-token.js";
@@ -37,12 +37,32 @@ export function base64Ceiling(bytes: number): number {
37
37
  }
38
38
 
39
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.
40
+ * The largest JSON body an object route's metadata envelope can legitimately
41
+ * carry: a bucket name, one key or prefix, and JSON punctuation. No payload term.
42
+ *
43
+ * Used twice, because it answers one question — how large can an envelope
44
+ * legitimately get. `/r2/object/list`, `get` and `delete` carry an envelope and
45
+ * nothing else, so this is their whole body limit (Task 1698). `put` carries the
46
+ * same envelope plus base64 object bytes, so it adds this to the base64 ceiling
47
+ * below, which is the allowance that keeps a legal at-cap put from being rejected
48
+ * by its own key.
49
+ *
50
+ * Deliberately one constant rather than two that happen to match. This is the
51
+ * opposite ruling to the email cap above, for the opposite reason: that pair must
52
+ * stay independent because the numbers track different constraints and would drift
53
+ * for different reasons. Here the constraint is identical, so a change to either
54
+ * use must move both.
55
+ *
56
+ * Sized from confirmed R2 limits rather than assumed. An object key is at most
57
+ * 1,024 bytes (developers.cloudflare.com/r2/platform/limits/) and a bucket name is
58
+ * 3-63 characters (developers.cloudflare.com/r2/buckets/create-buckets/), so a
59
+ * real envelope is about 1.1 KB, or roughly 6 KiB if every key byte takes a
60
+ * six-character \uXXXX escape. This clears that by about ten times and sits about
61
+ * 2,100 times below put's body limit: far above anything a real caller sends, far
62
+ * below anything that threatens house memory. Nothing here depends on either
63
+ * figure being exact.
44
64
  */
45
- const PUT_ENVELOPE_ALLOWANCE_BYTES = 64 * 1024;
65
+ export const R2_OBJECT_ENVELOPE_MAX_BODY_BYTES = 64 * 1024;
46
66
 
47
67
  /**
48
68
  * The largest JSON body /r2/object/put can legitimately carry.
@@ -51,7 +71,7 @@ const PUT_ENVELOPE_ALLOWANCE_BYTES = 64 * 1024;
51
71
  * decoded-byte rule lives in the MCP, which knows the source's size exactly.
52
72
  */
53
73
  export const R2_OBJECT_PUT_MAX_BODY_BYTES =
54
- base64Ceiling(R2_OBJECT_MAX_BYTES) + PUT_ENVELOPE_ALLOWANCE_BYTES;
74
+ base64Ceiling(R2_OBJECT_MAX_BYTES) + R2_OBJECT_ENVELOPE_MAX_BODY_BYTES;
55
75
 
56
76
  function formatMiB(bytes: number): string {
57
77
  return (bytes / 1024 / 1024).toFixed(1);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The bounds on the request bodies the resource-level routes will buffer:
3
+ * /d1/create, /d1/query and /r2/create (Task 1702).
4
+ *
5
+ * Parallel to object-limits.ts, which bounds the object routes. Kept separate
6
+ * because the facts differ: an object envelope is dominated by R2's 1,024-byte
7
+ * object key limit, and these routes carry no key at all. Coupling them would tie
8
+ * a D1 database name to R2's key limit, so a revision to either would move a bound
9
+ * that has no reason to move.
10
+ *
11
+ * These are bounds on house memory. They do not adjudicate the last byte of a name
12
+ * or a statement — the same stance object-limits.ts takes, for the same reason. The
13
+ * house process holds the account-wide credential, so an out-of-memory kill there
14
+ * takes the brand server down, and on the target hardware that is a hard freeze
15
+ * that emits nothing.
16
+ *
17
+ * Deliberately dependency-free, like object-limits.ts: the ui route reads these
18
+ * through the barrel, and nothing here needs the rest of the lib.
19
+ */
20
+
21
+ /**
22
+ * Cloudflare's documented maximum SQL statement length.
23
+ *
24
+ * "Maximum SQL statement length | 100,000 bytes (100 KB)"
25
+ * https://developers.cloudflare.com/d1/platform/limits/
26
+ *
27
+ * Recorded because it is what governs a legitimate `sql`, and because
28
+ * D1_QUERY_MAX_BODY_BYTES below is only knowable as safe by way of it.
29
+ *
30
+ * A second, independent bound applies to the same string on the way out, and is
31
+ * recorded here so nobody rediscovers it as a bug: cf-exec's d1Query shells
32
+ * `execFile("npx", ["wrangler", "d1", "execute", ..., "--command", sql])`, so the
33
+ * decoded statement travels as a single argv element. Linux caps one such element
34
+ * at MAX_ARG_STRLEN = 131,072 bytes and fails execve with E2BIG above it
35
+ * (https://man7.org/linux/man-pages/man2/execve.2.html). That is a property of the
36
+ * spawn path, not of D1. It does not bind first: 100,000 < 131,072, so a statement
37
+ * D1 would accept always fits the spawn.
38
+ */
39
+ export const D1_MAX_SQL_STATEMENT_BYTES = 100_000;
40
+
41
+ /**
42
+ * The worst-case expansion of a byte when it is serialized into a JSON string.
43
+ *
44
+ * A single ASCII control byte becomes the six characters \uXXXX. Multi-byte
45
+ * characters are cheaper per byte — a 3-byte UTF-8 character also becomes six
46
+ * characters (2x), and a non-BMP character becomes a 12-character surrogate pair
47
+ * for 4 bytes (3x) — so 6 is a true ceiling over bytes, reached by ASCII.
48
+ *
49
+ * This is why a body limit is not simply the statement limit: the cap bounds the
50
+ * wire body, and escaping moves the two apart.
51
+ */
52
+ export const JSON_STRING_WORST_CASE_EXPANSION = 6;
53
+
54
+ /**
55
+ * The largest JSON body /d1/create or /r2/create can legitimately carry: a single
56
+ * resource name and JSON punctuation. No payload term, and no key term.
57
+ *
58
+ * Sized on headroom rather than a tight derivation, because only one of the two
59
+ * names it bounds has a documented hard limit:
60
+ *
61
+ * - R2 bucket name: 3-63 characters, lowercase letters, digits and hyphens.
62
+ * https://developers.cloudflare.com/r2/buckets/create-buckets/
63
+ * - D1 database name: no documented limit. The API reference states only
64
+ * `name: string`, "D1 database name" — no maxLength, pattern or charset.
65
+ * https://developers.cloudflare.com/api/resources/d1/subresources/database/
66
+ * The get-started guide gives guidance, not a limit: names "should use a
67
+ * combination of ASCII characters, shorter than 32 characters".
68
+ *
69
+ * So a real body is under ~100 bytes and 64 KiB clears it by roughly 650x, while
70
+ * sitting ~2,100x below put's body limit: far above anything a real caller sends,
71
+ * far below anything that threatens house memory. Nothing here depends on either
72
+ * figure being exact. An undocumented D1 name limit cannot plausibly approach 64
73
+ * KiB, and a request body is the wrong place to discover that it had.
74
+ *
75
+ * Deliberately not R2_OBJECT_ENVELOPE_MAX_BODY_BYTES, which happens to hold the
76
+ * same value. That constant is governed by R2's object key limit; this one is not,
77
+ * and the two must be free to drift. Couple what is one fact; separate what merely
78
+ * matches today.
79
+ */
80
+ export const STORAGE_RESOURCE_NAME_MAX_BODY_BYTES = 64 * 1024;
81
+
82
+ /**
83
+ * The largest JSON body /d1/query can legitimately carry: a database name and a
84
+ * SQL statement.
85
+ *
86
+ * The envelope reasoning behind the object routes' bound does not govern this. A
87
+ * large but entirely legal migration or batch statement is not a 1,024-byte object
88
+ * key, so applying that constant here would reject correct queries.
89
+ *
90
+ * The bound is on the wire body, not the decoded statement, and JSON escaping moves
91
+ * those apart. The largest legal statement (D1_MAX_SQL_STATEMENT_BYTES) can present
92
+ * a wire body of up to
93
+ *
94
+ * JSON_STRING_WORST_CASE_EXPANSION * 100,000 + 65,536 = 665,536 bytes
95
+ *
96
+ * so capping at 100,000 would 413 a statement D1 would have accepted. 1 MiB clears
97
+ * the worst case by about 1.6x and sits ~133x below put's body limit.
98
+ *
99
+ * It is a flat number rather than that expression, for legibility at the call site.
100
+ * The arithmetic is not decoration: it is the only thing that makes 1 MiB knowable
101
+ * as safe, so resource-limits.test.ts asserts the relationship. If D1 ever raises
102
+ * its statement limit above 163,840 bytes, that test fails rather than this route
103
+ * quietly beginning to refuse legal SQL.
104
+ */
105
+ export const D1_QUERY_MAX_BODY_BYTES = 1024 * 1024;
@@ -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:15c9a9d54bd8898a18c541ce95b9f5dc869646faad096eefc60741bb405a062d
4
+ content-hash: sha256:c7d0d9f383f8d9ee8ed50b8869ce43477e3cb5ebf62305a39041c072e34bfeb1
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -2057,7 +2057,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
2057
2057
  | `outlook-mail-list` | Mail newest-first. Default top=25, folder=Inbox. `folder` takes a well-known name or alias (case/space-insensitive): Inbox, Sent Items/sent, Drafts, Deleted Items/trash, Junk Email/junk/spam, Archive, Outbox — or a raw folder id. Custom folder names are not resolved. `since`/`before` (ISO date-times) narrow by receipt date. The 250-per-page figure is not a ceiling: the reply carries `nextCursor` when older mail remains — pass it back as `cursor` to page further back. Filter by sender/subject via `outlook-mail-search`. |
2058
2058
  | `outlook-mail-search` | Microsoft Graph `$search` (KQL) over the mailbox. Preview-only, like `outlook-mail-list`, and pages the same way via `nextCursor`/`cursor`. Put sender/subject/recipient/date filters in the query: `from:`, `to:`, `subject:`, `received>=`. Graph forbids `$search`+`$filter`, so a strict date window is on `outlook-mail-list`. |
2059
2059
  | `outlook-mail-fetch-body` | Complete body of one message by id (`GET /me/messages/{id}?$select=body`). HTML decoded to text, no preview cap. The full-read path — list/search return Microsoft's ~255-char preview only. Returns the envelope plus the whole body. |
2060
- | `outlook-mail-attachment` | List or download a message's inbound attachments. With `messageId` alone, LISTS them (`id`, `name`, `contentType`, `size`, `isInline`, and a `kind` of file/item/reference) via `GET /me/messages/{id}/attachments` and `$select` (no bytes). With `messageId`+`attachmentId`, DOWNLOADS one file attachment: its bytes are written to `{accountDir}/uploads/outlook/<messageHash>/<bytesHash>-<name>` at `0o600` and the saved path is returned, ready for the Read tool, `SendUserFile`, or an `outlook-mail-reply` attachment. Only file attachments download — item/reference kinds, empty payloads, and anything over 25 MB are refused with nothing written. `outlook-mail-list` / `outlook-mail-search` / `outlook-mail-fetch-body` report `hasAttachments` so the agent knows when to call this. Needs `Mail.Read`. |
2060
+ | `outlook-mail-attachment` | List or download any message's attachments — received or draft. With `messageId` alone, LISTS them (`id`, `name`, `contentType`, `size`, `isInline`, and a `kind` of file/item/reference) via `GET /me/messages/{id}/attachments` and `$select` (no bytes). A draft is a message in the same collection, so passing a draftId lists what that draft carries; the returned `name`/`id` is what `outlook-draft-edit`'s `removeAttachments` takes. With `messageId`+`attachmentId`, DOWNLOADS one file attachment: its bytes are written to `{accountDir}/uploads/outlook/<messageHash>/<bytesHash>-<name>` at `0o600` and the saved path is returned, ready for the Read tool, `SendUserFile`, or an `outlook-mail-reply` attachment. Only file attachments download — item/reference kinds, empty payloads, and anything over 25 MB are refused with nothing written. `outlook-mail-list` / `outlook-mail-search` / `outlook-mail-fetch-body` report `hasAttachments` so the agent knows when to call this. Needs `Mail.Read`. |
2061
2061
  | `outlook-mail-reply` | Reply in-thread (`createReply` / `createReplyAll` + send). Threads natively via conversationId. `replyAll`, added cc/bcc, and account-scoped file attachments (up to 25 MB per file; files over Graph's ~3 MB inline limit use a chunked upload session). Body is plain text, prepended above the quote. Needs `Mail.ReadWrite` + `Mail.Send`. |
2062
2062
  | `outlook-mail-delete` | Move messages to Deleted Items (`POST /me/messages/{id}/move`). Recoverable — never hard-deletes. An id already gone is counted not-moved. Needs `Mail.ReadWrite`. |
2063
2063
  | `outlook-mail-otp-extract` | Poll the inbox for a one-time code from a sender (domain). Reads full bodies to extract it; optional subject regex and timeout (default 60 s). |
@@ -2073,7 +2073,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
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
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
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`. |
2076
+ | `outlook-draft-edit` | Update an existing draft in place (`PATCH /me/messages/{draftId}`) and/or add and remove attachments. Graph drafts are mutable — pass draftId plus the fields to change (to/cc/bcc/subject/body/isHtml/attachments/removeAttachments). `attachments` adds; `removeAttachments` takes files off by name or attachment id; both together replace a file in one call. 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, 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.
2151
+ Mail move to arbitrary folders / flag, hard (permanent) delete, removing an attachment from a **sent** message (Graph forbids it), 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, with a draft's attachments editable in place (add, remove, replace) — 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
@@ -2715,6 +2715,7 @@ Voice mirror is on by default for every drafting skill. To opt out for one, set
2715
2715
  - **Copy public figures** — voice mirror only learns from your own writing.
2716
2716
  - **Clone audio** — text only, no speech synthesis.
2717
2717
  - **Guess** — historical content stays `unknown` until you mark it. Maxy never auto-classifies your writing. (Automatic ingestion applies only to your live PTY sessions where authorship is certain.)
2718
+ - **Invent an identity** — a voice belongs to a real admin user on the account. Attribution is refused outright if it names anyone else, and nothing is written. A person's name is not an identity, so tagging someone's writing on a shared account needs their actual admin id, not their name.
2718
2719
 
2719
2720
  ## Status
2720
2721
 
@@ -32,7 +32,9 @@ The plugin registers no agent-facing MCP tools. Every Cloudflare operation is th
32
32
  | Skill | Purpose |
33
33
  |---|---|
34
34
  | [cloudflare/SKILL.md](skills/cloudflare/SKILL.md) | The entry point for every Cloudflare operation. Routes each operation class to its reference, names the outcome contracts (tunnel external HTTP 200; hosting deployed URL; D1 round-trip), and states the tool-discipline + secret-redaction rules that bind the agent. |
35
- | [site-deploy/SKILL.md](skills/site-deploy/SKILL.md) | Assemble one canonical content tree into a live Pages deployment on its custom domain that survives the install device being offline. Names the single-source-tree convention, the reused per-scope token, the form-to-D1 trigger, Open Graph correctness, and the cache-busted live-`200` done-gate; defers the command blocks to `hosting-sites.md` and `d1-data-capture.md`. Admin-run; a content specialist hands the deploy off to it. |
35
+ | [site-deploy/SKILL.md](skills/site-deploy/SKILL.md) | Assemble one canonical content tree into a live Pages deployment on its custom domain that survives the install device being offline. Names the single-source-tree convention, the reused per-scope token, the form-to-D1 trigger, the R2 binding trigger, Open Graph correctness, and the cache-busted live-`200` done-gate; defers the command blocks to `hosting-sites.md`, `d1-data-capture.md` and `r2-object-storage.md`. Admin-run; a content specialist hands the deploy off to it. |
36
+ | [calendar-site/SKILL.md](skills/calendar-site/SKILL.md) | Assemble and deploy one account's public booking page — a static page plus edge Functions over a D1 `bookings` table — on its own custom domain, so a submission survives the install device being offline. Admin-run; defers the deploy and the live done-gate to `site-deploy`. |
37
+ | [data-portal/SKILL.md](skills/data-portal/SKILL.md) | Stand up one account's gated file-drop portal on its own custom domain: each enrolled person signs in with their own passcode and can upload, list, download and delete only their own files, into one R2 bucket under a per-person prefix with a D1 manifest row per upload. The bucket is created by the owning account's own session — the broker registers ownership to the creator and never reassigns it — while the admin assembles and deploys. Admin-run; defers the deploy and the live done-gate to `site-deploy`. |
36
38
 
37
39
  ### References
38
40
 
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // Enrol one person into a data portal: mint a passcode, hash it, print the
3
+ // passcode once, and emit the INSERT.
4
+ //
5
+ // This exists because "generate a high-entropy passcode" in a skill's prose is
6
+ // not a constraint — an agent satisfies that sentence with a six-digit number.
7
+ // The strength now lives in code (template/functions/api/_lib/passcode.mjs), and
8
+ // this is the entry point that applies it. Hashing a passcode any other way
9
+ // bypasses the pinned length, so enrolment runs through here.
10
+ //
11
+ // It deliberately stops at emitting SQL. Running `wrangler d1 execute` itself
12
+ // would put the script's only path behind a Cloudflare credential and a network,
13
+ // which makes it untestable; the skill pipes stdout instead.
14
+ //
15
+ // The stream split is load-bearing, not cosmetic. stdout is SQL and NOTHING else,
16
+ // so it pipes straight into wrangler. The passcode goes to stderr: the operator
17
+ // still reads it off their terminal, but no pipe can carry it into wrangler,
18
+ // which would echo it back inside a syntax error — a plaintext passcode in a log
19
+ // is the one thing enrolment must never produce.
20
+ //
21
+ // Usage:
22
+ // node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> --name <name> \
23
+ // | npx wrangler d1 execute <db> --remote --file=-
24
+
25
+ import {
26
+ PASSCODE_LENGTH,
27
+ generatePasscode,
28
+ hashPasscode,
29
+ randomSaltHex,
30
+ } from '../skills/data-portal/template/functions/api/_lib/passcode.mjs'
31
+
32
+ /** The isolation rule is that an R2 key starts with `<ownerId>/`, so an ownerId
33
+ * containing a separator would let one person read another's files. The gate
34
+ * enforces this too; enrolment refuses rather than minting a row the gate will
35
+ * later reject. */
36
+ const OWNER_ID = /^[a-z0-9-]+$/
37
+
38
+ /** @param {string} message */
39
+ function die(message) {
40
+ console.error(`portal-enrol: ${message}`)
41
+ process.exit(1)
42
+ }
43
+
44
+ /** @param {string} flag @returns {string | undefined} */
45
+ function arg(flag) {
46
+ const i = process.argv.indexOf(flag)
47
+ return i === -1 ? undefined : process.argv[i + 1]
48
+ }
49
+
50
+ const ownerId = arg('--owner')
51
+ const name = arg('--name')
52
+
53
+ if (!ownerId) die('--owner <id> is required')
54
+ if (!name) die('--name <name> is required')
55
+ if (!OWNER_ID.test(/** @type {string} */ (ownerId))) {
56
+ die(`--owner must match ${OWNER_ID.source} — the isolation gate rejects anything else`)
57
+ }
58
+ // A newline in the name would split the one statement stdout promises to be.
59
+ // It is not an injection — SQLite has no escape but '' and a newline sits inside
60
+ // the literal harmlessly — but it breaks the pipeable contract, so refuse it.
61
+ if (/[\r\n]/.test(/** @type {string} */ (name))) die('--name cannot contain a line break')
62
+
63
+ const saltBytes = new Uint8Array(16)
64
+ crypto.getRandomValues(saltBytes)
65
+ const saltHex = randomSaltHex(saltBytes)
66
+
67
+ const passcode = generatePasscode()
68
+ const hash = await hashPasscode(passcode, saltHex)
69
+
70
+ // Doubled single quotes: the only SQL literal here not already constrained to a
71
+ // charset is the name, which comes from an operator.
72
+ const sqlName = /** @type {string} */ (name).replace(/'/g, "''")
73
+ const createdAt = new Date().toISOString()
74
+
75
+ // stdout: the statement, alone, pipeable.
76
+ //
77
+ // An upsert, not a bare INSERT, because `ownerId` is TEXT PRIMARY KEY and
78
+ // re-running this script for an existing person IS the rotation path — the only
79
+ // one there is. A bare INSERT dies on the UNIQUE violation inside wrangler, a
80
+ // separate process, AFTER this script has already printed a new passcode and
81
+ // exited 0: the operator hands out a passcode that never worked, believing the
82
+ // leaked one is revoked, while it and its sessions keep resolving. Rotation is
83
+ // precisely the case where a remedy that silently no-ops is worse than none.
84
+ //
85
+ // `createdAt` is deliberately absent from the DO UPDATE: it records when the
86
+ // person was enrolled, and rotating a passcode is not creating a person.
87
+ console.log(
88
+ `INSERT INTO people (ownerId, name, salt, hash, createdAt) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash;`,
89
+ )
90
+
91
+ // stderr: everything for the human. Never stdout — see the note at the top.
92
+ console.error(`passcode: ${passcode}`)
93
+ console.error('')
94
+ console.error(
95
+ `Give the passcode to ${name} out of band, and do not store it anywhere. It is ${PASSCODE_LENGTH} characters and it is not recoverable — re-running this rotates it, which also revokes their live sessions.`,
96
+ )