@rubytech/create-maxy-code 0.1.489 → 0.1.491

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 (221) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +1716 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +103 -0
  4. package/payload/platform/lib/storage-broker/dist/audit.d.ts +29 -1
  5. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  6. package/payload/platform/lib/storage-broker/dist/audit.js +25 -2
  7. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  8. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -0
  9. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -0
  11. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/src/audit.ts +59 -1
  13. package/payload/platform/lib/storage-broker/src/cf-exec.ts +48 -0
  14. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +34 -5
  15. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +28 -8
  16. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +133 -35
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
  18. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +141 -9
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +111 -4
  27. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +52 -14
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +70 -7
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +59 -4
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +58 -9
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -1
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
  39. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +216 -20
  40. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
  41. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  42. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  43. package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
  44. package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
  45. package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
  46. package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
  47. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
  48. package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
  49. package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
  50. package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
  51. package/payload/platform/templates/specialists/agents/librarian.md +6 -0
  52. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
  53. package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
  54. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
  55. package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
  56. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
  57. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
  58. package/payload/server/{chunk-UFXRUKXO.js → chunk-KZFE3MOY.js} +49 -3
  59. package/payload/server/public/activity.html +5 -6
  60. package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
  61. package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
  62. package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
  63. package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
  64. package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
  65. package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
  66. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
  67. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
  68. package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
  69. package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
  70. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
  71. package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
  72. package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
  73. package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
  74. package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
  75. package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
  76. package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
  77. package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
  78. package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
  79. package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
  80. package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
  81. package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
  82. package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
  83. package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
  84. package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
  85. package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
  86. package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
  87. package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
  88. package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
  89. package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
  90. package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
  91. package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
  92. package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
  93. package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
  94. package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
  95. package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
  96. package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
  97. package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
  98. package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
  99. package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
  100. package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
  101. package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
  102. package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
  103. package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
  104. package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
  105. package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
  106. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
  107. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
  108. package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
  109. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
  110. package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
  111. package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
  112. package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
  113. package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
  114. package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
  115. package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
  116. package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
  117. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
  118. package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
  119. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
  120. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
  121. package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
  122. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
  123. package/payload/server/public/assets/{graph-Cyh6FWaV.js → graph-kaxS9a_7.js} +2 -2
  124. package/payload/server/public/assets/{graph-labels-CUQtXFKW.js → graph-labels-GgejRtgV.js} +1 -1
  125. package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
  126. package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
  127. package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
  128. package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
  129. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
  130. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
  131. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
  132. package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
  133. package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
  134. package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
  135. package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
  136. package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
  137. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
  138. package/payload/server/public/assets/operator-CldXXilj.js +1 -0
  139. package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
  140. package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
  141. package/payload/server/public/assets/{page-Cno8NNMk.js → page-BE6DkJP9.js} +1 -1
  142. package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
  143. package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
  144. package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
  145. package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
  146. package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
  147. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
  148. package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
  149. package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
  150. package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
  151. package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
  152. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
  153. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
  154. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
  155. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
  156. package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
  157. package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
  158. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
  159. package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
  160. package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
  161. package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
  162. package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
  163. package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
  164. package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
  165. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
  166. package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
  167. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
  168. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
  169. package/payload/server/public/browser.html +4 -4
  170. package/payload/server/public/calendar.html +6 -6
  171. package/payload/server/public/chat.html +14 -14
  172. package/payload/server/public/data.html +11 -11
  173. package/payload/server/public/graph.html +10 -11
  174. package/payload/server/public/index.html +16 -17
  175. package/payload/server/public/operator.html +15 -16
  176. package/payload/server/public/public.html +14 -14
  177. package/payload/server/public/tasks.html +6 -6
  178. package/payload/server/server.js +1488 -895
  179. package/payload/server/{src-YJF3UJNR.js → src-UYDFMWF4.js} +1 -1
  180. package/payload/platform/lib/active-rules/package-lock.json +0 -12
  181. package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
  182. package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
  183. package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
  184. package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
  185. package/payload/server/public/assets/admin-DvQPTxqe.js +0 -1
  186. package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
  187. package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
  188. package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
  189. package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
  190. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
  191. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
  192. package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
  193. package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
  194. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
  195. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
  196. package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
  197. package/payload/server/public/assets/data-Bu-5SUmR.js +0 -1
  198. package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
  199. package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
  200. package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
  201. package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
  202. package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
  203. package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
  204. package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
  205. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
  206. package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
  207. package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
  208. package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
  209. package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
  210. /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
  211. /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
  212. /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
  213. /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
  214. /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
  215. /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
  216. /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
  217. /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
  218. /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
  219. /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
  220. /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
  221. /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
