@rubytech/create-maxy-code 0.1.458 → 0.1.460

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 (218) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +298 -3
  3. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  4. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts +2 -0
  5. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.d.ts.map +1 -0
  6. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +79 -0
  7. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -0
  8. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  9. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  10. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  12. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -1
  13. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  14. package/payload/platform/lib/storage-broker/dist/cf-exec.js +165 -0
  15. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  16. package/payload/platform/lib/storage-broker/dist/house-credential.d.ts.map +1 -1
  17. package/payload/platform/lib/storage-broker/dist/house-credential.js +4 -2
  18. package/payload/platform/lib/storage-broker/dist/house-credential.js.map +1 -1
  19. package/payload/platform/lib/storage-broker/dist/index.d.ts +2 -0
  20. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  21. package/payload/platform/lib/storage-broker/dist/index.js +2 -0
  22. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  23. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +80 -0
  24. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -0
  25. package/payload/platform/lib/storage-broker/dist/object-limits.js +92 -0
  26. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -0
  27. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  28. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  29. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  30. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  31. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +334 -5
  32. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +106 -0
  33. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  34. package/payload/platform/lib/storage-broker/src/cf-exec.ts +221 -6
  35. package/payload/platform/lib/storage-broker/src/house-credential.ts +4 -2
  36. package/payload/platform/lib/storage-broker/src/index.ts +2 -0
  37. package/payload/platform/lib/storage-broker/src/object-limits.ts +95 -0
  38. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  39. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +7 -6
  40. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/cloudflare/bin/cf-token.sh +4 -3
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +206 -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 +74 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  51. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  52. package/payload/platform/plugins/cloudflare/references/api.md +1 -1
  53. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +123 -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.ts +73 -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 +113 -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 +64 -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 +5 -5
  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__/attach.test.d.ts +2 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js +138 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/__tests__/attach.test.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts +2 -0
  90. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.d.ts.map +1 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js +121 -0
  92. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-attachments.test.js.map +1 -0
  93. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts +2 -0
  94. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.d.ts.map +1 -0
  95. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js +102 -0
  96. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-attachments.test.js.map +1 -0
  97. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  98. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  99. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  100. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  101. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  103. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts +2 -0
  104. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.d.ts.map +1 -0
  105. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js +209 -0
  106. package/payload/platform/plugins/outlook/mcp/dist/__tests__/mail-send-attachments.test.js.map +1 -0
  107. package/payload/platform/plugins/outlook/mcp/dist/index.js +12 -8
  108. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  109. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts +37 -0
  110. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.d.ts.map +1 -0
  111. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js +79 -0
  112. package/payload/platform/plugins/outlook/mcp/dist/lib/attach.js.map +1 -0
  113. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  114. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  115. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  116. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  117. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  118. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  119. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  120. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  121. package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts +8 -0
  122. package/payload/platform/plugins/outlook/mcp/dist/lib/message.d.ts.map +1 -1
  123. package/payload/platform/plugins/outlook/mcp/dist/lib/message.js +4 -0
  124. package/payload/platform/plugins/outlook/mcp/dist/lib/message.js.map +1 -1
  125. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +36 -8
  126. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  127. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js +40 -13
  128. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  129. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts +9 -5
  130. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.d.ts.map +1 -1
  131. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js +12 -8
  132. package/payload/platform/plugins/outlook/mcp/dist/lib/upload-session.js.map +1 -1
  133. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +49 -4
  134. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  135. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +82 -13
  136. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  137. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts +8 -2
  138. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  139. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +13 -4
  140. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  141. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  142. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  143. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  144. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  145. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
  146. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +3 -20
  147. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
  148. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts +27 -3
  149. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
  150. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +53 -8
  151. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
  152. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  153. package/payload/platform/plugins/outlook/references/graph-surfaces.md +33 -6
  154. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +22 -5
  155. package/payload/platform/plugins/storage-broker/PLUGIN.md +41 -2
  156. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts +2 -0
  157. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.d.ts.map +1 -0
  158. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js +85 -0
  159. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/confine-path.test.js.map +1 -0
  160. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts +2 -0
  161. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.d.ts.map +1 -0
  162. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js +77 -0
  163. package/payload/platform/plugins/storage-broker/mcp/dist/__tests__/put-source.test.js.map +1 -0
  164. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts +12 -0
  165. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.d.ts.map +1 -0
  166. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js +107 -0
  167. package/payload/platform/plugins/storage-broker/mcp/dist/confine-path.js.map +1 -0
  168. package/payload/platform/plugins/storage-broker/mcp/dist/index.js +86 -0
  169. package/payload/platform/plugins/storage-broker/mcp/dist/index.js.map +1 -1
  170. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts +11 -0
  171. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.d.ts.map +1 -0
  172. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js +35 -0
  173. package/payload/platform/plugins/storage-broker/mcp/dist/put-source.js.map +1 -0
  174. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  175. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  176. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  177. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  178. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  179. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  180. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  181. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  182. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  183. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +13 -3
  184. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  185. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  186. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  187. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  188. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  189. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  190. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  191. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  192. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  193. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  194. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  195. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +334 -0
  196. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +13 -0
  197. package/payload/platform/scripts/logs-read-jsonl.test.sh +118 -0
  198. package/payload/platform/scripts/logs-read.sh +63 -9
  199. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  200. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  201. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +4 -0
  202. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  203. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  204. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  205. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  206. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  207. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  208. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  209. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +34 -23
  210. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  211. package/payload/server/{chunk-64FGYKNZ.js → chunk-JXWFVE5X.js} +148 -0
  212. package/payload/server/{chunk-F4D35LKL.js → chunk-POBXIHOF.js} +34 -0
  213. package/payload/server/maxy-edge.js +1 -1
  214. package/payload/server/server.js +412 -37
  215. package/payload/server/{src-S7C4P6TT.js → src-4F37OHLK.js} +19 -1
  216. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  217. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  218. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