@@ -0,0 +1,1716 @@
1
+ # Two-Way Portal File Exchange Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Turn the client data portal from two disjoint one-way stores into a two-way folder exchange: a client drops a file onto any folder they can see and it lands there on the device within 60 seconds, and a device-side change is visible to them within 60 seconds.
6
+
7
+ **Architecture:** The `manifest` row gains a three-state lifecycle (in transit in R2, moved to a device path, withdrawn) driven by three new columns. The R2 key carries the chosen folder so two folders never collide. The device re-derives the legal folder set itself rather than trusting the portal, and the index push moves from an hourly heartbeat spawn to a 60 second loop with an atomic generation flip.
8
+
9
+ **Tech Stack:** Cloudflare Pages Functions (TypeScript, no bundler), Cloudflare D1 over HTTP with bound parameters, Cloudflare R2 (binding + multipart), Node 22 / Hono on the device, Vitest, dependency-free ES5-style browser JS.
10
+
11
+ ## Global Constraints
12
+
13
+ - Task files nest to `##` only, never `###`.
14
+ - Every log field carrying a remote string goes through `q()` (`_lib/log.ts`, `portal-fetch.ts:196`). Object keys, filenames and passcodes are never logged.
15
+ - Device-side containment never trusts a portal-supplied path. Every path decision re-derives the exposed set locally.
16
+ - Failures fail closed and stay retryable: a refused or failed row keeps `ingested=0` and is reported, never silently rerouted.
17
+ - Verified post-conditions, never "we called it": re-read the state before logging an outcome.
18
+ - Fakes must model the store's constraints (`UNIQUE`, `NOT NULL`), not just its happy path — Task 1704's lesson.
19
+ - `npm run typecheck:templates` in `platform/plugins/cloudflare/mcp` is the only thing that type-checks the template; esbuild strips types without checking them.
20
+ - Portal template source is `skills/data-portal/template/`. The `packages/create-maxy-code/payload/…` copy is generated at publish time and gitignored — never edit it.
21
+ - Paths below are relative to `maxy-code/`.
22
+
23
+ ## File Structure
24
+
25
+ | File | Responsibility | Task |
26
+ |---|---|---|
27
+ | `platform/plugins/cloudflare/skills/data-portal/schema.sql` | manifest columns, directory generation, pointer table | 1 |
28
+ | `…/template/functions/api/_lib/target.ts` (new) | one definition of a legal target path, portal side | 2 |
29
+ | `…/template/functions/api/upload.ts` | target validation, single-shot upload, multipart routes | 2, 3 |
30
+ | `…/template/functions/api/files.ts` | the uploads-panel listing rule | 4 |
31
+ | `…/template/functions/api/delete.ts` | withdraw vs delete by moved state | 5 |
32
+ | `…/template/functions/api/download.ts` | moved rows 302 to an own-upload link | 6 |
33
+ | `…/template/functions/api/_lib/portal-sign.mjs` | own-upload signed message | 6 |
34
+ | `platform/ui/server/routes/portal-fetch.ts` | own-upload admission, device side | 7 |
35
+ | `platform/ui/server/portal-uploads-pull.ts` | routing, collision, move, withdrawal, bound params | 8 |
36
+ | `platform/plugins/cloudflare/bin/portal-index-push.mjs` | atomic generation replace | 9 |
37
+ | `platform/ui/server/portal-index-loop.ts` (new) | 60s push loop | 10 |
38
+ | `platform/plugins/scheduling/mcp/src/lib/check-due-events.ts` | retire hourly push + its audit | 10 |
39
+ | `platform/ui/server/routes/storage-broker.ts` | four audit fields, config-absent union | 11 |
40
+ | `…/template/portal.js`, `index.html`, `portal.css` | drop targets, multi-file, drag-out, progress, poll | 12 |
41
+
42
+ ---
43
+
44
+ ### Task 1: Schema — manifest lifecycle columns and directory generations
45
+
46
+ **Files:**
47
+ - Modify: `platform/plugins/cloudflare/skills/data-portal/schema.sql`
48
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts` (create)
49
+
50
+ **Interfaces:**
51
+ - Produces: `manifest.targetPath TEXT NOT NULL DEFAULT ''`, `manifest.devicePath TEXT NOT NULL DEFAULT ''`, `manifest.deleted INTEGER NOT NULL DEFAULT 0`, `directory.generation INTEGER NOT NULL DEFAULT 0`, table `directory_state(accountId TEXT PRIMARY KEY, currentGeneration INTEGER NOT NULL)`.
52
+
53
+ - [ ] **Step 1: Write the failing test**
54
+
55
+ ```ts
56
+ import { readFileSync } from 'node:fs'
57
+ import { join } from 'node:path'
58
+ import { describe, expect, it } from 'vitest'
59
+
60
+ const SQL = readFileSync(
61
+ join(__dirname, '../../skills/data-portal/schema.sql'),
62
+ 'utf8',
63
+ )
64
+
65
+ describe('portal schema shape', () => {
66
+ it('carries the manifest lifecycle columns', () => {
67
+ expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
68
+ expect(SQL).toMatch(/devicePath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
69
+ expect(SQL).toMatch(/deleted\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
70
+ })
71
+
72
+ it('carries the directory generation and its pointer table', () => {
73
+ expect(SQL).toMatch(/generation\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
74
+ expect(SQL).toMatch(/CREATE TABLE IF NOT EXISTS directory_state/)
75
+ expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
76
+ })
77
+
78
+ it('drops the objectKey UNIQUE that a foldered key makes wrong', () => {
79
+ // objectKey is now ownerId/targetPath/filename. The upsert conflict target
80
+ // is still objectKey, so the UNIQUE stays — this pins that it was not
81
+ // removed by accident while the key shape changed.
82
+ expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
83
+ })
84
+
85
+ it('documents the hand-run ALTERs for a portal deployed before this', () => {
86
+ for (const col of ['targetPath', 'devicePath', 'deleted', 'generation']) {
87
+ expect(SQL).toContain(`ALTER TABLE`)
88
+ expect(SQL).toContain(col)
89
+ }
90
+ })
91
+ })
92
+ ```
93
+
94
+ - [ ] **Step 2: Run test to verify it fails**
95
+
96
+ Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-schema-shape.test.ts`
97
+ Expected: FAIL — `targetPath` not found in schema.sql.
98
+
99
+ - [ ] **Step 3: Write the schema changes**
100
+
101
+ In `schema.sql`, replace the `manifest` block (currently lines 61-77) with:
102
+
103
+ ```sql
104
+ -- One row per stored object, carrying a three-state lifecycle.
105
+ --
106
+ -- in transit ingested=0, devicePath='' bytes in R2
107
+ -- moved ingested=1, devicePath set bytes on the device, R2 object gone
108
+ -- withdrawn deleted=1 device file pending removal
109
+ --
110
+ -- `targetPath` is the exposed folder the client chose at upload, '' when they
111
+ -- chose none. It is part of `objectKey` (ownerId/targetPath/filename), because
112
+ -- a flat key would collide the same filename dropped into two folders: the
113
+ -- upsert would overwrite the first object, reset ingested, and the first file
114
+ -- would never land.
115
+ --
116
+ -- `devicePath` is written only after a verified device write, so its presence
117
+ -- IS the proof the move happened. A boolean would say a move occurred without
118
+ -- saying where, and the audit's unpublishedRouted check needs the path.
119
+ --
120
+ -- `deleted` marks a withdrawal of an already-moved file. The row is removed
121
+ -- outright once the device file is confirmed absent — not kept as a tombstone,
122
+ -- which would grow the manifest without bound and force every listing read to
123
+ -- filter it while describing bytes that no longer exist anywhere.
124
+ --
125
+ -- IF NOT EXISTS adds no column to an existing table. A portal deployed before
126
+ -- this needs the three hand-run once:
127
+ -- ALTER TABLE manifest ADD COLUMN targetPath TEXT NOT NULL DEFAULT '';
128
+ -- ALTER TABLE manifest ADD COLUMN devicePath TEXT NOT NULL DEFAULT '';
129
+ -- ALTER TABLE manifest ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
130
+ -- Their absence is loud: upload names targetPath in its INSERT.
131
+ CREATE TABLE IF NOT EXISTS manifest (
132
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
133
+ fileId TEXT UNIQUE NOT NULL,
134
+ ownerId TEXT NOT NULL,
135
+ filename TEXT NOT NULL,
136
+ objectKey TEXT UNIQUE NOT NULL,
137
+ size INTEGER NOT NULL,
138
+ uploadedAt TEXT NOT NULL,
139
+ ingested INTEGER NOT NULL DEFAULT 0,
140
+ targetPath TEXT NOT NULL DEFAULT '',
141
+ devicePath TEXT NOT NULL DEFAULT '',
142
+ deleted INTEGER NOT NULL DEFAULT 0
143
+ );
144
+
145
+ CREATE INDEX IF NOT EXISTS manifest_uningested ON manifest (ingested);
146
+ CREATE INDEX IF NOT EXISTS manifest_owner ON manifest (ownerId);
147
+ CREATE INDEX IF NOT EXISTS manifest_deleted ON manifest (deleted);
148
+ ```
149
+
150
+ Append after the `directory` block:
151
+
152
+ ```sql
153
+ -- The generation an account's directory rows belong to. The push writes the
154
+ -- next generation, flips this pointer in ONE statement, then deletes rows of
155
+ -- earlier generations. The flip is the atomic point: a listing racing a push
156
+ -- reads the old tree or the new one and never a half-built one. Cleanup after
157
+ -- the flip need not be atomic because nothing reads a superseded generation.
158
+ --
159
+ -- This replaces the delete-then-insert window recorded as backlog Task 1842.
160
+ -- At 60s cadence that window would be hit sixty times more often than hourly.
161
+ --
162
+ -- Hand-run once on a portal deployed before this:
163
+ -- ALTER TABLE directory ADD COLUMN generation INTEGER NOT NULL DEFAULT 0;
164
+ CREATE TABLE IF NOT EXISTS directory_state (
165
+ accountId TEXT PRIMARY KEY,
166
+ currentGeneration INTEGER NOT NULL
167
+ );
168
+ ```
169
+
170
+ And add `generation INTEGER NOT NULL DEFAULT 0,` to the `directory` table body, above the `UNIQUE (accountId, relPath)` line, changing that constraint to `UNIQUE (accountId, relPath, generation)` so two generations can hold the same path at once.
171
+
172
+ - [ ] **Step 4: Run test to verify it passes**
173
+
174
+ Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-schema-shape.test.ts`
175
+ Expected: PASS, 4 tests.
176
+
177
+ - [ ] **Step 5: Commit**
178
+
179
+ ```bash
180
+ git add platform/plugins/cloudflare/skills/data-portal/schema.sql platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts
181
+ git commit -m "feat(1910): manifest lifecycle columns and directory generations"
182
+ ```
183
+
184
+ ---
185
+
186
+ ### Task 2: Portal — one definition of a legal target, and a targeted single-shot upload
187
+
188
+ **Files:**
189
+ - Create: `platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts`
190
+ - Modify: `…/template/functions/api/upload.ts`
191
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts` (create), `__tests__/portal-upload-target.test.ts` (create)
192
+
193
+ **Interfaces:**
194
+ - Consumes: `grantAllows(grant: string[], relPath: string): boolean` from `_lib/session.ts`; `authorizeKey(ownerId, key): boolean` and `ownerPrefix(ownerId): string` from `_lib/authorize.ts`.
195
+ - Produces: `isSafeTargetSegment(s: string): boolean`; `type TargetCheck = { ok: true } | { ok: false; reason: 'segment' | 'grant' | 'index' }`; `checkTarget(db, accountId, grant, targetPath): Promise<TargetCheck>`; `uploadKey(ownerId: string, targetPath: string, filename: string): string`. `processUpload` gains a `targetPath: string` parameter after `filename`.
196
+
197
+ - [ ] **Step 1: Write the failing test for the target module**
198
+
199
+ Create `__tests__/portal-target.test.ts`:
200
+
201
+ ```ts
202
+ import { describe, expect, it } from 'vitest'
203
+ import {
204
+ checkTarget,
205
+ isSafeTargetSegment,
206
+ uploadKey,
207
+ } from '../../skills/data-portal/template/functions/api/_lib/target'
208
+
209
+ /** A D1 fake whose `first()` answers from a set of published folder rows.
210
+ * It models the real query's shape: only isDir=1 rows are folders. */
211
+ function fakeDb(folders: string[]) {
212
+ return {
213
+ prepare(sql: string) {
214
+ return {
215
+ bind(accountId: string, relPath: string) {
216
+ return {
217
+ async first() {
218
+ if (!sql.includes('isDir = 1')) throw new Error('query must filter isDir')
219
+ return folders.includes(relPath) ? { relPath } : null
220
+ },
221
+ }
222
+ },
223
+ }
224
+ },
225
+ } as never
226
+ }
227
+
228
+ describe('isSafeTargetSegment', () => {
229
+ it('rejects traversal, separators, empties and NUL', () => {
230
+ for (const bad of ['', '.', '..', 'a/b', 'a\\b', 'a\0b']) {
231
+ expect(isSafeTargetSegment(bad)).toBe(false)
232
+ }
233
+ })
234
+ it('accepts an ordinary folder name', () => {
235
+ expect(isSafeTargetSegment('jobs')).toBe(true)
236
+ })
237
+ })
238
+
239
+ describe('uploadKey', () => {
240
+ it('collapses to owner/filename when untargeted', () => {
241
+ expect(uploadKey('alice', '', 'a.pdf')).toBe('alice/a.pdf')
242
+ })
243
+ it('carries the target so two folders never collide', () => {
244
+ expect(uploadKey('alice', 'jobs', 'a.pdf')).toBe('alice/jobs/a.pdf')
245
+ expect(uploadKey('alice', 'jobs/2026', 'a.pdf')).toBe('alice/jobs/2026/a.pdf')
246
+ })
247
+ })
248
+
249
+ describe('checkTarget', () => {
250
+ it('admits the empty target without touching the index', async () => {
251
+ const db = { prepare() { throw new Error('must not query for the empty target') } } as never
252
+ expect(await checkTarget(db, 'acct', [], '')).toEqual({ ok: true })
253
+ })
254
+
255
+ it('admits a granted folder that the index publishes', async () => {
256
+ expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'jobs')).toEqual({ ok: true })
257
+ })
258
+
259
+ it('refuses a folder outside the grant before reading the index', async () => {
260
+ const db = { prepare() { throw new Error('must not query outside the grant') } } as never
261
+ expect(await checkTarget(db, 'acct', ['output'], 'jobs')).toEqual({ ok: false, reason: 'grant' })
262
+ })
263
+
264
+ it('refuses a folder the index does not publish', async () => {
265
+ expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'output')).toEqual({
266
+ ok: false,
267
+ reason: 'index',
268
+ })
269
+ })
270
+
271
+ it('refuses a traversal segment before any lookup', async () => {
272
+ const db = { prepare() { throw new Error('must not query a bad segment') } } as never
273
+ expect(await checkTarget(db, 'acct', [], 'jobs/../secrets')).toEqual({
274
+ ok: false,
275
+ reason: 'segment',
276
+ })
277
+ })
278
+ })
279
+ ```
280
+
281
+ - [ ] **Step 2: Run test to verify it fails**
282
+
283
+ Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-target.test.ts`
284
+ Expected: FAIL — cannot resolve `_lib/target`.
285
+
286
+ - [ ] **Step 3: Write `_lib/target.ts`**
287
+
288
+ ```ts
289
+ import type { D1Database } from './types'
290
+ import { grantAllows } from './session'
291
+ import { ownerPrefix } from './authorize'
292
+
293
+ /**
294
+ * One path segment of a legal target: not empty, not a traversal token, and
295
+ * carrying no separator of either flavour. Rejected outright rather than
296
+ * normalised, the same rule and the same reason as authorizeKey — a normaliser
297
+ * is one more thing that can be wrong.
298
+ *
299
+ * Deliberately identical to the device's own safeSegment
300
+ * (`portal-uploads-pull.ts`), which is what the parity test pins.
301
+ */
302
+ export function isSafeTargetSegment(s: string): boolean {
303
+ if (!s || s === '.' || s === '..') return false
304
+ return !s.includes('/') && !s.includes('\\') && !s.includes('\0')
305
+ }
306
+
307
+ export type TargetCheck = { ok: true } | { ok: false; reason: 'segment' | 'grant' | 'index' }
308
+
309
+ /**
310
+ * Whether this person may upload into `targetPath`.
311
+ *
312
+ * The rule is: every segment is safe, the person's grant admits the top-level
313
+ * name, AND the account's published index carries a folder row for the exact
314
+ * path. That last clause means you can only drop where you can see, which
315
+ * introduces no new permission concept — the legal target set is exactly the
316
+ * set of folders already rendered to this person.
317
+ *
318
+ * The empty target is always legal and costs no query; it is the default
319
+ * uploads area, which is not in the index by construction.
320
+ *
321
+ * This is a convenience check. The security boundary is the device, which
322
+ * re-derives the exposed set itself and refuses anything outside it. Both exist
323
+ * for the same reason the download path has two checks: if this end is ever
324
+ * wrong — a stale index, a bug, a compromised Pages project — the device
325
+ * contains it.
326
+ */
327
+ export async function checkTarget(
328
+ db: D1Database,
329
+ accountId: string,
330
+ grant: string[],
331
+ targetPath: string,
332
+ ): Promise<TargetCheck> {
333
+ if (targetPath === '') return { ok: true }
334
+ const segments = targetPath.split('/')
335
+ if (!segments.every(isSafeTargetSegment)) return { ok: false, reason: 'segment' }
336
+ if (!grantAllows(grant, targetPath)) return { ok: false, reason: 'grant' }
337
+ const row = await db
338
+ .prepare('SELECT relPath FROM directory WHERE accountId = ? AND relPath = ? AND isDir = 1')
339
+ .bind(accountId, targetPath)
340
+ .first<{ relPath: string }>()
341
+ return row ? { ok: true } : { ok: false, reason: 'index' }
342
+ }
343
+
344
+ /** `ownerId/targetPath/filename`, collapsing to `ownerId/filename` when
345
+ * untargeted. The target is IN the key: a flat key collides the same filename
346
+ * dropped into two folders, and the upsert would destroy the first object. */
347
+ export function uploadKey(ownerId: string, targetPath: string, filename: string): string {
348
+ return targetPath
349
+ ? `${ownerPrefix(ownerId)}${targetPath}/${filename}`
350
+ : `${ownerPrefix(ownerId)}${filename}`
351
+ }
352
+ ```
353
+
354
+ - [ ] **Step 4: Run test to verify it passes**
355
+
356
+ Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-target.test.ts`
357
+ Expected: PASS, 8 tests.
358
+
359
+ - [ ] **Step 5: Write the failing test for the targeted upload**
360
+
361
+ Create `__tests__/portal-upload-target.test.ts`. Model the manifest with a `Map` keyed on `objectKey` so the `UNIQUE` is real (the Task 1704 lesson), and assert the emitted log lines:
362
+
363
+ ```ts
364
+ import { describe, expect, it } from 'vitest'
365
+ import { processUpload } from '../../skills/data-portal/template/functions/api/upload'
366
+
367
+ function harness(opts: { folders?: string[]; grant?: string[] } = {}) {
368
+ const folders = opts.folders ?? ['jobs']
369
+ const objects = new Map<string, Uint8Array>()
370
+ const rows = new Map<string, Record<string, unknown>>()
371
+ const lines: string[] = []
372
+ const env = {
373
+ BUCKET: {
374
+ async put(key: string, body: Uint8Array) { objects.set(key, body) },
375
+ async head(key: string) { return objects.has(key) ? { size: objects.get(key)!.length } : null },
376
+ },
377
+ DB: {
378
+ prepare(sql: string) {
379
+ return {
380
+ bind(...args: unknown[]) {
381
+ return {
382
+ async first() {
383
+ if (sql.includes('FROM sessions')) {
384
+ return { ownerId: 'alice', accountId: 'acct', folders: (opts.grant ?? []).join(',') }
385
+ }
386
+ if (sql.includes('isDir = 1')) {
387
+ return folders.includes(String(args[1])) ? { relPath: args[1] } : null
388
+ }
389
+ if (sql.includes('SELECT fileId FROM manifest')) {
390
+ return rows.get(String(args[0])) ?? null
391
+ }
392
+ return null
393
+ },
394
+ async run() {
395
+ const key = String(args[3])
396
+ // Upsert on objectKey, exactly as the real UNIQUE forces.
397
+ rows.set(key, { fileId: args[0], ownerId: args[1], filename: args[2], key,
398
+ size: args[4], targetPath: args[6], ingested: 0 })
399
+ },
400
+ }
401
+ },
402
+ }
403
+ },
404
+ },
405
+ } as never
406
+ let n = 0
407
+ return { env, objects, rows, lines, newId: () => `id-${++n}`, log: (l: string) => lines.push(l) }
408
+ }
409
+
410
+ describe('targeted upload', () => {
411
+ it('stores under the target and records it on the row', async () => {
412
+ const h = harness()
413
+ const res = await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1, 2]), h.env, h.log, h.newId, () => 1000)
414
+ expect(res.status).toBe(200)
415
+ expect([...h.objects.keys()]).toEqual(['alice/jobs/a.pdf'])
416
+ expect(h.rows.get('alice/jobs/a.pdf')!.targetPath).toBe('jobs')
417
+ })
418
+
419
+ it('keeps the same filename in two folders as two distinct objects', async () => {
420
+ const h = harness({ folders: ['jobs', 'output'] })
421
+ await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
422
+ await processUpload('s', 'a.pdf', 'output', new Uint8Array([2]), h.env, h.log, h.newId, () => 1000)
423
+ expect([...h.objects.keys()].sort()).toEqual(['alice/jobs/a.pdf', 'alice/output/a.pdf'])
424
+ })
425
+
426
+ it('refuses an out-of-grant target and stores nothing', async () => {
427
+ const h = harness({ grant: ['output'] })
428
+ const res = await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
429
+ expect(res.status).toBe(403)
430
+ expect(h.objects.size).toBe(0)
431
+ expect(h.lines.some((l) => l.includes('op=target-check') && l.includes('result=denied-grant'))).toBe(true)
432
+ })
433
+
434
+ it('refuses a target the index does not publish', async () => {
435
+ const h = harness({ folders: [] })
436
+ const res = await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
437
+ expect(res.status).toBe(403)
438
+ expect(h.lines.some((l) => l.includes('result=denied-index'))).toBe(true)
439
+ })
440
+
441
+ it('logs op=target-check result=allowed with the target on the happy path', async () => {
442
+ const h = harness()
443
+ await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
444
+ expect(h.lines.some((l) => l.includes('op=target-check') && l.includes('result=allowed'))).toBe(true)
445
+ })
446
+ })
447
+ ```
448
+
449
+ - [ ] **Step 6: Run test to verify it fails**
450
+
451
+ Run: `npx vitest run __tests__/portal-upload-target.test.ts`
452
+ Expected: FAIL — `processUpload` takes 7 arguments, target ignored.
453
+
454
+ - [ ] **Step 7: Thread the target through `upload.ts`**
455
+
456
+ Add the import and the parameter, and replace the key derivation (currently `upload.ts:44-50`):
457
+
458
+ ```ts
459
+ import { checkTarget, uploadKey } from './_lib/target'
460
+ ```
461
+
462
+ `processUpload` signature becomes `(sessionId, filename, targetPath, bytes, env, log, newId, now)`. After the filename check and before the key:
463
+
464
+ ```ts
465
+ // The target is checked BEFORE any R2 call, the same ordering rule the owner
466
+ // gate follows: a denied caller must not be able to cause a store write.
467
+ const target = await checkTarget(env.DB, session.accountId, session.folders, targetPath)
468
+ if (!target.ok) {
469
+ log(
470
+ `[data-portal] op=target-check uploadId=${uploadId} owner=${q(owner)} ` +
471
+ `targetPath=${q(targetPath)} result=denied-${target.reason === 'segment' ? 'segment' : target.reason}`,
472
+ )
473
+ return { status: 403, payload: { ok: false, error: 'target not allowed' } }
474
+ }
475
+ log(
476
+ `[data-portal] op=target-check uploadId=${uploadId} owner=${q(owner)} ` +
477
+ `targetPath=${q(targetPath)} result=allowed`,
478
+ )
479
+
480
+ const key = uploadKey(owner, targetPath, filename)
481
+ ```
482
+
483
+ Keep the existing `authorizeKey(owner, key)` guard immediately after — it still holds, because `authorizeKey` admits deeper keys and rejects `..` and a leading separator.
484
+
485
+ Extend the manifest INSERT to name `targetPath`:
486
+
487
+ ```ts
488
+ await env.DB.prepare(
489
+ `INSERT INTO manifest (fileId, ownerId, filename, objectKey, size, uploadedAt, ingested, targetPath)
490
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
491
+ ON CONFLICT (objectKey) DO UPDATE SET
492
+ fileId = excluded.fileId,
493
+ size = excluded.size,
494
+ uploadedAt = excluded.uploadedAt,
495
+ targetPath = excluded.targetPath,
496
+ ingested = 0,
497
+ devicePath = '',
498
+ deleted = 0`,
499
+ )
500
+ .bind(fileId, owner, filename, key, bytes.byteLength, new Date(startedMs).toISOString(), 0, targetPath)
501
+ .run()
502
+ ```
503
+
504
+ `devicePath` and `deleted` reset on conflict for the same reason `ingested` does: a replacement of an already-moved file is a new file at that path, and leaving either set would describe the previous one.
505
+
506
+ In `onRequestPost`, read the target from the form:
507
+
508
+ ```ts
509
+ filename = file.name
510
+ targetPath = String(form.get('targetPath') ?? '')
511
+ bytes = new Uint8Array(await file.arrayBuffer())
512
+ ```
513
+
514
+ - [ ] **Step 8: Run both test files**
515
+
516
+ Run: `npx vitest run __tests__/portal-target.test.ts __tests__/portal-upload-target.test.ts`
517
+ Expected: PASS, 13 tests.
518
+
519
+ - [ ] **Step 9: Commit**
520
+
521
+ ```bash
522
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts \
523
+ platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts \
524
+ platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts \
525
+ platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts
526
+ git commit -m "feat(1910): folder-targeted uploads, validated against grant and index"
527
+ ```
528
+
529
+ ---
530
+
531
+ ### Task 3: Portal — multipart upload to 1 GiB
532
+
533
+ **Files:**
534
+ - Modify: `…/template/functions/api/upload.ts`
535
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts` (create)
536
+
537
+ **Interfaces:**
538
+ - Consumes: `checkTarget`, `uploadKey` from `_lib/target.ts`.
539
+ - Produces: `MAX_PART_BYTES = 25 * 1024 * 1024`, `MAX_TOTAL_BYTES = 1024 * 1024 * 1024`; `processMultipartCreate(sessionId, filename, targetPath, declaredSize, env, log, newId, now)`, `processMultipartPart(sessionId, uploadKeyStr, r2UploadId, partNumber, bytes, env, log)`, `processMultipartComplete(sessionId, uploadKeyStr, r2UploadId, parts, env, log, newId, now)`.
540
+
541
+ - [ ] **Step 1: Write the failing test**
542
+
543
+ ```ts
544
+ import { describe, expect, it } from 'vitest'
545
+ import {
546
+ MAX_PART_BYTES,
547
+ MAX_TOTAL_BYTES,
548
+ processMultipartCreate,
549
+ processMultipartPart,
550
+ } from '../../skills/data-portal/template/functions/api/upload'
551
+
552
+ function harness() {
553
+ const sessions = { ownerId: 'alice', accountId: 'acct', folders: '' }
554
+ const uploads = new Map<string, { key: string; parts: Map<number, number> }>()
555
+ const lines: string[] = []
556
+ const env = {
557
+ BUCKET: {
558
+ async createMultipartUpload(key: string) {
559
+ const id = `r2-${uploads.size + 1}`
560
+ uploads.set(id, { key, parts: new Map() })
561
+ return {
562
+ uploadId: id,
563
+ async uploadPart(n: number, body: Uint8Array) {
564
+ uploads.get(id)!.parts.set(n, body.length)
565
+ return { partNumber: n, etag: `etag-${n}` }
566
+ },
567
+ }
568
+ },
569
+ resumeMultipartUpload(key: string, id: string) {
570
+ return {
571
+ uploadId: id,
572
+ async uploadPart(n: number, body: Uint8Array) {
573
+ uploads.get(id)!.parts.set(n, body.length)
574
+ return { partNumber: n, etag: `etag-${n}` }
575
+ },
576
+ }
577
+ },
578
+ },
579
+ DB: {
580
+ prepare(sql: string) {
581
+ return {
582
+ bind(...a: unknown[]) {
583
+ return {
584
+ async first() { return sql.includes('FROM sessions') ? sessions : null },
585
+ async run() {},
586
+ }
587
+ },
588
+ }
589
+ },
590
+ },
591
+ } as never
592
+ return { env, uploads, lines, log: (l: string) => lines.push(l), newId: () => 'uid-1' }
593
+ }
594
+
595
+ describe('multipart upload', () => {
596
+ it('refuses a declared size over the total cap without opening a session', async () => {
597
+ const h = harness()
598
+ const res = await processMultipartCreate('s', 'big.zip', '', MAX_TOTAL_BYTES + 1, h.env, h.log, h.newId, () => 1)
599
+ expect(res.status).toBe(413)
600
+ expect(h.uploads.size).toBe(0)
601
+ })
602
+
603
+ it('opens a session under the cap and returns its ids', async () => {
604
+ const h = harness()
605
+ const res = await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
606
+ expect(res.status).toBe(200)
607
+ expect((res.payload as { r2UploadId: string }).r2UploadId).toBe('r2-1')
608
+ expect((res.payload as { key: string }).key).toBe('alice/big.zip')
609
+ })
610
+
611
+ it('refuses a part over the per-part gate', async () => {
612
+ const h = harness()
613
+ await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
614
+ const res = await processMultipartPart('s', 'alice/big.zip', 'r2-1', 1,
615
+ new Uint8Array(MAX_PART_BYTES + 1), h.env, h.log)
616
+ expect(res.status).toBe(413)
617
+ })
618
+
619
+ it('refuses a part whose key is not the caller own', async () => {
620
+ const h = harness()
621
+ const res = await processMultipartPart('s', 'bob/big.zip', 'r2-1', 1, new Uint8Array(4), h.env, h.log)
622
+ expect(res.status).toBe(403)
623
+ })
624
+
625
+ it('accepts a part within the gate and returns its etag', async () => {
626
+ const h = harness()
627
+ await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
628
+ const res = await processMultipartPart('s', 'alice/big.zip', 'r2-1', 1, new Uint8Array(8), h.env, h.log)
629
+ expect(res.status).toBe(200)
630
+ expect((res.payload as { etag: string }).etag).toBe('etag-1')
631
+ })
632
+ })
633
+ ```
634
+
635
+ - [ ] **Step 2: Run test to verify it fails**
636
+
637
+ Run: `npx vitest run __tests__/portal-upload-multipart.test.ts`
638
+ Expected: FAIL — `processMultipartCreate` is not exported.
639
+
640
+ - [ ] **Step 3: Implement the three multipart stages in `upload.ts`**
641
+
642
+ ```ts
643
+ /** The per-part ceiling. An upload part is read wholly into the Function's
644
+ * memory exactly as a single-shot body is, so this is the SAME isolate bound
645
+ * the single-shot path has always had (see MAX_UPLOAD_BYTES above) — the
646
+ * parts do not relax it, they let a large file respect it repeatedly. */
647
+ export const MAX_PART_BYTES = 25 * 1024 * 1024
648
+
649
+ /** The whole-file ceiling. Not an isolate limit: it is a storage-and-patience
650
+ * bound on what one client may push through one portal in one go. */
651
+ export const MAX_TOTAL_BYTES = 1024 * 1024 * 1024
652
+
653
+ export async function processMultipartCreate(
654
+ sessionId: string, filename: string, targetPath: string, declaredSize: number,
655
+ env: PortalEnv, log: Logger, newId: () => string, now: () => number,
656
+ ): Promise<Handler> {
657
+ const startedMs = now()
658
+ const session = await resolveSession(env.DB, sessionId, startedMs)
659
+ if (!session) {
660
+ log('[data-portal] op=mp-create owner=none result=denied')
661
+ return { status: 401, payload: { ok: false, error: 'denied' } }
662
+ }
663
+ const owner = session.ownerId
664
+ const uploadId = newId()
665
+ if (!filename || filename.length > MAX_FILENAME) {
666
+ log(`[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} result=bad-filename`)
667
+ return { status: 400, payload: { ok: false, error: 'filename required' } }
668
+ }
669
+ // Checked BEFORE the session is opened: an over-cap declaration must not
670
+ // leave an orphan multipart upload accruing storage in R2.
671
+ if (!Number.isFinite(declaredSize) || declaredSize < 0 || declaredSize > MAX_TOTAL_BYTES) {
672
+ log(
673
+ `[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} ` +
674
+ `declared=${declaredSize} result=too-large limit=${MAX_TOTAL_BYTES}`,
675
+ )
676
+ return { status: 413, payload: { ok: false, error: `over the ${MAX_TOTAL_BYTES}-byte limit` } }
677
+ }
678
+ const target = await checkTarget(env.DB, session.accountId, session.folders, targetPath)
679
+ if (!target.ok) {
680
+ log(
681
+ `[data-portal] op=target-check uploadId=${uploadId} owner=${q(owner)} ` +
682
+ `targetPath=${q(targetPath)} result=denied-${target.reason}`,
683
+ )
684
+ return { status: 403, payload: { ok: false, error: 'target not allowed' } }
685
+ }
686
+ const key = uploadKey(owner, targetPath, filename)
687
+ if (!authorizeKey(owner, key)) {
688
+ log(`[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} result=bad-filename`)
689
+ return { status: 400, payload: { ok: false, error: 'invalid filename' } }
690
+ }
691
+ const mp = await env.BUCKET.createMultipartUpload(key)
692
+ log(
693
+ `[data-portal] op=mp-create uploadId=${uploadId} owner=${q(owner)} key=${q(key)} ` +
694
+ `declared=${declaredSize} parts=${Math.ceil(declaredSize / MAX_PART_BYTES)} result=ok`,
695
+ )
696
+ return { status: 200, payload: { ok: true, uploadId, key, r2UploadId: mp.uploadId } }
697
+ }
698
+
699
+ export async function processMultipartPart(
700
+ sessionId: string, key: string, r2UploadId: string, partNumber: number,
701
+ bytes: Uint8Array, env: PortalEnv, log: Logger,
702
+ ): Promise<Handler> {
703
+ const session = await resolveSession(env.DB, sessionId, Date.now())
704
+ if (!session) {
705
+ log('[data-portal] op=r2-put owner=none result=denied')
706
+ return { status: 401, payload: { ok: false, error: 'denied' } }
707
+ }
708
+ // The key is re-authorized on EVERY part, not only at create: the client
709
+ // supplies it on each request, and a session that may write alice/ must not
710
+ // be able to append a part to bob/.
711
+ if (!authorizeKey(session.ownerId, key)) {
712
+ log(`[data-portal] op=r2-put owner=${q(session.ownerId)} result=denied reason=not-owner`)
713
+ return { status: 403, payload: { ok: false, error: 'denied' } }
714
+ }
715
+ if (bytes.byteLength > MAX_PART_BYTES) {
716
+ log(
717
+ `[data-portal] op=r2-put owner=${q(session.ownerId)} part=${partNumber} ` +
718
+ `bytes=${bytes.byteLength} result=too-large limit=${MAX_PART_BYTES}`,
719
+ )
720
+ return { status: 413, payload: { ok: false, error: 'part too large' } }
721
+ }
722
+ const mp = env.BUCKET.resumeMultipartUpload(key, r2UploadId)
723
+ const part = await mp.uploadPart(partNumber, bytes)
724
+ log(
725
+ `[data-portal] op=r2-put owner=${q(session.ownerId)} key=${q(key)} ` +
726
+ `part=${partNumber} bytes=${bytes.byteLength} result=ok`,
727
+ )
728
+ return { status: 200, payload: { ok: true, partNumber, etag: part.etag } }
729
+ }
730
+ ```
731
+
732
+ `processMultipartComplete` mirrors the single-shot path from `env.BUCKET.resumeMultipartUpload(key, r2UploadId).complete(parts)` onward: it sums the part sizes, refuses over `MAX_TOTAL_BYTES` (aborting the upload so no orphan remains), writes the same upsert as Task 2, then re-reads `head(key)` and the row and logs the same `op=complete objectPresent= rowPresent=` line.
733
+
734
+ Route all three from `onRequestPost` on an `action` form field (`create`, `part`, `complete`), with no action meaning today's single-shot path so the common case stays one request.
735
+
736
+ - [ ] **Step 4: Run test to verify it passes**
737
+
738
+ Run: `npx vitest run __tests__/portal-upload-multipart.test.ts`
739
+ Expected: PASS, 5 tests.
740
+
741
+ - [ ] **Step 5: Add the R2 multipart types to `_lib/types.ts`**
742
+
743
+ Add `createMultipartUpload(key: string): Promise<R2MultipartUpload>` and `resumeMultipartUpload(key: string, uploadId: string): R2MultipartUpload` to the bucket interface, with `R2MultipartUpload` carrying `uploadId`, `uploadPart`, `complete` and `abort`. Run `npm run typecheck:templates` — esbuild strips types without checking them, so this is the only thing that would catch a wrong shape.
744
+
745
+ - [ ] **Step 6: Commit**
746
+
747
+ ```bash
748
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts \
749
+ platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts \
750
+ platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts
751
+ git commit -m "feat(1910): multipart upload to 1 GiB at a 25 MiB part bound"
752
+ ```
753
+
754
+ ---
755
+
756
+ ### Task 4: Portal — the uploads-panel listing rule
757
+
758
+ **Files:**
759
+ - Modify: `…/template/functions/api/files.ts`
760
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts` (create)
761
+
762
+ **Interfaces:**
763
+ - Produces: `shouldListUpload(row: { devicePath: string; deleted: number }, visibleTops: Set<string>): boolean`.
764
+
765
+ - [ ] **Step 1: Write the failing test**
766
+
767
+ ```ts
768
+ import { describe, expect, it } from 'vitest'
769
+ import { shouldListUpload } from '../../skills/data-portal/template/functions/api/files'
770
+
771
+ const visible = new Set(['jobs', 'output'])
772
+
773
+ describe('shouldListUpload', () => {
774
+ it('lists a row still in transit', () => {
775
+ expect(shouldListUpload({ devicePath: '', deleted: 0 }, visible)).toBe(true)
776
+ })
777
+ it('hides a moved row whose folder the client can browse', () => {
778
+ expect(shouldListUpload({ devicePath: 'jobs/a.pdf', deleted: 0 }, visible)).toBe(false)
779
+ })
780
+ it('lists a moved row the client cannot reach in the tree', () => {
781
+ expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 0 }, visible)).toBe(true)
782
+ })
783
+ it('hides a withdrawn row', () => {
784
+ expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 1 }, visible)).toBe(false)
785
+ })
786
+ })
787
+ ```
788
+
789
+ - [ ] **Step 2: Run test to verify it fails**
790
+
791
+ Run: `npx vitest run __tests__/portal-uploads-listing.test.ts`
792
+ Expected: FAIL — `shouldListUpload` is not exported.
793
+
794
+ - [ ] **Step 3: Implement the rule in `files.ts`**
795
+
796
+ ```ts
797
+ /**
798
+ * Whether an upload row belongs in the client's own uploads panel.
799
+ *
800
+ * One rule covering both halves of the move: a row is listed while its bytes
801
+ * are still in R2, and after the move ONLY when the file is not reachable in
802
+ * the folder tree. So a targeted file leaves the panel and appears in its
803
+ * folder, and an untargeted file — which lands in `uploads/`, never exposed —
804
+ * stays in the panel and downloads through the device.
805
+ *
806
+ * Nothing is listed twice, and nothing becomes unreachable. A withdrawn row is
807
+ * hidden while the device removal is pending; it is deleted outright once the
808
+ * device file is confirmed absent, so this branch is a brief state, not a
809
+ * tombstone filter.
810
+ */
811
+ export function shouldListUpload(
812
+ row: { devicePath: string; deleted: number },
813
+ visibleTops: Set<string>,
814
+ ): boolean {
815
+ if (row.deleted) return false
816
+ if (!row.devicePath) return true
817
+ return !visibleTops.has(row.devicePath.split('/')[0])
818
+ }
819
+ ```
820
+
821
+ In `processFiles`, select the new columns, build `visibleTops` from the grant-filtered directory rows' top segments, and filter `files` through `shouldListUpload`. The manifest query becomes:
822
+
823
+ ```ts
824
+ const rows = await env.DB.prepare(
825
+ 'SELECT fileId, filename, objectKey, size, uploadedAt, ingested, targetPath, devicePath, deleted ' +
826
+ 'FROM manifest WHERE ownerId = ? ORDER BY uploadedAt DESC',
827
+ )
828
+ ```
829
+
830
+ Order matters: the directory read must happen before the filter, because `visibleTops` comes from it. Add `listed=` and `moved=` counts to the `op=list` line so an operator can see the split without reading a row.
831
+
832
+ - [ ] **Step 4: Run test to verify it passes**
833
+
834
+ Run: `npx vitest run __tests__/portal-uploads-listing.test.ts`
835
+ Expected: PASS, 4 tests.
836
+
837
+ - [ ] **Step 5: Commit**
838
+
839
+ ```bash
840
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts \
841
+ platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts
842
+ git commit -m "feat(1910): one listing rule for in-transit and moved uploads"
843
+ ```
844
+
845
+ ---
846
+
847
+ ### Task 5: Portal — withdraw a moved file, delete an in-transit one
848
+
849
+ **Files:**
850
+ - Modify: `…/template/functions/api/delete.ts`
851
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts` (create)
852
+
853
+ - [ ] **Step 1: Write the failing test**
854
+
855
+ ```ts
856
+ import { describe, expect, it } from 'vitest'
857
+ import { processDelete } from '../../skills/data-portal/template/functions/api/delete'
858
+
859
+ function harness(row: { devicePath: string } | null) {
860
+ const state = { objectDeleted: false, rowDeleted: false, marked: false }
861
+ const lines: string[] = []
862
+ const env = {
863
+ BUCKET: {
864
+ async delete() { state.objectDeleted = true },
865
+ async head() { return state.objectDeleted ? null : {} },
866
+ },
867
+ DB: {
868
+ prepare(sql: string) {
869
+ return {
870
+ bind() {
871
+ return {
872
+ async first() {
873
+ if (sql.includes('FROM sessions')) return { ownerId: 'alice', accountId: 'a', folders: '' }
874
+ if (sql.includes('devicePath')) return row
875
+ if (sql.includes('SELECT fileId FROM manifest')) return state.rowDeleted ? null : row
876
+ return null
877
+ },
878
+ async run() {
879
+ if (sql.startsWith('UPDATE manifest')) state.marked = true
880
+ if (sql.startsWith('DELETE FROM manifest')) state.rowDeleted = true
881
+ },
882
+ }
883
+ },
884
+ }
885
+ },
886
+ },
887
+ } as never
888
+ return { env, state, lines, log: (l: string) => lines.push(l) }
889
+ }
890
+
891
+ describe('delete by moved state', () => {
892
+ it('deletes object and row outright when the file is still in transit', async () => {
893
+ const h = harness({ devicePath: '' })
894
+ const res = await processDelete('s', 'alice/a.pdf', h.env, h.log, () => 1)
895
+ expect(res.status).toBe(200)
896
+ expect(h.state.objectDeleted).toBe(true)
897
+ expect(h.state.rowDeleted).toBe(true)
898
+ expect(h.state.marked).toBe(false)
899
+ })
900
+
901
+ it('marks a moved file for withdrawal instead of touching R2', async () => {
902
+ const h = harness({ devicePath: 'jobs/a.pdf' })
903
+ const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
904
+ expect(res.status).toBe(200)
905
+ expect(h.state.marked).toBe(true)
906
+ expect(h.state.rowDeleted).toBe(false)
907
+ expect(h.lines.some((l) => l.includes('op=withdraw') && l.includes('result=marked'))).toBe(true)
908
+ })
909
+
910
+ it('reports an unknown key as not found without writing anything', async () => {
911
+ const h = harness(null)
912
+ const res = await processDelete('s', 'alice/nope.pdf', h.env, h.log, () => 1)
913
+ expect(res.status).toBe(404)
914
+ expect(h.state.objectDeleted).toBe(false)
915
+ expect(h.state.marked).toBe(false)
916
+ })
917
+ })
918
+ ```
919
+
920
+ - [ ] **Step 2: Run test to verify it fails**
921
+
922
+ Run: `npx vitest run __tests__/portal-delete-withdraw.test.ts`
923
+ Expected: FAIL — the moved case still deletes from R2.
924
+
925
+ - [ ] **Step 3: Branch `processDelete` on `devicePath`**
926
+
927
+ After the `authorizeKey` guard, read the row first:
928
+
929
+ ```ts
930
+ const row = await env.DB.prepare('SELECT devicePath FROM manifest WHERE objectKey = ?')
931
+ .bind(key)
932
+ .first<{ devicePath: string }>()
933
+ if (!row) {
934
+ log(`${'[data-portal]'} op=delete owner=${owner} key=${q(key)} result=absent`)
935
+ return { status: 404, payload: { ok: false, error: 'not found' } }
936
+ }
937
+
938
+ // A moved file's bytes are on the device and its R2 object is already gone.
939
+ // Deleting here would report success while the client's file stayed on the
940
+ // device forever, which is the failure this branch exists to prevent. The
941
+ // pull removes the device file, confirms it is absent, and only then removes
942
+ // the row — so the withdrawal is complete on BOTH sides or on neither.
943
+ if (row.devicePath) {
944
+ await env.DB.prepare('UPDATE manifest SET deleted = 1 WHERE objectKey = ?').bind(key).run()
945
+ const marked = await env.DB.prepare('SELECT deleted FROM manifest WHERE objectKey = ?')
946
+ .bind(key)
947
+ .first<{ deleted: number }>()
948
+ const ok = marked?.deleted === 1
949
+ log(
950
+ `[data-portal] op=withdraw owner=${owner} key=${q(key)} ` +
951
+ `result=${ok ? 'marked' : 'not-marked'}`,
952
+ )
953
+ return { status: ok ? 200 : 500, payload: { ok, pending: true } }
954
+ }
955
+ ```
956
+
957
+ The existing R2-delete-then-row-delete path follows unchanged for the in-transit case.
958
+
959
+ - [ ] **Step 4: Run test to verify it passes**
960
+
961
+ Run: `npx vitest run __tests__/portal-delete-withdraw.test.ts`
962
+ Expected: PASS, 3 tests.
963
+
964
+ - [ ] **Step 5: Commit**
965
+
966
+ ```bash
967
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts \
968
+ platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts
969
+ git commit -m "feat(1910): withdraw a moved upload, delete an in-transit one"
970
+ ```
971
+
972
+ ---
973
+
974
+ ### Task 6: Portal — own-upload signed links, and moved rows that still download
975
+
976
+ **Files:**
977
+ - Modify: `…/template/functions/api/_lib/portal-sign.mjs`, `…/template/functions/api/download.ts`
978
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts` (extend), `__tests__/portal-moved-download.test.ts` (create)
979
+
980
+ **Interfaces:**
981
+ - Produces: `signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs): Promise<string>`, `verifyOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs, signature, nowMs): Promise<boolean>`, `buildOwnUploadUrl(origin, secret, accountId, ownerId, relPath, expiresAtMs): Promise<string>`.
982
+
983
+ - [ ] **Step 1: Write the failing test**
984
+
985
+ Add to `__tests__/portal-sign.test.ts`:
986
+
987
+ ```ts
988
+ import { signOwnUpload, signPath, verifyOwnUpload } from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
989
+
990
+ describe('own-upload signatures', () => {
991
+ it('verifies a link minted for the same owner', async () => {
992
+ const exp = Date.now() + 60_000
993
+ const sig = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
994
+ expect(await verifyOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp, sig, Date.now())).toBe(true)
995
+ })
996
+
997
+ it('refuses the same path under a different owner', async () => {
998
+ const exp = Date.now() + 60_000
999
+ const sig = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
1000
+ expect(await verifyOwnUpload('s3cret', 'acct', 'bob', 'uploads/alice/a.pdf', exp, sig, Date.now())).toBe(false)
1001
+ })
1002
+
1003
+ it('is a DIFFERENT message from the exposed-folder signature', async () => {
1004
+ // The two link types must not be interchangeable: an exposed-folder
1005
+ // signature must never admit an uploads path, or the owner binding is
1006
+ // decorative.
1007
+ const exp = Date.now() + 60_000
1008
+ const own = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
1009
+ const plain = await signPath('s3cret', 'acct', 'uploads/alice/a.pdf', exp)
1010
+ expect(own).not.toBe(plain)
1011
+ })
1012
+
1013
+ it('refuses an expired link', async () => {
1014
+ const exp = Date.now() - 1
1015
+ const sig = await signOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp)
1016
+ expect(await verifyOwnUpload('s3cret', 'acct', 'alice', 'uploads/alice/a.pdf', exp, sig, Date.now())).toBe(false)
1017
+ })
1018
+ })
1019
+ ```
1020
+
1021
+ - [ ] **Step 2: Run test to verify it fails**
1022
+
1023
+ Run: `npx vitest run __tests__/portal-sign.test.ts`
1024
+ Expected: FAIL — `signOwnUpload` is not exported.
1025
+
1026
+ - [ ] **Step 3: Add the second message shape to `portal-sign.mjs`**
1027
+
1028
+ ```js
1029
+ /**
1030
+ * The own-upload message. A SECOND shape rather than an extended first one.
1031
+ *
1032
+ * The literal 'uploads' discriminator is what stops an exposed-folder signature
1033
+ * ever admitting an uploads path, and the ownerId inside the HMAC is what stops
1034
+ * one portal minting a link into another person's upload folder.
1035
+ *
1036
+ * Two shapes rather than one because the device and the portal redeploy at
1037
+ * different times — the device through an installer publish, the portal through
1038
+ * site-deploy. A single extended message would break every existing download in
1039
+ * the window between them. An already-deployed portal never mints this type, so
1040
+ * nothing regresses.
1041
+ *
1042
+ * @param {string} accountId @param {string} ownerId @param {string} relPath
1043
+ * @param {number} expiresAtMs
1044
+ */
1045
+ function ownUploadMessage(accountId, ownerId, relPath, expiresAtMs) {
1046
+ return `${accountId}\nuploads\n${ownerId}\n${relPath}\n${expiresAtMs}`
1047
+ }
1048
+
1049
+ /** @param {string} secret @param {string} accountId @param {string} ownerId
1050
+ * @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>} */
1051
+ export async function signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs) {
1052
+ const key = await keyFor(secret)
1053
+ const mac = await crypto.subtle.sign(
1054
+ 'HMAC',
1055
+ key,
1056
+ new TextEncoder().encode(ownUploadMessage(accountId, ownerId, relPath, expiresAtMs)),
1057
+ )
1058
+ return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
1059
+ }
1060
+
1061
+ export async function verifyOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs, signature, nowMs) {
1062
+ if (!secret || !signature) return false
1063
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
1064
+ return constantTimeEqual(await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs), signature)
1065
+ }
1066
+
1067
+ export async function buildOwnUploadUrl(origin, secret, accountId, ownerId, relPath, expiresAtMs) {
1068
+ const sig = await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs)
1069
+ const url = new URL('/api/portal/fetch', origin)
1070
+ url.searchParams.set('accountId', accountId)
1071
+ url.searchParams.set('owner', ownerId)
1072
+ url.searchParams.set('path', relPath)
1073
+ url.searchParams.set('exp', String(expiresAtMs))
1074
+ url.searchParams.set('sig', sig)
1075
+ return url.toString()
1076
+ }
1077
+ ```
1078
+
1079
+ - [ ] **Step 4: Route a moved row's download through it**
1080
+
1081
+ In `processDownload`, read `devicePath` before the R2 get. When it is set, mint an own-upload link and return it for a 302 rather than reading bytes that are no longer there. When it is empty, today's R2 path is unchanged. Write `__tests__/portal-moved-download.test.ts` asserting: an in-transit row streams R2 bytes, a moved row returns a `url` and status 200 with `op=download result=moved`, and a moved row belonging to another owner is still refused 403 by `authorizeKey` before anything is minted.
1082
+
1083
+ - [ ] **Step 5: Run the tests**
1084
+
1085
+ Run: `npx vitest run __tests__/portal-sign.test.ts __tests__/portal-moved-download.test.ts`
1086
+ Expected: PASS.
1087
+
1088
+ - [ ] **Step 6: Commit**
1089
+
1090
+ ```bash
1091
+ git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs \
1092
+ platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts \
1093
+ platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts \
1094
+ platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts
1095
+ git commit -m "feat(1910): own-upload signed links so a moved file still downloads"
1096
+ ```
1097
+
1098
+ ---
1099
+
1100
+ ### Task 7: Device — admit an own-upload path, and only its own owner
1101
+
1102
+ **Files:**
1103
+ - Modify: `platform/ui/server/routes/portal-fetch.ts`
1104
+ - Test: `platform/ui/server/routes/__tests__/portal-fetch.test.ts` (extend)
1105
+
1106
+ **Interfaces:**
1107
+ - Consumes: the own-upload message shape from Task 6.
1108
+ - Produces: `verifyOwnUploadSignature(secret, accountId, ownerId, relPath, expiresAtMs, signature, nowMs): Promise<boolean>`; `resolveFetchTarget(accountDir, relPath, ownerId?)` gains a third parameter.
1109
+
1110
+ - [ ] **Step 1: Write the failing test**
1111
+
1112
+ ```ts
1113
+ it('serves a file under the caller own uploads folder', async () => {
1114
+ // uploads/ is never in the exposed set, so this path is admitted by the
1115
+ // owner rule alone. That is the whole point of the second link type.
1116
+ const target = await resolveFetchTarget(accountDir, 'uploads/alice/a.pdf', 'alice')
1117
+ expect(target.ok).toBe(true)
1118
+ })
1119
+
1120
+ it('refuses another person uploads folder even with a valid signature', async () => {
1121
+ const target = await resolveFetchTarget(accountDir, 'uploads/bob/a.pdf', 'alice')
1122
+ expect(target).toEqual({ ok: false, reason: 'not-exposed' })
1123
+ })
1124
+
1125
+ it('still refuses a non-exposed folder when an owner is supplied', async () => {
1126
+ // The owner rule admits uploads/<owner>/ ONLY. It must not become a general
1127
+ // bypass of the exposed-set check.
1128
+ const target = await resolveFetchTarget(accountDir, 'documents/private.pdf', 'alice')
1129
+ expect(target).toEqual({ ok: false, reason: 'not-exposed' })
1130
+ })
1131
+
1132
+ it('refuses an uploads path when no owner was signed', async () => {
1133
+ const target = await resolveFetchTarget(accountDir, 'uploads/alice/a.pdf')
1134
+ expect(target).toEqual({ ok: false, reason: 'not-exposed' })
1135
+ })
1136
+ ```
1137
+
1138
+ - [ ] **Step 2: Run test to verify it fails**
1139
+
1140
+ Run: `cd platform/ui && npx vitest run server/routes/__tests__/portal-fetch.test.ts`
1141
+ Expected: FAIL — the uploads path is refused as `not-exposed`.
1142
+
1143
+ - [ ] **Step 3: Add the owner admission rule**
1144
+
1145
+ In `resolveFetchTarget`, after the realpath containment assertion and before the exposed-set check, admit the one extra shape:
1146
+
1147
+ ```ts
1148
+ const rel = absolute.slice(root.length + 1)
1149
+ const top = rel.split(sep)[0]
1150
+
1151
+ // The ONE path outside the exposed set that is servable: the caller's own
1152
+ // upload folder. An untargeted upload moves to `uploads/<ownerId>/`, which is
1153
+ // never exposed to the folder tree — without this the client could not
1154
+ // download the file they themselves just sent.
1155
+ //
1156
+ // Scoped to the signed ownerId, so this is not a bypass: `uploads/bob/` is
1157
+ // refused for alice exactly as `documents/` is. The owner comes from inside
1158
+ // the HMAC (verifyOwnUploadSignature), never from a bare query parameter.
1159
+ if (ownerId) {
1160
+ const own = ['uploads', ownerId].join(sep) + sep
1161
+ if (rel.startsWith(own)) {
1162
+ try {
1163
+ const st = await stat(absolute)
1164
+ if (!st.isFile()) return { ok: false, reason: 'enoent' }
1165
+ return { ok: true, absolute, sizeBytes: st.size }
1166
+ } catch {
1167
+ return { ok: false, reason: 'enoent' }
1168
+ }
1169
+ }
1170
+ return { ok: false, reason: 'not-exposed' }
1171
+ }
1172
+
1173
+ if (!exposed.includes(top)) return { ok: false, reason: 'not-exposed' }
1174
+ ```
1175
+
1176
+ In `handle`, read `owner` from the query. When present, verify with `verifyOwnUploadSignature` and pass the owner to `resolveFetchTarget`; when absent, today's `verifySignature` path and call are unchanged. Log `op=request` with an `owner=` field so the two link types are distinguishable in the journal.
1177
+
1178
+ - [ ] **Step 4: Run test to verify it passes**
1179
+
1180
+ Run: `cd platform/ui && npx vitest run server/routes/__tests__/portal-fetch.test.ts`
1181
+ Expected: PASS.
1182
+
1183
+ - [ ] **Step 5: Commit**
1184
+
1185
+ ```bash
1186
+ git add platform/ui/server/routes/portal-fetch.ts platform/ui/server/routes/__tests__/portal-fetch.test.ts
1187
+ git commit -m "feat(1910): device admits a signed own-upload path, scoped to its owner"
1188
+ ```
1189
+
1190
+ ---
1191
+
1192
+ ### Task 8: Device — route, suffix collisions, move, and propagate withdrawals
1193
+
1194
+ **Files:**
1195
+ - Modify: `platform/ui/server/portal-uploads-pull.ts`
1196
+ - Test: `platform/ui/server/__tests__/portal-uploads-pull.test.ts` (extend)
1197
+
1198
+ **Interfaces:**
1199
+ - Consumes: `resolveExposedDirs` from `plugins/cloudflare/bin/schema-exposed-dirs.mjs` (already imported by `portal-fetch.ts:22`).
1200
+ - Produces: `PendingRow` gains `ownerId: string`, `targetPath: string`, `deleted: number`; `resolveDestination(accountDir, row, exposed: string[])` gains a third parameter and a `'bad-target'` reason; `PullCfExec` gains `r2ObjectDelete(bucket, key): Promise<void>` and `d1Exec(name, sql, params)`.
1201
+
1202
+ - [ ] **Step 1: Write the failing tests**
1203
+
1204
+ ```ts
1205
+ describe('targeted routing', () => {
1206
+ it('routes into an exposed folder named by the row', () => {
1207
+ const d = resolveDestination('/acct', { ...row, targetPath: 'jobs' }, ['jobs', 'output'])
1208
+ expect(d.ok && d.final).toBe('/acct/jobs/a.pdf')
1209
+ })
1210
+
1211
+ it('defaults to uploads/<ownerId>, never the person display name', () => {
1212
+ // ownerId, not people.name: two people sharing a display name would
1213
+ // otherwise share one folder and overwrite each other.
1214
+ const d = resolveDestination('/acct', { ...row, ownerId: 'alice', targetPath: '' }, ['jobs'])
1215
+ expect(d.ok && d.final).toBe('/acct/uploads/alice/a.pdf')
1216
+ })
1217
+
1218
+ it('refuses a target outside the device own exposed set', () => {
1219
+ // The portal already checked this. The device checks it AGAIN because the
1220
+ // portal is not the boundary — this is what contains a stale index or a
1221
+ // compromised Pages project.
1222
+ const d = resolveDestination('/acct', { ...row, targetPath: 'documents' }, ['jobs'])
1223
+ expect(d).toEqual({ ok: false, reason: 'bad-target' })
1224
+ })
1225
+
1226
+ it('refuses a traversal segment in the target', () => {
1227
+ const d = resolveDestination('/acct', { ...row, targetPath: 'jobs/../../etc' }, ['jobs'])
1228
+ expect(d).toEqual({ ok: false, reason: 'bad-target' })
1229
+ })
1230
+ })
1231
+
1232
+ describe('collisions', () => {
1233
+ it('suffixes rather than overwriting an existing file', async () => {
1234
+ // Last-write-wins would silently destroy a file the client can no longer
1235
+ // recover, since the R2 copy is deleted on move.
1236
+ await writeFile(join(dir, 'a.pdf'), 'first')
1237
+ const r = await pullAccount({ ...opts, rows: [row] })
1238
+ expect(existsSync(join(dir, 'a (2).pdf'))).toBe(true)
1239
+ expect(await readFile(join(dir, 'a.pdf'), 'utf8')).toBe('first')
1240
+ })
1241
+ })
1242
+
1243
+ describe('move semantics', () => {
1244
+ it('deletes the R2 object only after the size verifies', async () => {
1245
+ const deleted: string[] = []
1246
+ await pullAccount({ ...opts, rows: [row], cf: { ...cf, r2ObjectDelete: async (_b, k) => { deleted.push(k) } } })
1247
+ expect(deleted).toEqual([row.objectKey])
1248
+ })
1249
+
1250
+ it('leaves the object in place when the write fails', async () => {
1251
+ const deleted: string[] = []
1252
+ await pullAccount({ ...opts, rows: [{ ...row, size: 999 }],
1253
+ cf: { ...cf, r2ObjectDelete: async (_b, k) => { deleted.push(k) } } })
1254
+ expect(deleted).toEqual([])
1255
+ })
1256
+
1257
+ it('records devicePath in the same claim as ingested', async () => {
1258
+ const sql: string[] = []
1259
+ await pullAccount({ ...opts, rows: [row], cf: { ...cf, d1Exec: async (_n, s) => { sql.push(s) } } })
1260
+ expect(sql.some((s) => s.includes('devicePath') && s.includes('ingested = 1'))).toBe(true)
1261
+ })
1262
+ })
1263
+
1264
+ describe('withdrawal', () => {
1265
+ it('removes the device file then the row, in that order', async () => {
1266
+ await writeFile(join(dir, 'a.pdf'), 'x')
1267
+ const sql: string[] = []
1268
+ await pullAccount({ ...opts, rows: [{ ...row, deleted: 1, devicePath: 'jobs/a.pdf' }],
1269
+ cf: { ...cf, d1Exec: async (_n, s) => { sql.push(s) } } })
1270
+ expect(existsSync(join(dir, 'a.pdf'))).toBe(false)
1271
+ expect(sql.some((s) => s.startsWith('DELETE FROM manifest'))).toBe(true)
1272
+ })
1273
+
1274
+ it('keeps the row when the device file could not be removed', async () => {
1275
+ // Removing the row while the file survives would strand it: nothing would
1276
+ // ever look at that path again.
1277
+ const sql: string[] = []
1278
+ await pullAccount({ ...opts, rows: [{ ...row, deleted: 1, devicePath: 'jobs/locked.pdf' }],
1279
+ cf: { ...cf, d1Exec: async (_n, s) => { sql.push(s) } } })
1280
+ expect(sql.some((s) => s.startsWith('DELETE FROM manifest'))).toBe(false)
1281
+ })
1282
+ })
1283
+
1284
+ describe('bound parameters', () => {
1285
+ it('never interpolates a value into SQL', async () => {
1286
+ const calls: { sql: string; params: unknown[] }[] = []
1287
+ await pullAccount({ ...opts, rows: [row],
1288
+ cf: { ...cf, d1Exec: async (_n, sql, params) => { calls.push({ sql, params }) } } })
1289
+ for (const c of calls) expect(c.sql).not.toContain(row.fileId)
1290
+ expect(calls.some((c) => c.params.includes(row.fileId))).toBe(true)
1291
+ })
1292
+ })
1293
+ ```
1294
+
1295
+ - [ ] **Step 2: Run tests to verify they fail**
1296
+
1297
+ Run: `cd platform/ui && npx vitest run server/__tests__/portal-uploads-pull.test.ts`
1298
+ Expected: FAIL — `resolveDestination` takes two arguments.
1299
+
1300
+ - [ ] **Step 3: Implement routing, collisions, move and withdrawal**
1301
+
1302
+ `resolveDestination` gains the exposed set and a target branch:
1303
+
1304
+ ```ts
1305
+ export function resolveDestination(
1306
+ accountDir: string,
1307
+ row: PendingRow,
1308
+ exposed: string[],
1309
+ ): DestinationResult {
1310
+ if (!UUID.test(row.fileId)) return { ok: false, reason: 'bad-fileid' }
1311
+ if (!safeSegment(row.ownerId)) return { ok: false, reason: 'bad-owner' }
1312
+ if (!safeSegment(row.filename)) return { ok: false, reason: 'bad-filename' }
1313
+
1314
+ // The device re-derives the legal folder set itself. The portal checked this
1315
+ // already; that check is convenience, this one is the boundary. A stale
1316
+ // index, a bug, or a compromised Pages project stops here.
1317
+ let root: string
1318
+ let dir: string
1319
+ if (row.targetPath) {
1320
+ const segments = row.targetPath.split('/')
1321
+ if (!segments.every(safeSegment)) return { ok: false, reason: 'bad-target' }
1322
+ if (!exposed.includes(segments[0])) return { ok: false, reason: 'bad-target' }
1323
+ root = resolve(accountDir)
1324
+ dir = join(root, ...segments)
1325
+ } else {
1326
+ // ownerId, not people.name: a display name is not unique, and two people
1327
+ // sharing one would share a folder and overwrite each other.
1328
+ root = resolve(accountDir, 'uploads')
1329
+ dir = join(root, row.ownerId)
1330
+ }
1331
+
1332
+ const final = join(dir, row.filename)
1333
+ if (!final.startsWith(root + sep)) return { ok: false, reason: 'bad-target' }
1334
+ return { ok: true, dir, tmp: join(dir, '.uploads-tmp', row.filename), final }
1335
+ }
1336
+ ```
1337
+
1338
+ Add `nextFreePath(final)`, which returns `final` when nothing is there and otherwise `stem (2).ext`, `stem (3).ext` and so on. Last-write-wins is not survivable once the move deletes the R2 copy: the overwritten file would be unrecoverable.
1339
+
1340
+ After the size verification and before the claim, delete the R2 object and re-list to confirm, then claim with `devicePath` in the same statement, bound:
1341
+
1342
+ ```ts
1343
+ await cf.r2ObjectDelete(bucketName, row.objectKey)
1344
+ // Re-read rather than assume: an object that survived its own pull is a
1345
+ // leak that emits nothing at request time, and `unmovedObjects` in the
1346
+ // audit is derived from exactly this state.
1347
+ const stillThere = await cf.r2ObjectHead(bucketName, row.objectKey)
1348
+ log(`${head} op=move r2Deleted=${stillThere === null}`)
1349
+
1350
+ await cf.d1Exec(
1351
+ dbName,
1352
+ 'UPDATE manifest SET ingested = 1, devicePath = ? WHERE fileId = ?',
1353
+ [relativeDevicePath, row.fileId],
1354
+ )
1355
+ ```
1356
+
1357
+ Withdrawal rows (`deleted = 1`) are handled in their own branch before the fetch: `unlink` the recorded `devicePath`, `stat` to confirm absence, and only then `DELETE FROM manifest WHERE fileId = ?`. A failed removal keeps the row so the next cycle retries.
1358
+
1359
+ Switch `PullCfExec` from `d1Query(name, sql)` to `d1Exec(name, sql, params)` backed by `createD1Client` from `d1-http.mjs`, so nothing is interpolated. This closes the interpolation the current code documents at `portal-uploads-pull.ts:164-167`.
1360
+
1361
+ Extend `PENDING_SELECT` to `WHERE m.ingested = 0 OR m.deleted = 1` and select `ownerId`, `targetPath`, `devicePath`, `deleted`.
1362
+
1363
+ - [ ] **Step 4: Run tests to verify they pass**
1364
+
1365
+ Run: `cd platform/ui && npx vitest run server/__tests__/portal-uploads-pull.test.ts`
1366
+ Expected: PASS.
1367
+
1368
+ - [ ] **Step 5: Commit**
1369
+
1370
+ ```bash
1371
+ git add platform/ui/server/portal-uploads-pull.ts platform/ui/server/__tests__/portal-uploads-pull.test.ts
1372
+ git commit -m "feat(1910): route uploads to their folder, move them, propagate withdrawals"
1373
+ ```
1374
+
1375
+ ---
1376
+
1377
+ ### Task 9: Push — atomic generation replace
1378
+
1379
+ **Files:**
1380
+ - Modify: `platform/plugins/cloudflare/bin/portal-index-push.mjs`
1381
+ - Test: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts` (extend)
1382
+
1383
+ - [ ] **Step 1: Write the failing test**
1384
+
1385
+ ```ts
1386
+ it('flips the pointer in one statement after every row is written', async () => {
1387
+ const order = []
1388
+ await pushAccount({ ...opts, d1: { query: async (sql) => { order.push(sql.split(' ')[0]); return { results: [] } } } })
1389
+ const flip = order.lastIndexOf('INSERT') // the pointer upsert
1390
+ const rows = order.lastIndexOf('INSERT', flip - 1)
1391
+ expect(rows).toBeLessThan(flip)
1392
+ })
1393
+
1394
+ it('never deletes rows before the flip', async () => {
1395
+ const order = []
1396
+ await pushAccount({ ...opts, d1: { query: async (sql) => { order.push(sql); return { results: [] } } } })
1397
+ const flipAt = order.findIndex((s) => s.includes('directory_state'))
1398
+ const deleteAt = order.findIndex((s) => s.startsWith('DELETE FROM directory'))
1399
+ expect(flipAt).toBeGreaterThanOrEqual(0)
1400
+ expect(deleteAt).toBeGreaterThan(flipAt)
1401
+ })
1402
+
1403
+ it('writes nothing at all when the exposed set is empty', async () => {
1404
+ // Fail-closed must not delete the account rows: that would be
1405
+ // indistinguishable from a successful publish of an account with no
1406
+ // deliverables.
1407
+ const calls = []
1408
+ await pushAccount({ ...opts, schemaText: null, d1: { query: async (s) => { calls.push(s); return { results: [] } } } })
1409
+ expect(calls.filter((s) => s.startsWith('DELETE') || s.startsWith('INSERT'))).toEqual([])
1410
+ })
1411
+ ```
1412
+
1413
+ - [ ] **Step 2: Run test to verify it fails**
1414
+
1415
+ Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts`
1416
+ Expected: FAIL — the push still issues `DELETE FROM directory` first.
1417
+
1418
+ - [ ] **Step 3: Replace delete-then-insert with generation-then-flip**
1419
+
1420
+ Read the current generation, write every row at `current + 1`, upsert `directory_state` to the new generation in one statement, then delete every row of an earlier generation. Keep the fail-closed early return ahead of all of it, unchanged.
1421
+
1422
+ Readers select through the pointer:
1423
+
1424
+ ```sql
1425
+ SELECT relPath, sizeBytes, modifiedAt, isDir
1426
+ FROM directory d
1427
+ JOIN directory_state s ON s.accountId = d.accountId AND s.currentGeneration = d.generation
1428
+ WHERE d.accountId = ?
1429
+ ```
1430
+
1431
+ Update the same join into `files.ts` and `download.ts` from Tasks 4 and 6.
1432
+
1433
+ - [ ] **Step 4: Run test to verify it passes**
1434
+
1435
+ Run: `npx vitest run __tests__/portal-index-push.test.ts`
1436
+ Expected: PASS.
1437
+
1438
+ - [ ] **Step 5: Commit**
1439
+
1440
+ ```bash
1441
+ git add platform/plugins/cloudflare/bin/portal-index-push.mjs \
1442
+ platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts \
1443
+ platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts \
1444
+ platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
1445
+ git commit -m "feat(1910): atomic generation flip for the portal index replace"
1446
+ ```
1447
+
1448
+ ---
1449
+
1450
+ ### Task 10: Push — 60s loop on the device, and retire the hourly heartbeat
1451
+
1452
+ **Files:**
1453
+ - Create: `platform/ui/server/portal-index-loop.ts`
1454
+ - Modify: `platform/ui/server/index.ts`, `platform/plugins/scheduling/mcp/src/lib/check-due-events.ts`
1455
+ - Test: `platform/ui/server/__tests__/portal-index-loop.test.ts` (create)
1456
+
1457
+ **Interfaces:**
1458
+ - Produces: `PORTAL_INDEX_PUSH_INTERVAL_MS = 60_000`, `runPortalIndexPush(root: string): Promise<string>`.
1459
+
1460
+ - [ ] **Step 1: Write the failing test**
1461
+
1462
+ ```ts
1463
+ it('is registered at sixty seconds', () => {
1464
+ expect(PORTAL_INDEX_PUSH_INTERVAL_MS).toBe(60_000)
1465
+ })
1466
+
1467
+ it('returns before minting a credential when no account has a portal', async () => {
1468
+ // Most installs have neither a portal nor a house credential. Minting
1469
+ // unconditionally would make this loop report an error on every one of them
1470
+ // forever — the same rule the pull and the audit already follow.
1471
+ const note = await runPortalIndexPush(rootWithNoPortals)
1472
+ expect(note).toBe('portals=0 pushed=0')
1473
+ })
1474
+
1475
+ it('one account failure does not stop the rest', async () => {
1476
+ const note = await runPortalIndexPush(rootWithTwoPortalsFirstBroken)
1477
+ expect(note).toContain('pushed=1')
1478
+ expect(note).toContain('failed=1')
1479
+ })
1480
+ ```
1481
+
1482
+ - [ ] **Step 2: Run test to verify it fails**
1483
+
1484
+ Run: `cd platform/ui && npx vitest run server/__tests__/portal-index-loop.test.ts`
1485
+ Expected: FAIL — module not found.
1486
+
1487
+ - [ ] **Step 3: Write the loop and register it**
1488
+
1489
+ `portal-index-loop.ts` enumerates portal accounts with the same `discoverPortalAccounts` the pull uses, and calls the push per account, catching per account so one failure never stops the rest. Register in `index.ts` beside the pull:
1490
+
1491
+ ```ts
1492
+ // The index push moved here from the scheduling heartbeat (Task 1910). That
1493
+ // spawn's own header recorded it had "never been observed running on a
1494
+ // device"; this is the mechanism the pull demonstrably uses. 60s rather than
1495
+ // hourly is what makes a device-side change visible to a client in the same
1496
+ // minute — the hourly gate left the client's tree up to two hours stale.
1497
+ // First run offset past the pull's 65s so a boot does not fire three loops
1498
+ // against the same credential mint.
1499
+ registerLoop({
1500
+ name: 'portal-index-push',
1501
+ intervalMs: PORTAL_INDEX_PUSH_INTERVAL_MS,
1502
+ firstRunDelayMs: 80_000,
1503
+ run: () => runPortalIndexPush(auditPlatformRoot),
1504
+ })
1505
+ ```
1506
+
1507
+ Delete `maybeRunPortalIndexPush` and `auditPortalIndexPush` from `check-due-events.ts` along with their call sites and the `PORTAL_INDEX_INTERVAL_MS` constant, and remove the corresponding lines from `scheduling/PLUGIN.md`.
1508
+
1509
+ - [ ] **Step 4: Run the tests**
1510
+
1511
+ Run: `cd platform/ui && npx vitest run server/__tests__/portal-index-loop.test.ts` then `cd ../plugins/scheduling/mcp && npx vitest run`
1512
+ Expected: PASS both, with no scheduling test still referencing the retired functions.
1513
+
1514
+ - [ ] **Step 5: Commit**
1515
+
1516
+ ```bash
1517
+ git add platform/ui/server/portal-index-loop.ts platform/ui/server/index.ts \
1518
+ platform/ui/server/__tests__/portal-index-loop.test.ts \
1519
+ platform/plugins/scheduling/mcp/src/lib/check-due-events.ts \
1520
+ platform/plugins/scheduling/PLUGIN.md
1521
+ git commit -m "feat(1910): 60s index push loop, retiring the hourly heartbeat spawn"
1522
+ ```
1523
+
1524
+ ---
1525
+
1526
+ ### Task 11: Audit — four fields for four silent failures
1527
+
1528
+ **Files:**
1529
+ - Modify: `platform/ui/server/routes/storage-broker.ts`
1530
+ - Test: `platform/ui/server/__tests__/data-portal-audit.test.ts` (extend)
1531
+
1532
+ - [ ] **Step 1: Write the failing test**
1533
+
1534
+ ```ts
1535
+ it('counts an object that survived its own pull', () => {
1536
+ const r = reconcileDataPortal(
1537
+ [{ key: 'alice/a.pdf' }],
1538
+ [{ objectKey: 'alice/a.pdf', ingested: 1, devicePath: 'jobs/a.pdf', deleted: 0, uploadedAt: t }],
1539
+ now,
1540
+ )
1541
+ expect(r.unmovedObjects.length).toBe(1)
1542
+ })
1543
+
1544
+ it('does not count an in-transit object as unmoved', () => {
1545
+ const r = reconcileDataPortal(
1546
+ [{ key: 'alice/a.pdf' }],
1547
+ [{ objectKey: 'alice/a.pdf', ingested: 0, devicePath: '', deleted: 0, uploadedAt: t }],
1548
+ now,
1549
+ )
1550
+ expect(r.unmovedObjects.length).toBe(0)
1551
+ })
1552
+
1553
+ it('counts a withdrawal the device has not acted on', () => {
1554
+ const r = reconcileDataPortal([], [{ objectKey: 'k', ingested: 1, devicePath: 'jobs/a.pdf', deleted: 1, uploadedAt: t }], now)
1555
+ expect(r.deletePendingRows).toBe(1)
1556
+ })
1557
+
1558
+ it('counts a routed file the index never published', () => {
1559
+ const r = reconcileDataPortal([], [{ objectKey: 'k', ingested: 1, devicePath: 'jobs/a.pdf', deleted: 0, uploadedAt: t }], now, new Set())
1560
+ expect(r.unpublishedRouted).toBe(1)
1561
+ })
1562
+
1563
+ it('reports an account whose config vanished rather than falling silent', async () => {
1564
+ // Task 1901: enumerating by config alone made the audit go quiet in exactly
1565
+ // the incident it exists to report.
1566
+ const note = await runDataPortalAudit(rootWhereConfigVanishedButStateRemembers)
1567
+ expect(note).toContain('config-absent=1')
1568
+ })
1569
+
1570
+ it('emits lastPushAgeSec from the push state file', async () => {
1571
+ const lines = capture(() => runDataPortalAudit(rootWithStalePushState))
1572
+ expect(lines.some((l) => l.includes('lastPushAgeSec='))).toBe(true)
1573
+ })
1574
+ ```
1575
+
1576
+ - [ ] **Step 2: Run test to verify it fails**
1577
+
1578
+ Run: `cd platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
1579
+ Expected: FAIL — `unmovedObjects` is not in the reconcile result.
1580
+
1581
+ - [ ] **Step 3: Extend the reconcile and the line**
1582
+
1583
+ Add the four derivations, and union the enumeration with accounts named in `portal-index-push-state.json` so a vanished config reports `config-absent` instead of dropping the account from the set. The line becomes:
1584
+
1585
+ ```
1586
+ [data-portal-audit] account=… objects=N rows=N orphanObjects=N phantomRows=N
1587
+ oldestUningestedHrs=N pendingRows=N unmovedObjects=N deletePendingRows=N
1588
+ unpublishedRouted=N lastPushAgeSec=N
1589
+ ```
1590
+
1591
+ Counts, never keys: an object key is a real person's filename. Keep every read under its own catch so one data-source failure degrades the line rather than removing it.
1592
+
1593
+ - [ ] **Step 4: Run test to verify it passes**
1594
+
1595
+ Run: `cd platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
1596
+ Expected: PASS.
1597
+
1598
+ - [ ] **Step 5: Commit**
1599
+
1600
+ ```bash
1601
+ git add platform/ui/server/routes/storage-broker.ts platform/ui/server/__tests__/data-portal-audit.test.ts
1602
+ git commit -m "feat(1910): audit the move, the withdrawal, the routing and the push age"
1603
+ ```
1604
+
1605
+ ---
1606
+
1607
+ ### Task 12: Client — drop onto a folder, drag out, multi-file, progress, poll
1608
+
1609
+ **Files:**
1610
+ - Modify: `…/template/portal.js`, `…/template/index.html`, `…/template/portal.css`
1611
+ - Test: manual DOM verification plus the live acceptance in Task 13. There is no server test harness for the static template; `template-config.test.ts` covers only its layout.
1612
+
1613
+ - [ ] **Step 1: Make folder rows and the open listing drop targets**
1614
+
1615
+ In `renderEntries`, for `kind === 'directory'`, add `dragover`/`dragleave`/`drop` handlers that set a `dp-drop-target` class and upload each dropped file with `e.relPath` as the target. Add the same three handlers to `entriesEl` itself so a drop on the open folder's whitespace targets the folder currently in `prefix`.
1616
+
1617
+ - [ ] **Step 2: Take every dropped file, not the first**
1618
+
1619
+ Replace both `files[0]` reads with a loop over `dt.files`, uploading in sequence so the status line reports one file at a time. Add `multiple` to `#dp-file` in `index.html`.
1620
+
1621
+ - [ ] **Step 3: Add drag-out to the client's own upload rows**
1622
+
1623
+ In `render`, mirror the `dragstart` handler `renderEntries` already has (`portal.js:227-233`), pointing at `/api/download?key=…&redirect=1` resolved absolute.
1624
+
1625
+ - [ ] **Step 4: Report progress and distinguish failures**
1626
+
1627
+ Replace the single `'Upload failed.'` with a status-derived message: 401 "Your session expired. Sign in again.", 403 "You cannot upload to that folder.", 413 "That file is too large.", network "Could not reach the server.". For a file above one part, upload through the multipart stages and update the status with the part count as each completes.
1628
+
1629
+ - [ ] **Step 5: Poll while the tab is visible**
1630
+
1631
+ ```js
1632
+ /* The tree is pushed from the device every 60s (Task 1910), so a poll at the
1633
+ same cadence is what makes a device-side change appear without the client
1634
+ clicking anything. Polling was rejected in Tasks 1885 and 1900 on the
1635
+ explicit reasoning that "a poll against an hourly tree is waste" — that
1636
+ premise died with the hourly push.
1637
+
1638
+ Gated on visibility so a forgotten background tab costs nothing, and
1639
+ skipped while a refresh is already in flight so a slow response cannot
1640
+ stack requests. */
1641
+ var polling = false;
1642
+ setInterval(function () {
1643
+ if (document.hidden || panelEl.hidden || polling) return;
1644
+ polling = true;
1645
+ refresh().then(function () { polling = false; });
1646
+ }, 60000);
1647
+ ```
1648
+
1649
+ - [ ] **Step 6: Verify in a browser**
1650
+
1651
+ Serve the template with `npx wrangler pages dev` against a seeded local D1 and drive it with the chrome-devtools MCP: drop a file on a folder row, confirm one `POST /api/upload` carrying `targetPath`, confirm the row appears, drag a file out and confirm the 302. Read console messages and network requests; both are part of the verification surface.
1652
+
1653
+ - [ ] **Step 7: Commit**
1654
+
1655
+ ```bash
1656
+ git add platform/plugins/cloudflare/skills/data-portal/template/portal.js \
1657
+ platform/plugins/cloudflare/skills/data-portal/template/index.html \
1658
+ platform/plugins/cloudflare/skills/data-portal/template/portal.css
1659
+ git commit -m "feat(1910): drop onto a folder, drag out, multi-file, progress, 60s poll"
1660
+ ```
1661
+
1662
+ ---
1663
+
1664
+ ### Task 13: Parity test, docs, and the deploy gate
1665
+
1666
+ **Files:**
1667
+ - Create: `platform/ui/server/__tests__/portal-target-parity.test.ts`
1668
+ - Modify: `.docs/data-portal.md`, `.docs/data-portal-folder-index.md`, `skills/data-portal/SKILL.md`, `platform/plugins/docs/references/` portal guide, `.tasks/LANES.md`
1669
+
1670
+ - [ ] **Step 1: Write the parity test**
1671
+
1672
+ One fixture set of target paths, asserted to be accepted or refused identically by the portal's `checkTarget` segment rule and the device's `resolveDestination`. The two ends implement the same rule separately, so this is what pins them together — the same pattern as the existing HMAC parity test.
1673
+
1674
+ ```ts
1675
+ const FIXTURES = [
1676
+ { target: 'jobs', exposed: ['jobs'], allowed: true },
1677
+ { target: 'jobs/2026', exposed: ['jobs'], allowed: true },
1678
+ { target: 'documents', exposed: ['jobs'], allowed: false },
1679
+ { target: 'jobs/../etc', exposed: ['jobs'], allowed: false },
1680
+ { target: '../etc', exposed: ['jobs'], allowed: false },
1681
+ { target: '', exposed: ['jobs'], allowed: true },
1682
+ { target: 'jobs/', exposed: ['jobs'], allowed: false },
1683
+ { target: '.', exposed: ['jobs'], allowed: false },
1684
+ ]
1685
+ ```
1686
+
1687
+ - [ ] **Step 2: Run the full suites**
1688
+
1689
+ Run: `cd platform/ui && npx vitest run` then `cd ../plugins/cloudflare/mcp && npx vitest run && npm run typecheck:templates`
1690
+ Expected: all green.
1691
+
1692
+ - [ ] **Step 3: Update the docs**
1693
+
1694
+ `.docs/data-portal.md` gains the manifest lifecycle, the two signed-link types and why there are two, and the new cadence. `.docs/data-portal-folder-index.md` gains the generation flip (noting it supersedes backlog 1842) and that `uploads/` stays unexposed while an own-upload link reaches it. `SKILL.md` and the shipped user guide gain the client-facing behaviour. Shipped skill markdown carries no em-dashes.
1695
+
1696
+ - [ ] **Step 4: Commit**
1697
+
1698
+ ```bash
1699
+ git add platform/ui/server/__tests__/portal-target-parity.test.ts .docs/ \
1700
+ platform/plugins/cloudflare/skills/data-portal/SKILL.md platform/plugins/docs/references/
1701
+ git commit -m "test(1910): pin portal and device target rules; docs"
1702
+ ```
1703
+
1704
+ - [ ] **Step 5: Record the deploy gate**
1705
+
1706
+ Live acceptance needs the portal to enumerate accounts again, which is Task 1909's restore of the quarantined `data-portal.json`. Until that lands, success items 1 to 10 cannot be driven on glsmith. The hand-run `ALTER TABLE` statements from Task 1 are also required on any portal deployed before this change; their absence is loud, because the upload names `targetPath` in its INSERT.
1707
+
1708
+ ---
1709
+
1710
+ ## Self-Review
1711
+
1712
+ **Spec coverage.** Manifest state machine → Task 1, 4. Upload key → Task 2. Target validation both ends → Tasks 2, 8, 13. Serving a moved untargeted file → Tasks 6, 7. Withdrawal → Tasks 5, 8. Large uploads → Task 3. Atomic replace → Task 9. Cadence → Task 10. Audit → Task 11. Client → Task 12. Download routing → Task 6. Out-of-scope items appear in no task, as intended. Dependency on 1909 → Task 13 Step 5.
1713
+
1714
+ **Placeholder scan.** No TBD, no "handle edge cases", no "similar to Task N". Task 12 has no automated test and says so explicitly with the reason, rather than implying coverage that does not exist.
1715
+
1716
+ **Type consistency.** `checkTarget` returns `TargetCheck` in Tasks 2 and 3. `resolveDestination` takes `(accountDir, row, exposed)` in Tasks 8 and 13. `PullCfExec.d1Exec(name, sql, params)` is used in Task 8 only. `shouldListUpload(row, visibleTops)` is defined and used in Task 4. `signOwnUpload`/`verifyOwnUpload` are minted in Task 6 and verified in Task 7 with matching argument order.