@@ -0,0 +1,123 @@
1
+ ---
2
+ name: data-portal
3
+ description: Stand up a per-account gated file-drop portal on its own custom domain, where named people each sign in with their own passcode and can upload, list, download and delete only their own files. Files land in one R2 bucket under a per-person prefix and every upload writes a D1 manifest row the agent later ingests. Use when an account needs somewhere for named people to drop photos, invoices or spreadsheets — "set up a file drop for <client>", "somewhere for <person> to send us their invoices", "a place clients can upload documents".
4
+ ---
5
+
6
+ # Data portal
7
+
8
+ Assemble and deploy one account's file-drop portal. This is an outcome contract, not a copy of the
9
+ Pages mechanics. The deploy, custom-domain attach, token discipline, and live done-gate are owned by
10
+ `site-deploy`; the R2 store rules are owned by `references/r2-object-storage.md`. Read those
11
+ references with `plugin-read` before issuing their commands.
12
+
13
+ ## What this skill produces, and what it does not
14
+
15
+ It produces a Pages project on a custom domain, an R2 bucket holding one prefix per enrolled person,
16
+ and a D1 database holding the manifest, the enrolment records, and the sessions. It does not ingest
17
+ the dropped files: a separate device-side sweep reads un-ingested manifest rows and pulls them into
18
+ the graph. This skill delivers the manifest that sweep consumes.
19
+
20
+ ## Why the portal is on Pages and not the install
21
+
22
+ Uploads must keep succeeding while the device is offline, which is the same reason the booking page
23
+ went to Pages. The install's magic-link gate cannot be reused: it gates `/api/chat` on the install
24
+ and its sessions live in memory, so it does not cross origins.
25
+
26
+ ## Provisioning is two sessions, and the order matters
27
+
28
+ The storage broker registers a bucket to **whoever creates it**, and that ownership is set on create
29
+ and never reassigned. There is no delegation.
30
+
31
+ 1. **In the client account's own session**, create the bucket with `storage-r2-bucket-create`, named
32
+ from `bin/cf-store-name.sh <accountId> <purpose>`. It registers to that account.
33
+ 2. **In the admin session**, do everything else: the D1 database, the assembly, and the deploy.
34
+
35
+ If the admin creates the bucket instead, it registers to the house, and the client account's own
36
+ ingestion sweep is denied `cross-account` against its own files — permanently, because ownership is
37
+ never reassigned. The portal would appear to work while nothing on the client side could read what
38
+ it stored.
39
+
40
+ ## Enrol the people first
41
+
42
+ Collect from the operator the portal's custom domain and the list of people, then write
43
+ `<accountDir>/data-portal.json` carrying `portalDomain`, `portalDbName`, `bucketName`, and a
44
+ `people` array of `{ name, ownerId, prefix }`.
45
+
46
+ For each person, generate a high-entropy passcode, print it to the operator **once**, and insert the
47
+ person into D1 with only a PBKDF2 salt and hash. **A passcode is never stored in plaintext** — not
48
+ in `data-portal.json`, not in a log line, nowhere on the device. The operator conveys it to the
49
+ person out of band. Rotating a passcode is a re-run of this step for that person; there is no
50
+ separate editor.
51
+
52
+ Rotation also revokes: a session records a digest of the person's passcode hash at sign-in, and the
53
+ session lookup requires it to still match, so re-running enrolment kills every live session for that
54
+ person immediately. There is no cleanup step to remember and no window to wait out — which is the
55
+ point, because a leaked passcode is exactly the case where a procedure you have to remember fails.
56
+ Deleting the person's row revokes their sessions the same way.
57
+
58
+ The `ownerId` must match `[a-z0-9-]+`. This is not cosmetic: the whole isolation rule is that a key
59
+ starts with `<ownerId>/`, so an `ownerId` containing a separator would let one person read another's
60
+ files. The gate enforces it too, but do not enrol a name it will reject.
61
+
62
+ ## Assemble the canonical tree
63
+
64
+ Copy `template/` to `<accountDir>/pages/<project>/` and fill `wrangler.toml`'s `__PROJECT_NAME__`,
65
+ `__D1_DATABASE_NAME__`, `__D1_DATABASE_ID__`, and `__R2_BUCKET_NAME__`. **Leave no `__...__`
66
+ placeholder in the assembled tree.**
67
+
68
+ Name the D1 database with the same `bin/cf-store-name.sh` scheme as the bucket, and apply
69
+ `schema.sql` to the remote database. It is `IF NOT EXISTS` throughout, so re-applying is safe.
70
+
71
+ ## Deploy through site-deploy
72
+
73
+ Hand the assembled tree to `cloudflare:site-deploy`. Both bindings — D1 and R2 — are declarative
74
+ config in `wrangler.toml`, carried in the deploy payload under the Pages scope; the deploy needs no
75
+ R2 token. Only the bucket *creation* in step 1 needs R2 write, and the broker holds that.
76
+
77
+ ## The portal needs a paid Cloudflare plan
78
+
79
+ Check this before promising a date. Signing in costs one PBKDF2 derivation at the OWASP floor of
80
+ 600,000 iterations, measured at roughly 250ms of CPU. Workers Free allows **10ms of CPU per
81
+ request**, so on a free plan authentication cannot succeed — and no OWASP-compliant setting fits
82
+ either, since even 100,000 iterations costs about 40ms. Paid plans allow far more and are fine.
83
+
84
+ The cost is tunable without a migration if it ever needs to be: `iterations` is stored per person
85
+ row, so it is the enrolment-time value that binds, not a constant baked into the deployed code.
86
+
87
+ ## Done-gate
88
+
89
+ The portal is not done until all five hold on the live domain:
90
+
91
+ 1. A cache-busted request to the portal domain returns `HTTP/2 200`.
92
+ 2. Signing in with the right passcode returns `200` **within the CPU budget** rather than timing
93
+ out — this is the check that catches a free-plan deployment, and it fails nowhere else.
94
+ 3. A wrong passcode returns `401`; the **sixth** rapid wrong passcode returns `429` (five are
95
+ allowed per fifteen minutes, per person, per IP).
96
+ 4. An upload as one person returns `200`, and the object is present in R2 under that person's
97
+ prefix with exactly one manifest row at `ingested=0`. Re-uploading the same filename also
98
+ returns `200`, replaces the object, and leaves exactly one row, now back at `ingested=0`.
99
+ 5. That person's browser cannot list, download, or delete another person's file.
100
+
101
+ A `502` on `/api/upload` with `op=r2-put result=failed` on every attempt is the expected signature of
102
+ an R2 binding that did not deploy — check `wrangler.toml` reached the tree with its placeholder
103
+ filled. An auth request that hangs or 500s while everything else works is the free-plan signature
104
+ above, not a code fault.
105
+
106
+ Uploads are capped at 25 MiB and a larger one returns `413` with `result=too-large`.
107
+
108
+ ## Observability
109
+
110
+ Every step of an upload emits one `[data-portal]` line correlated by `uploadId`, ending in
111
+ `op=complete` with `objectPresent` and `rowPresent` **re-read** rather than assumed. Passcodes,
112
+ hashes, and object bodies are never logged.
113
+
114
+ Read one upload's full lifeline by filtering the brand journal on its `uploadId`.
115
+
116
+ The standing audit that would reconcile R2 objects against manifest rows is not built yet. Until it
117
+ is, an object with no manifest row and a row with no object are both invisible: neither emits an
118
+ event at request time.
119
+
120
+ ## Tool discipline
121
+
122
+ Never write a token into the tree, never commit one, never echo one. The bucket name and database
123
+ name come from `bin/cf-store-name.sh`, never hand-built.
@@ -0,0 +1,32 @@
1
+ // The isolation rule.
2
+ //
3
+ // Every file op authorizes owner-vs-key here BEFORE any R2 call. The
4
+ // per-person key prefix is a consequence of this check, never the check
5
+ // itself: a caller could otherwise reach another person's files by naming
6
+ // them, and prefix convention alone would not stop it.
7
+
8
+ const SEP = '/'
9
+
10
+ // The whole gate rests on an ownerId that cannot contain the separator. If two
11
+ // people were ever enrolled as 'alice' and 'alice/2024', then
12
+ // 'alice/2024/invoice.pdf' starts with 'alice/' and alice would read the other
13
+ // person's files. Enrolment is an agent writing rows, so this invariant cannot
14
+ // live only in the skill's prose — it is enforced here, where the decision is
15
+ // actually made, and it fails closed.
16
+ const OWNER_ID = /^[a-z0-9-]+$/
17
+
18
+ export function ownerPrefix(ownerId: string): string {
19
+ return `${ownerId}${SEP}`
20
+ }
21
+
22
+ export function authorizeKey(ownerId: string, key: string): boolean {
23
+ if (!ownerId || !key) return false
24
+ if (!OWNER_ID.test(ownerId)) return false
25
+ // Reject traversal and absolute forms outright rather than normalising them:
26
+ // a normaliser is one more thing that can be wrong, and no legitimate key
27
+ // contains these.
28
+ if (key.includes('..') || key.startsWith(SEP)) return false
29
+ // startsWith(ownerId) alone would authorize 'alice-evil/...' for 'alice'.
30
+ // The separator is part of the rule.
31
+ return key.startsWith(ownerPrefix(ownerId))
32
+ }
@@ -0,0 +1,12 @@
1
+ // The log grammar is `[tag] op=<verb> key=value …`, space-separated. Any field
2
+ // carrying free text has to be quoted, because the values here are not ours:
3
+ // `ownerId` is unvalidated JSON off the wire at auth time, and `filename` comes
4
+ // straight from a multipart part name. Unquoted, a newline forges whole log
5
+ // lines and an embedded `result=ok` corrupts the line's parse — in a portal
6
+ // where the lifeline IS the diagnostic contract, and whose deferred reconcile
7
+ // audit is specified to read these lines back.
8
+
9
+ /** Render a free-text value as a quoted, single-line log field. */
10
+ export function q(value: string): string {
11
+ return `"${value.replace(/[\\"]/g, '\\$&').replace(/[\r\n\t]/g, ' ')}"`
12
+ }
@@ -0,0 +1,73 @@
1
+ // Passcode hashing for the data portal. PBKDF2-SHA256 via Web Crypto, which
2
+ // exists identically in the Workers runtime and in Node's webcrypto, so these
3
+ // functions are tested against real crypto rather than a fake.
4
+ //
5
+ // constantTimeEqual is hand-rolled deliberately: crypto.subtle.timingSafeEqual
6
+ // is a Cloudflare extension with no Node equivalent, so depending on it would
7
+ // make the comparison untestable here.
8
+
9
+ export const PBKDF2_ITERATIONS = 600_000
10
+
11
+ export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
12
+ // Length is not secret; an early return on it leaks nothing a caller cannot
13
+ // already see. The byte loop below never short-circuits.
14
+ if (a.length !== b.length) return false
15
+ let diff = 0
16
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
17
+ return diff === 0
18
+ }
19
+
20
+ export function randomSaltHex(bytes: Uint8Array): string {
21
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
22
+ }
23
+
24
+ // Returns Uint8Array<ArrayBuffer>, not the bare Uint8Array: the bare form
25
+ // widens to ArrayBufferLike, which Web Crypto will not accept as a BufferSource
26
+ // because a SharedArrayBuffer could back it.
27
+ function hexToBytes(hex: string): Uint8Array<ArrayBuffer> | null {
28
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null
29
+ const out = new Uint8Array(hex.length / 2)
30
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
31
+ return out
32
+ }
33
+
34
+ export async function hashPasscode(
35
+ passcode: string,
36
+ saltHex: string,
37
+ iterations: number,
38
+ ): Promise<string> {
39
+ const salt = hexToBytes(saltHex)
40
+ if (!salt) throw new Error('salt must be hex')
41
+ const key = await crypto.subtle.importKey(
42
+ 'raw',
43
+ new TextEncoder().encode(passcode),
44
+ 'PBKDF2',
45
+ false,
46
+ ['deriveBits'],
47
+ )
48
+ const bits = await crypto.subtle.deriveBits(
49
+ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
50
+ key,
51
+ 256,
52
+ )
53
+ return randomSaltHex(new Uint8Array(bits))
54
+ }
55
+
56
+ export async function verifyPasscode(
57
+ passcode: string,
58
+ saltHex: string,
59
+ expectedHashHex: string,
60
+ iterations: number,
61
+ ): Promise<boolean> {
62
+ const expected = hexToBytes(expectedHashHex)
63
+ if (!expected) return false
64
+ let actualHex: string
65
+ try {
66
+ actualHex = await hashPasscode(passcode, saltHex, iterations)
67
+ } catch {
68
+ return false
69
+ }
70
+ const actual = hexToBytes(actualHex)
71
+ if (!actual) return false
72
+ return constantTimeEqual(actual, expected)
73
+ }
@@ -0,0 +1,55 @@
1
+ import type { D1Database } from './types'
2
+
3
+ // Fixed-window attempt counter in D1.
4
+ //
5
+ // Every rate limiter elsewhere in this platform is an in-memory Map in a
6
+ // long-lived Node process (ui/server/routes/client-error.ts). None of that
7
+ // ports here: Pages Functions are stateless and spread across edge locations,
8
+ // so the counter must live in storage the whole edge shares.
9
+ //
10
+ // D1 has no atomic increment, so two concurrent requests can read the same
11
+ // count and both be allowed. This is a speed bump against passcode guessing,
12
+ // not a hard limit, and the docs say so rather than implying a guarantee.
13
+
14
+ export const RATE_WINDOW_MS = 900_000 // 15 minutes
15
+ export const RATE_MAX_ATTEMPTS = 5
16
+
17
+ /** The counter's key. The client IP is part of it, not decoration: keyed on the
18
+ * owner alone, the limiter is a weapon rather than a shield — six
19
+ * unauthenticated requests naming a person lock that person out of their own
20
+ * portal for the window, from anywhere, repeatably. Keyed on owner+IP, an
21
+ * attacker throttles only themselves. The cost is that a distributed guesser
22
+ * gets RATE_MAX_ATTEMPTS per address, which the passcodes' entropy carries. */
23
+ export function rateScope(ownerId: string, clientIp: string): string {
24
+ return `${ownerId}|${clientIp}`
25
+ }
26
+
27
+ /** Clear a scope's counter. Called on a successful sign-in, so a person doing
28
+ * ordinary work does not lock themselves out. */
29
+ export async function clearAttempts(db: D1Database, scope: string): Promise<void> {
30
+ await db.prepare('DELETE FROM auth_attempts WHERE scope = ?').bind(scope).run()
31
+ }
32
+
33
+ export async function checkAndRecordAttempt(
34
+ db: D1Database,
35
+ scope: string,
36
+ nowMs: number,
37
+ ): Promise<{ allowed: boolean; count: number }> {
38
+ const windowStart = Math.floor(nowMs / RATE_WINDOW_MS) * RATE_WINDOW_MS
39
+
40
+ await db
41
+ .prepare(
42
+ `INSERT INTO auth_attempts (scope, windowStart, count) VALUES (?, ?, 1)
43
+ ON CONFLICT (scope, windowStart) DO UPDATE SET count = count + 1`,
44
+ )
45
+ .bind(scope, windowStart)
46
+ .run()
47
+
48
+ const row = await db
49
+ .prepare('SELECT count FROM auth_attempts WHERE scope = ? AND windowStart = ?')
50
+ .bind(scope, windowStart)
51
+ .first<{ count: number }>()
52
+
53
+ const count = typeof row?.count === 'number' ? row.count : 1
54
+ return { allowed: count <= RATE_MAX_ATTEMPTS, count }
55
+ }
@@ -0,0 +1,85 @@
1
+ import type { D1Database } from './types'
2
+
3
+ // Server-side sessions in D1 rather than a signed stateless token: a signed
4
+ // token needs a signing secret provisioned into the Pages env, a secret
5
+ // lifecycle this plugin does not have. D1 needs no secret and is revocable.
6
+ // The cost is one D1 read per request.
7
+ //
8
+ // "Revocable" is a claim this module has to actually honour, so revocation is
9
+ // structural rather than procedural. Each session records `pcCheck`, a short
10
+ // digest of the person's passcode hash at mint time, and resolveSession joins
11
+ // `people` and requires it to still match. Rotating a passcode necessarily
12
+ // changes the hash, so every session minted against the old one stops
13
+ // resolving with no cleanup step to remember and no window to wait out.
14
+ // Unenrolling a person kills their sessions by the same join.
15
+
16
+ export const SESSION_TTL_MS = 43_200_000 // 12 hours
17
+
18
+ // The __Host- prefix is the only thing a browser enforces against a cookie set
19
+ // on a sibling subdomain with Domain=.example.com shadowing this one — which
20
+ // would let an attacker fix a victim's session and receive their uploads. It
21
+ // requires Secure, Path=/, and no Domain; the Set-Cookie in auth.ts meets all
22
+ // three.
23
+ export const SESSION_COOKIE = '__Host-__portal_session'
24
+
25
+ /** The stored session-vs-passcode check. A prefix of an already-irreversible
26
+ * hash, kept next to it in the same database — it exposes nothing new. */
27
+ export function passcodeCheck(hash: string): string {
28
+ return hash.slice(0, 16)
29
+ }
30
+
31
+ export async function mintSession(
32
+ db: D1Database,
33
+ ownerId: string,
34
+ sessionId: string,
35
+ nowMs: number,
36
+ pcCheck: string,
37
+ ): Promise<string> {
38
+ await db
39
+ .prepare(
40
+ 'INSERT INTO sessions (sessionId, ownerId, pcCheck, expiresAt, createdAt) VALUES (?, ?, ?, ?, ?)',
41
+ )
42
+ .bind(sessionId, ownerId, pcCheck, nowMs + SESSION_TTL_MS, new Date(nowMs).toISOString())
43
+ .run()
44
+ // Opportunistic GC: expired rows are dead weight and nothing else prunes
45
+ // them. Scoped to this owner so it stays a small, indexed delete.
46
+ await db
47
+ .prepare('DELETE FROM sessions WHERE ownerId = ? AND expiresAt <= ?')
48
+ .bind(ownerId, nowMs)
49
+ .run()
50
+ return sessionId
51
+ }
52
+
53
+ export async function resolveSession(
54
+ db: D1Database,
55
+ sessionId: string,
56
+ nowMs: number,
57
+ ): Promise<{ ownerId: string } | null> {
58
+ if (!sessionId) return null
59
+ const row = await db
60
+ .prepare(
61
+ `SELECT s.ownerId AS ownerId
62
+ FROM sessions s
63
+ JOIN people p ON p.ownerId = s.ownerId
64
+ WHERE s.sessionId = ?
65
+ AND s.expiresAt > ?
66
+ AND s.pcCheck = substr(p.hash, 1, 16)`,
67
+ )
68
+ .bind(sessionId, nowMs)
69
+ .first<{ ownerId: string }>()
70
+ return row && typeof row.ownerId === 'string' ? { ownerId: row.ownerId } : null
71
+ }
72
+
73
+ export function readSessionCookie(header: string | null): string | null {
74
+ if (!header) return null
75
+ for (const part of header.split(';')) {
76
+ const [name, ...rest] = part.trim().split('=')
77
+ if (name !== SESSION_COOKIE) continue
78
+ const value = rest.join('=')
79
+ // Keep scanning rather than returning on the first match: a planted
80
+ // valueless duplicate sorted first would otherwise blank the real session,
81
+ // which is a persistent logout denial-of-service.
82
+ if (value) return value
83
+ }
84
+ return null
85
+ }
@@ -0,0 +1,44 @@
1
+ // Structural interfaces for the Pages runtime bindings this portal uses.
2
+ // Declared here rather than imported from @cloudflare/workers-types (not a
3
+ // dependency in this repo) so every function below is testable with a
4
+ // hand-rolled fake — the choice book.ts:12-24 already makes.
5
+
6
+ export interface D1Result<T = Record<string, unknown>> {
7
+ results?: T[]
8
+ meta?: { changes?: number }
9
+ }
10
+
11
+ export interface D1PreparedStatement {
12
+ bind: (...values: unknown[]) => D1PreparedStatement
13
+ run: () => Promise<D1Result>
14
+ all: <T = Record<string, unknown>>() => Promise<D1Result<T>>
15
+ first: <T = Record<string, unknown>>() => Promise<T | null>
16
+ }
17
+
18
+ export interface D1Database {
19
+ prepare: (query: string) => D1PreparedStatement
20
+ }
21
+
22
+ export interface R2ObjectBody {
23
+ body: ReadableStream | null
24
+ size: number
25
+ }
26
+
27
+ export interface R2Bucket {
28
+ put: (key: string, value: ArrayBuffer | Uint8Array) => Promise<unknown>
29
+ get: (key: string) => Promise<R2ObjectBody | null>
30
+ delete: (key: string) => Promise<void>
31
+ head: (key: string) => Promise<{ size: number } | null>
32
+ }
33
+
34
+ export interface PortalEnv {
35
+ DB: D1Database
36
+ BUCKET: R2Bucket
37
+ }
38
+
39
+ export type Logger = (line: string) => void
40
+
41
+ export interface Handler {
42
+ status: number
43
+ payload: Record<string, unknown>
44
+ }
@@ -0,0 +1,113 @@
1
+ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
+ import { PBKDF2_ITERATIONS, verifyPasscode } from './_lib/passcode'
3
+ import { checkAndRecordAttempt, clearAttempts, rateScope } from './_lib/ratelimit'
4
+ import { SESSION_COOKIE, SESSION_TTL_MS, mintSession, passcodeCheck } from './_lib/session'
5
+ import { q } from './_lib/log'
6
+
7
+ export interface AuthBody {
8
+ ownerId?: unknown
9
+ passcode?: unknown
10
+ }
11
+
12
+ interface AuthResult extends Handler {
13
+ sessionId?: string
14
+ }
15
+
16
+ // Same denial payload for a wrong passcode and an unknown owner: distinguishing
17
+ // them would enumerate who is enrolled.
18
+ const DENIED: Handler = { status: 401, payload: { ok: false, error: 'denied' } }
19
+
20
+ // A real-shaped salt and hash used only to burn the same PBKDF2 cost when the
21
+ // owner does not exist. Without it, an unknown owner returns after one D1 read
22
+ // (single-digit ms) while a known one spends ~250ms deriving — a timing oracle
23
+ // three orders of magnitude wide, which enumerates the enrolled people outright
24
+ // and makes the identical DENIED payload above cosmetic.
25
+ const DUMMY_SALT = '00000000000000000000000000000000'
26
+ const DUMMY_HASH = '0'.repeat(64)
27
+
28
+ export async function processAuth(
29
+ body: AuthBody,
30
+ env: PortalEnv,
31
+ log: Logger,
32
+ newId: () => string,
33
+ nowMs: number,
34
+ clientIp: string,
35
+ ): Promise<AuthResult> {
36
+ const ownerId = typeof body?.ownerId === 'string' ? body.ownerId : ''
37
+ const passcode = typeof body?.passcode === 'string' ? body.passcode : ''
38
+ if (!ownerId || !passcode) {
39
+ log('[data-portal] op=auth owner=none result=malformed')
40
+ return { status: 400, payload: { ok: false, error: 'ownerId and passcode required' } }
41
+ }
42
+
43
+ // Rate-limit BEFORE any passcode comparison, so a guessing loop is throttled
44
+ // rather than merely logged. The scope carries the client IP as well as the
45
+ // owner: keyed on the owner alone, the counter is a weapon — six requests
46
+ // naming a person lock that person out of their own portal for the window,
47
+ // from anywhere, unauthenticated.
48
+ const scope = rateScope(ownerId, clientIp)
49
+ const rate = await checkAndRecordAttempt(env.DB, scope, nowMs)
50
+ if (!rate.allowed) {
51
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=rate-limited attempts=${rate.count}`)
52
+ return { status: 429, payload: { ok: false, error: 'too many attempts' } }
53
+ }
54
+
55
+ const person = await env.DB.prepare(
56
+ 'SELECT ownerId, salt, hash, iterations FROM people WHERE ownerId = ?',
57
+ )
58
+ .bind(ownerId)
59
+ .first<{ ownerId: string; salt: string; hash: string; iterations: number }>()
60
+
61
+ if (!person) {
62
+ // Burn the same work an enrolled owner would cost, then deny identically.
63
+ await verifyPasscode(passcode, DUMMY_SALT, DUMMY_HASH, PBKDF2_ITERATIONS)
64
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=no-such-owner`)
65
+ return DENIED
66
+ }
67
+
68
+ const ok = await verifyPasscode(passcode, person.salt, person.hash, person.iterations)
69
+ if (!ok) {
70
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=denied reason=bad-passcode`)
71
+ return DENIED
72
+ }
73
+
74
+ // A successful sign-in clears the counter. Without this, six legitimate
75
+ // sign-ins inside one window lock the person out of their own files.
76
+ await clearAttempts(env.DB, scope)
77
+
78
+ const sessionId = newId()
79
+ await mintSession(env.DB, ownerId, sessionId, nowMs, passcodeCheck(person.hash))
80
+ log(`[data-portal] op=auth owner=${q(ownerId)} result=ok`)
81
+ return { status: 200, payload: { ok: true }, sessionId }
82
+ }
83
+
84
+ interface PagesContext {
85
+ request: Request
86
+ env: PortalEnv
87
+ }
88
+
89
+ export async function onRequestPost(context: PagesContext): Promise<Response> {
90
+ let body: AuthBody
91
+ try {
92
+ body = (await context.request.json()) as AuthBody
93
+ } catch {
94
+ return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 })
95
+ }
96
+ const { status, payload, sessionId } = await processAuth(
97
+ body,
98
+ context.env,
99
+ (line) => console.log(line),
100
+ () => crypto.randomUUID(),
101
+ Date.now(),
102
+ context.request.headers.get('cf-connecting-ip') ?? 'unknown',
103
+ )
104
+ const res = Response.json(payload, { status })
105
+ if (sessionId) {
106
+ // Secure + Path=/ + no Domain are the __Host- prefix's preconditions.
107
+ res.headers.append(
108
+ 'Set-Cookie',
109
+ `${SESSION_COOKIE}=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`,
110
+ )
111
+ }
112
+ return res
113
+ }
@@ -0,0 +1,89 @@
1
+ import type { Handler, Logger, PortalEnv } from './_lib/types'
2
+ import { readSessionCookie, resolveSession } from './_lib/session'
3
+ import { q } from './_lib/log'
4
+ import { authorizeKey } from './_lib/authorize'
5
+
6
+ export async function processDelete(
7
+ sessionId: string,
8
+ key: string,
9
+ env: PortalEnv,
10
+ log: Logger,
11
+ now: () => number,
12
+ ): Promise<Handler> {
13
+ const startedMs = now()
14
+ const session = await resolveSession(env.DB, sessionId, startedMs)
15
+ if (!session) {
16
+ log('[data-portal] op=delete owner=none result=denied')
17
+ return { status: 401, payload: { ok: false, error: 'denied' } }
18
+ }
19
+ if (!authorizeKey(session.ownerId, key)) {
20
+ log(`[data-portal] op=delete owner=${q(session.ownerId)} result=denied reason=not-owner`)
21
+ return { status: 403, payload: { ok: false, error: 'denied' } }
22
+ }
23
+ const owner = q(session.ownerId)
24
+
25
+ // Both writes are wrapped and each emits its own line, mirroring upload's
26
+ // steps 3-5. Unwrapped, a throw here escapes the handler, Pages returns a
27
+ // bare 500, and NOTHING is logged — and if the R2 delete succeeded before the
28
+ // D1 delete threw, that silently manufactures a phantom row, which is exactly
29
+ // the failure class the deferred reconcile audit exists to catch. The
30
+ // lifeline that should have caught it must not be the thing that goes quiet.
31
+ try {
32
+ await env.BUCKET.delete(key)
33
+ log(`[data-portal] op=r2-delete owner=${owner} key=${q(key)} result=ok`)
34
+ } catch (err) {
35
+ log(
36
+ `[data-portal] op=r2-delete owner=${owner} key=${q(key)} result=failed err="${(err as Error).message}"`,
37
+ )
38
+ return { status: 502, payload: { ok: false, error: 'delete failed' } }
39
+ }
40
+
41
+ try {
42
+ await env.DB.prepare('DELETE FROM manifest WHERE objectKey = ?').bind(key).run()
43
+ log(`[data-portal] op=manifest-delete owner=${owner} key=${q(key)} result=ok`)
44
+ } catch (err) {
45
+ log(
46
+ `[data-portal] op=manifest-delete owner=${owner} key=${q(key)} result=failed err="${(err as Error).message}"`,
47
+ )
48
+ return { status: 502, payload: { ok: false, error: 'manifest delete failed' } }
49
+ }
50
+
51
+ // The verified post-condition: both sides re-read, not "we called delete".
52
+ const objectPresent = (await env.BUCKET.head(key)) !== null
53
+ const rowPresent =
54
+ (await env.DB.prepare('SELECT fileId FROM manifest WHERE objectKey = ?').bind(key).first()) !==
55
+ null
56
+ // Derived, never hardcoded: a failed delete printing result=ok next to
57
+ // objectPresent=true is a self-contradicting line.
58
+ const result = !objectPresent && !rowPresent ? 'ok' : 'incomplete'
59
+ log(
60
+ `[data-portal] op=complete owner=${owner} key=${q(key)} op2=delete ms=${now() - startedMs} result=${result} objectPresent=${objectPresent} rowPresent=${rowPresent}`,
61
+ )
62
+ return {
63
+ status: result === 'ok' ? 200 : 500,
64
+ payload: { ok: result === 'ok', objectPresent, rowPresent },
65
+ }
66
+ }
67
+
68
+ interface PagesContext {
69
+ request: Request
70
+ env: PortalEnv
71
+ }
72
+
73
+ export async function onRequestPost(context: PagesContext): Promise<Response> {
74
+ const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
75
+ let key = ''
76
+ try {
77
+ key = String(((await context.request.json()) as { key?: unknown }).key ?? '')
78
+ } catch {
79
+ return Response.json({ ok: false, error: 'invalid JSON' }, { status: 400 })
80
+ }
81
+ const { status, payload } = await processDelete(
82
+ sessionId,
83
+ key,
84
+ context.env,
85
+ (line) => console.log(line),
86
+ () => Date.now(),
87
+ )
88
+ return Response.json(payload, { status })
89
+ }