@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,138 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { processFiles, shouldListUpload } from '../../skills/data-portal/template/functions/api/files'
3
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
+
5
+ const visible = new Set(['jobs', 'output'])
6
+
7
+ /**
8
+ * A directory fake that HONOURS the generation pointer, unlike the older
9
+ * listing fake which matches `FROM directory` and returns every row whatever
10
+ * the query says.
11
+ *
12
+ * The distinction is the whole atomicity guarantee: rows staged at a generation
13
+ * the pointer does not name must be invisible. A fake that ignores the join
14
+ * would pass whether or not files.ts actually reads through it — Task 1704's
15
+ * lesson, that a fake modelling only the happy path proves nothing.
16
+ */
17
+ function envWithGenerations(
18
+ rows: { relPath: string; isDir: number; generation: number }[],
19
+ currentGeneration: number | null,
20
+ manifest: Record<string, unknown>[] = [],
21
+ ) {
22
+ return {
23
+ DB: {
24
+ prepare(sql: string) {
25
+ return {
26
+ bind(...bound: unknown[]) {
27
+ return {
28
+ async first() {
29
+ if (sql.includes('FROM sessions')) {
30
+ return { ownerId: 'alice', accountId: 'acct', folders: '' }
31
+ }
32
+ return null
33
+ },
34
+ async all() {
35
+ if (/FROM manifest/i.test(sql)) return { results: manifest }
36
+ if (/FROM directory/i.test(sql)) {
37
+ // The JOIN: no pointer row means nothing is live at all.
38
+ if (currentGeneration === null) return { results: [] }
39
+ if (!/JOIN\s+directory_state/i.test(sql)) {
40
+ throw new Error('directory read must join the generation pointer')
41
+ }
42
+ return {
43
+ results: rows.filter(
44
+ (r) => bound[0] === 'acct' && r.generation === currentGeneration,
45
+ ),
46
+ }
47
+ }
48
+ return { results: [] }
49
+ },
50
+ }
51
+ },
52
+ }
53
+ },
54
+ },
55
+ } as unknown as PortalEnv
56
+ }
57
+
58
+ describe('the listing reads through the generation pointer', () => {
59
+ it('shows only the generation the pointer names', async () => {
60
+ const env = envWithGenerations(
61
+ [
62
+ { relPath: 'jobs', isDir: 1, generation: 1 },
63
+ { relPath: 'output', isDir: 1, generation: 2 },
64
+ ],
65
+ 1,
66
+ )
67
+ const res = await processFiles('s', env, () => {}, 1000)
68
+ const entries = (res.payload as { entries: { name: string }[] }).entries
69
+ expect(entries.map((e) => e.name)).toEqual(['jobs'])
70
+ })
71
+
72
+ it('shows the whole new tree the instant the pointer flips, never a mixture', async () => {
73
+ const rows = [
74
+ { relPath: 'jobs', isDir: 1, generation: 1 },
75
+ { relPath: 'output', isDir: 1, generation: 2 },
76
+ { relPath: 'quotes', isDir: 1, generation: 2 },
77
+ ]
78
+ const before = await processFiles('s', envWithGenerations(rows, 1), () => {}, 1000)
79
+ const after = await processFiles('s', envWithGenerations(rows, 2), () => {}, 1000)
80
+ expect((before.payload as { entries: unknown[] }).entries.length).toBe(1)
81
+ expect((after.payload as { entries: unknown[] }).entries.length).toBe(2)
82
+ })
83
+
84
+ it('shows nothing for an account that has never completed a publish', async () => {
85
+ const env = envWithGenerations([{ relPath: 'jobs', isDir: 1, generation: 1 }], null)
86
+ const res = await processFiles('s', env, () => {}, 1000)
87
+ expect((res.payload as { entries: unknown[] }).entries).toEqual([])
88
+ })
89
+ })
90
+
91
+ describe('the uploads panel and the tree agree on what is reachable', () => {
92
+ it('hides a moved row whose folder is live and lists one whose folder is not', async () => {
93
+ const env = envWithGenerations(
94
+ [{ relPath: 'jobs', isDir: 1, generation: 1 }],
95
+ 1,
96
+ [
97
+ { fileId: 'f1', filename: 'a.pdf', devicePath: 'jobs/a.pdf', deleted: 0 },
98
+ { fileId: 'f2', filename: 'b.pdf', devicePath: 'uploads/alice/b.pdf', deleted: 0 },
99
+ { fileId: 'f3', filename: 'c.pdf', devicePath: '', deleted: 0 },
100
+ ],
101
+ )
102
+ const res = await processFiles('s', env, () => {}, 1000)
103
+ const files = (res.payload as { files: { fileId: string }[] }).files
104
+ expect(files.map((f) => f.fileId)).toEqual(['f2', 'f3'])
105
+ })
106
+ })
107
+
108
+ describe('shouldListUpload', () => {
109
+ it('lists a row still in transit', () => {
110
+ expect(shouldListUpload({ devicePath: '', deleted: 0 }, visible)).toBe(true)
111
+ })
112
+
113
+ it('hides a moved row whose folder the client can browse', () => {
114
+ // It is not gone, it is shown where it now lives. Listing it here too
115
+ // would put the same file on the page twice.
116
+ expect(shouldListUpload({ devicePath: 'jobs/a.pdf', deleted: 0 }, visible)).toBe(false)
117
+ })
118
+
119
+ it('lists a moved row the client cannot reach in the tree', () => {
120
+ // An untargeted upload lands in uploads/, which is never exposed. Hiding
121
+ // it here would make the file the client just sent unreachable entirely.
122
+ expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 0 }, visible)).toBe(true)
123
+ })
124
+
125
+ it('hides a withdrawn row while the device removal is pending', () => {
126
+ expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 1 }, visible)).toBe(false)
127
+ })
128
+
129
+ it('hides a withdrawn row that has not moved either', () => {
130
+ expect(shouldListUpload({ devicePath: '', deleted: 1 }, visible)).toBe(false)
131
+ })
132
+
133
+ it('matches on the whole top segment, never a prefix', () => {
134
+ // `job` must not satisfy a visible `jobs`, the same whole-segment rule
135
+ // grantAllows follows.
136
+ expect(shouldListUpload({ devicePath: 'job/a.pdf', deleted: 0 }, visible)).toBe(true)
137
+ })
138
+ })
@@ -217,9 +217,20 @@ describe('refresh control', () => {
217
217
  expect((body![0].match(/return false/g) || []).length).toBe(2)
218
218
  })
219
219
 
220
- // No timer, no focus handler. A poll against an hourly tree is waste.
221
- it('never polls', () => {
222
- expect(portalJs()).not.toMatch(/setInterval|visibilitychange/)
220
+ // Task 1900 asserted "never polls", on the reasoning that a poll against an
221
+ // hourly tree is waste. Task 1910 moved the push to a 60s loop, so that
222
+ // premise no longer holds: the poll is now the thing that makes a device-side
223
+ // change appear without the client clicking anything.
224
+ //
225
+ // What still must hold is that the poll cannot run away. It is gated on tab
226
+ // visibility and on a re-entry guard, so a background tab costs nothing and a
227
+ // slow response cannot stack reads on a portal running on a free plan.
228
+ it('polls once a minute, gated on visibility and not re-entrant', () => {
229
+ const text = portalJs()
230
+ const timer = text.match(/setInterval\(function \(\)[\s\S]*?\}, 60000\)/)
231
+ expect(timer).not.toBeNull()
232
+ expect(timer![0]).toMatch(/document\.hidden/)
233
+ expect(timer![0]).toMatch(/polling/)
223
234
  })
224
235
  })
225
236
 
@@ -235,10 +246,106 @@ describe('an action that outlives its re-list', () => {
235
246
  it('has both callers read the boolean refresh() returns', () => {
236
247
  const text = portalJs()
237
248
  const removeBody = text.match(/function remove\(key\)[\s\S]*?\n }\n/)
238
- const uploadBody = text.match(/function uploadFile\(file\)[\s\S]*?\n }\n/)
249
+ const uploadBody = text.match(/function uploadFile\(file, target\)[\s\S]*?\n }\n/)
239
250
  expect(removeBody).not.toBeNull()
240
251
  expect(uploadBody).not.toBeNull()
241
252
  expect(removeBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
242
253
  expect(uploadBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
243
254
  })
244
255
  })
256
+
257
+ // The two-way client (Task 1910). These are text assertions over the shipped
258
+ // template, which is the only harness this dependency-free page has — there is
259
+ // no bundler and no DOM test runner for it. They pin the behaviours that make
260
+ // the exchange two-way; the live drag-and-drop itself is an on-device check.
261
+ describe('the client can send a file to a folder', () => {
262
+ it('makes every folder row a drop target', () => {
263
+ // The interaction the whole task exists for: drop onto the folder, not
264
+ // into a modal that decides for you.
265
+ const text = portalJs()
266
+ expect(text).toMatch(/li\.addEventListener\('drop'/)
267
+ expect(text).toMatch(/dp-drop-target/)
268
+ })
269
+
270
+ it('sends the row own path as the target, not the folder merely open', () => {
271
+ const text = portalJs()
272
+ expect(text).toMatch(/uploadMany\(dt\.files, e\.relPath\)/)
273
+ })
274
+
275
+ it('does not upload a row drop twice', () => {
276
+ // A drop on a folder row bubbles to the listing, whose handler would
277
+ // otherwise upload the same file again into whichever folder is open.
278
+ const text = portalJs()
279
+ expect(text).toMatch(/ev\.dpHandled = true/)
280
+ expect(text).toMatch(/if \(e\.dpHandled\) return/)
281
+ })
282
+
283
+ it('takes every dropped file, not the first', () => {
284
+ const text = portalJs()
285
+ expect(text).not.toMatch(/dt\.files\[0\]/)
286
+ expect(text).toMatch(/function uploadMany\(files, target\)/)
287
+ })
288
+
289
+ it('accepts a multiple selection in the picker', () => {
290
+ expect(indexHtml()).toMatch(/id="dp-file"[^>]*multiple/)
291
+ })
292
+
293
+ it('names the destination folder in the modal', () => {
294
+ // An upload whose folder the client has to infer is the defect this task
295
+ // removes; stating it is part of the fix, not decoration.
296
+ expect(portalJs()).toMatch(/Uploading into /)
297
+ expect(indexHtml()).toMatch(/id="dp-upload-target"/)
298
+ })
299
+ })
300
+
301
+ describe('the client can take a file out', () => {
302
+ it('offers drag-out on the client own uploads, not only device files', () => {
303
+ // Before this there were two stores and only one of them could be dragged
304
+ // out of. DownloadURL is what makes the OS write real bytes.
305
+ const text = portalJs()
306
+ expect((text.match(/setData\(\s*'DownloadURL'/g) || []).length).toBe(2)
307
+ })
308
+ })
309
+
310
+ describe('the client can send a large file', () => {
311
+ it('slices at the same 25 MiB bound the server gates on', () => {
312
+ const text = portalJs()
313
+ expect(text).toMatch(/PART_BYTES = 25 \* 1024 \* 1024/)
314
+ expect(text).toMatch(/MAX_TOTAL_BYTES = 1024 \* 1024 \* 1024/)
315
+ })
316
+
317
+ it('checks the size before sending rather than after', () => {
318
+ // The browser should not spend minutes sending a gigabyte only to be told
319
+ // at the end that it was too large.
320
+ expect(portalJs()).toMatch(/file\.size > MAX_TOTAL_BYTES/)
321
+ })
322
+
323
+ it('runs the three multipart stages', () => {
324
+ const text = portalJs()
325
+ for (const action of ['create', 'part', 'complete']) {
326
+ expect(text).toContain(`'action', '${action}'`)
327
+ }
328
+ })
329
+
330
+ it('shows progress across the parts', () => {
331
+ expect(portalJs()).toMatch(/showProgress\(i \/ total\)/)
332
+ expect(indexHtml()).toMatch(/id="dp-progress-bar"/)
333
+ })
334
+ })
335
+
336
+ describe('a failed upload says which failure it was', () => {
337
+ it('maps each status to its own message', () => {
338
+ // Every failure used to read 'Upload failed.', which tells a client nothing
339
+ // about whether to retry, sign in again, or pick a smaller file.
340
+ const text = portalJs()
341
+ const fn = text.match(/function uploadFailureMessage\(status\)[\s\S]*?\n }\n/)
342
+ expect(fn).not.toBeNull()
343
+ for (const status of ['401', '403', '413', '400']) {
344
+ expect(fn![0]).toContain(status)
345
+ }
346
+ })
347
+
348
+ it('distinguishes a network failure from a server refusal', () => {
349
+ expect(portalJs()).toMatch(/Could not reach the server/)
350
+ })
351
+ })
@@ -78,7 +78,7 @@ describe('processUpload', () => {
78
78
  const res = await processUpload(
79
79
  'sess-a',
80
80
  'invoice.pdf',
81
- new Uint8Array([1, 2, 3]),
81
+ '', new Uint8Array([1, 2, 3]),
82
82
  env,
83
83
  (l) => lines.push(l),
84
84
  () => 'up-1',
@@ -94,7 +94,7 @@ describe('processUpload', () => {
94
94
  await processUpload(
95
95
  'sess-a',
96
96
  'invoice.pdf',
97
- new Uint8Array([1]),
97
+ '', new Uint8Array([1]),
98
98
  env,
99
99
  () => {},
100
100
  () => 'up-1',
@@ -110,7 +110,7 @@ describe('processUpload', () => {
110
110
  await processUpload(
111
111
  'sess-a',
112
112
  'invoice.pdf',
113
- new Uint8Array([1]),
113
+ '', new Uint8Array([1]),
114
114
  env,
115
115
  (l) => lines.push(l),
116
116
  () => 'up-1',
@@ -128,7 +128,7 @@ describe('processUpload', () => {
128
128
  const res = await processUpload(
129
129
  '',
130
130
  'invoice.pdf',
131
- new Uint8Array([1]),
131
+ '', new Uint8Array([1]),
132
132
  env,
133
133
  () => {},
134
134
  () => 'up-1',
@@ -144,7 +144,7 @@ describe('processUpload', () => {
144
144
  const res = await processUpload(
145
145
  'sess-a',
146
146
  '../bob/evil.pdf',
147
- new Uint8Array([1]),
147
+ '', new Uint8Array([1]),
148
148
  env,
149
149
  () => {},
150
150
  () => 'up-1',
@@ -162,7 +162,7 @@ describe('processUpload', () => {
162
162
  await processUpload(
163
163
  'sess-a',
164
164
  '',
165
- new Uint8Array([1]),
165
+ '', new Uint8Array([1]),
166
166
  env,
167
167
  () => {},
168
168
  () => 'up-1',
@@ -179,6 +179,7 @@ describe('processUpload', () => {
179
179
  const res = await processUpload(
180
180
  'sess-a',
181
181
  'huge.bin',
182
+ '',
182
183
  tooBig,
183
184
  env,
184
185
  (l) => lines.push(l),
@@ -198,7 +199,7 @@ describe('processUpload', () => {
198
199
  await processUpload(
199
200
  'sess-a',
200
201
  'invoice.pdf',
201
- new Uint8Array([1]),
202
+ '', new Uint8Array([1]),
202
203
  env,
203
204
  (l) => lines.push(l),
204
205
  () => 'up-1',
@@ -217,11 +218,11 @@ describe('processUpload', () => {
217
218
  describe('re-uploading the same filename', () => {
218
219
  it('replaces the object, keeps exactly one row, and succeeds', async () => {
219
220
  const { env, manifestRows, objects } = makeEnv()
220
- await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
221
+ await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
221
222
  const second = await processUpload(
222
223
  'sess-a',
223
224
  'invoice.pdf',
224
- new Uint8Array([9, 9, 9]),
225
+ '', new Uint8Array([9, 9, 9]),
225
226
  env,
226
227
  () => {},
227
228
  () => 'up-2',
@@ -234,8 +235,8 @@ describe('processUpload', () => {
234
235
 
235
236
  it('updates the row to describe the new object, not the old one', async () => {
236
237
  const { env, manifestRows } = makeEnv()
237
- await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
238
- await processUpload('sess-a', 'invoice.pdf', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
238
+ await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
239
+ await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
239
240
  // Column order: fileId, ownerId, filename, objectKey, size, uploadedAt, ingested
240
241
  expect(manifestRows[0][4]).toBe(3)
241
242
  expect(manifestRows[0][5]).toBe(new Date(2_000).toISOString())
@@ -246,9 +247,9 @@ describe('processUpload', () => {
246
247
  // ingested=1, a replacement that left the flag set would never be
247
248
  // ingested, and nothing would ever say so.
248
249
  const { env, manifestRows } = makeEnv()
249
- await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
250
+ await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
250
251
  manifestRows[0][6] = 1 // the device-side sweep marks it ingested
251
- await processUpload('sess-a', 'invoice.pdf', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
252
+ await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
252
253
  expect(manifestRows[0][6]).toBe(0)
253
254
  })
254
255
  })
@@ -210,15 +210,25 @@ dot-prefixed. These extras are added on top of the schema-derived set and, unlik
210
210
  are not intersected with the `allowed-top-level` block. An empty or absent list behaves exactly as
211
211
  before.
212
212
 
213
- The heartbeat runs this hourly for every account carrying a `data-portal.json`. Run it once by hand
213
+ The admin server runs this every sixty seconds for every account carrying a `data-portal.json`
214
+ . It used to be an hourly spawn from the scheduling heartbeat, which had never been
215
+ observed running on a device and left the client's tree up to an hour stale. Run it once by hand
214
216
  after assembly so the client has something to see on their first sign-in, and check the output names
215
217
  the folders you expect:
216
218
 
217
219
  ```
218
220
  [portal-index] op=resolve account=<id> schemaPresent=true allowed=22 domainBuckets=quotes extraFolders=1 exposed=inbound-invoices,output,quotes
221
+ [portal-index] op=stage account=<id> generation=7 inserted=41
222
+ [portal-index] op=flip account=<id> generation=7
219
223
  [portal-index] op=verify account=<id> rowsAfter=41 expected=41
220
224
  ```
221
225
 
226
+ The publish stages every row at the next generation, moves a pointer in one statement, then sweeps
227
+ the superseded rows. That flip is the atomic point: a client listing mid-publish reads the old tree
228
+ or the new one and never a half-built one. Before this the replace was delete-then-insert, so the
229
+ tree was empty for the length of the insert run, which was tolerable hourly and is not at sixty
230
+ seconds.
231
+
222
232
  `exposed=none` on an account that has deliverables means the schema was missing or its ontology
223
233
  region was absent — not that the walk found nothing. `extraFolders=<n>` counts the folders the
224
234
  operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in
@@ -233,14 +243,15 @@ upload icon. Filenames render on one line: the stem truncates with an ellipsis
233
243
  and the extension stays pinned, so `.pdf` and `.html` on two same-named orders
234
244
  are still told apart.
235
245
 
236
- Refresh re-reads the index in the folder that is already open, so a client who
237
- stays signed in sees files the device pushed after they arrived without
238
- reloading the page. It is a click and never a timer: the device pushes hourly,
239
- so polling would be waste. The button is inert while its request is in flight,
240
- and the status line clears on success or keeps the failure message on screen.
241
- Each click is one further `op=list` line carrying the same account and prefix,
242
- so a click with no line is the handler not firing and two lines is the in-flight
243
- guard not holding.
246
+ Refresh re-reads the index in the folder that is already open. The page also
247
+ re-reads itself every sixty seconds while its tab is visible, which is what
248
+ makes a file the device published appear without the client clicking anything.
249
+ That poll was deliberately absent while the push was hourly, when it would have
250
+ been waste; it arrived with the sixty-second push. It is skipped for a hidden
251
+ tab and while a read is already in flight, so a background tab costs nothing and
252
+ a slow response cannot stack requests. The refresh button is inert while its own
253
+ request is in flight, and the status line clears on success or keeps the failure
254
+ message on screen.
244
255
 
245
256
  Delete and upload both re-read the list when they finish. If that re-read
246
257
  fails, the status keeps the outcome and adds to it, so a client reads
@@ -256,11 +267,38 @@ saves the file rather than a JSON body. The three download methods share the one
256
267
  `op=fetch-through` log line, so a broken method shows as a missing line under the
257
268
  same signal.
258
269
 
259
- Upload opens from the header icon into a dismissable modal holding the file
260
- picker and Upload button. It posts to `/api/upload` exactly as before; a kept
261
- upload refreshes the list and closes the modal. Dropping a file onto the modal's
262
- upload area uploads the same way. The modal dismisses on the close button, a
263
- click on the backdrop, or Escape.
270
+ A client's own uploaded file drags out the same way, so both lists behave alike
271
+ rather than only the device-held one supporting it.
272
+
273
+ **Uploads go to a folder the client chooses.** Dropping files onto a
274
+ folder row sends them into that folder; dropping onto the open listing sends
275
+ them into the folder currently open; the modal states which folder it will use.
276
+ An upload with no folder chosen lands in the client's own uploads area, which
277
+ stays unexposed to the folder tree. The client still sees and downloads it from
278
+ their own list, through a signed link scoped to them.
279
+
280
+ A legal target is one the client can already see: it must pass their folder
281
+ grant and carry a row in the published index. The device re-derives that set
282
+ itself and refuses anything outside it, so a stale index or a compromised Pages
283
+ project cannot route a file into a folder the device does not expose. A refused
284
+ target leaves the file pending and logs the exact reason; it is never quietly
285
+ rerouted somewhere the client did not choose.
286
+
287
+ Several files may be dropped or picked at once and upload one after another.
288
+ Anything over 25 MiB is sliced into parts against a multipart session, up to
289
+ 1 GB, with a progress bar; a file over the limit is refused before any bytes are
290
+ sent. Failures now read differently by cause: an expired session, a folder that
291
+ is not allowed, a file that is too large, and an unreachable server are four
292
+ messages, not one.
293
+
294
+ Once the device has collected an upload it deletes the portal's copy, so the
295
+ file lives in one place rather than two. A targeted file then appears in its
296
+ folder rather than in the uploads list; an untargeted one stays in the uploads
297
+ list and downloads through the device. Deleting a file the device already
298
+ collected removes it from the device too, within a minute; the client is told it
299
+ is pending rather than gone, because for that minute it is.
300
+
301
+ The modal dismisses on the close button, a click on the backdrop, or Escape.
264
302
 
265
303
  ## Deploy through site-deploy
266
304
 
@@ -58,10 +58,40 @@ CREATE TABLE IF NOT EXISTS sessions (
58
58
 
59
59
  CREATE INDEX IF NOT EXISTS sessions_owner ON sessions (ownerId);
60
60
 
61
- -- One row per stored object. `ingested` is the claim flag the admin server's
62
- -- `portal-uploads-pull` loop reads and sets; a row stays 0 until its object is
63
- -- on the device at the size recorded here, so the backlog survives the device
64
- -- being offline and a half-finished transfer never reads as delivered.
61
+ -- One row per stored object, carrying a three-state lifecycle (Task 1910).
62
+ --
63
+ -- in transit ingested=0, devicePath='' bytes in R2
64
+ -- moved ingested=1, devicePath set bytes on the device, R2 object gone
65
+ -- withdrawn deleted=1 device file pending removal
66
+ --
67
+ -- `ingested` is the claim flag the admin server's `portal-uploads-pull` loop
68
+ -- reads and sets; a row stays 0 until its object is on the device at the size
69
+ -- recorded here, so the backlog survives the device being offline and a
70
+ -- half-finished transfer never reads as delivered.
71
+ --
72
+ -- `targetPath` is the exposed folder the client chose at upload, '' when they
73
+ -- chose none. It is part of `objectKey` (ownerId/targetPath/filename), because
74
+ -- a flat key collides the same filename dropped into two folders: the upsert
75
+ -- below would overwrite the first object, reset ingested, and the first file
76
+ -- would never land on the device at all.
77
+ --
78
+ -- `devicePath` is written only after a verified device write, so its presence
79
+ -- IS the proof the move happened. A boolean would say a move occurred without
80
+ -- saying where, and the audit's unpublishedRouted check needs the path to
81
+ -- compare against the published index.
82
+ --
83
+ -- `deleted` marks a withdrawal of an already-moved file, whose bytes are on the
84
+ -- device and whose R2 object is already gone. The row is removed outright once
85
+ -- the device file is confirmed absent — not kept as a tombstone, which would
86
+ -- grow this table without bound and force every listing read to filter it while
87
+ -- describing bytes that no longer exist anywhere.
88
+ --
89
+ -- IF NOT EXISTS adds no column to an existing table. A portal deployed before
90
+ -- this needs the three run by hand, once:
91
+ -- ALTER TABLE manifest ADD COLUMN targetPath TEXT NOT NULL DEFAULT '';
92
+ -- ALTER TABLE manifest ADD COLUMN devicePath TEXT NOT NULL DEFAULT '';
93
+ -- ALTER TABLE manifest ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
94
+ -- Their absence is loud: upload names targetPath in its INSERT.
65
95
  CREATE TABLE IF NOT EXISTS manifest (
66
96
  id INTEGER PRIMARY KEY AUTOINCREMENT,
67
97
  fileId TEXT UNIQUE NOT NULL,
@@ -70,11 +100,17 @@ CREATE TABLE IF NOT EXISTS manifest (
70
100
  objectKey TEXT UNIQUE NOT NULL,
71
101
  size INTEGER NOT NULL,
72
102
  uploadedAt TEXT NOT NULL,
73
- ingested INTEGER NOT NULL DEFAULT 0
103
+ ingested INTEGER NOT NULL DEFAULT 0,
104
+ targetPath TEXT NOT NULL DEFAULT '',
105
+ devicePath TEXT NOT NULL DEFAULT '',
106
+ deleted INTEGER NOT NULL DEFAULT 0
74
107
  );
75
108
 
76
109
  CREATE INDEX IF NOT EXISTS manifest_uningested ON manifest (ingested);
77
110
  CREATE INDEX IF NOT EXISTS manifest_owner ON manifest (ownerId);
111
+ -- The pull selects `ingested = 0 OR deleted = 1`, so the withdrawal half needs
112
+ -- its own index or a withdrawal costs a full scan every sixty seconds.
113
+ CREATE INDEX IF NOT EXISTS manifest_deleted ON manifest (deleted);
78
114
 
79
115
  -- Fixed-window auth attempt counter. Pages Functions are stateless, so the
80
116
  -- counter cannot live in process memory the way every other rate limiter in
@@ -111,7 +147,14 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (s
111
147
  -- its INSERT, so such a table fails loudly on the next cycle. Add it by hand in
112
148
  -- that case:
113
149
  -- ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0;
114
- -- No backfill follows it: the push full-replaces every row each cycle.
150
+ -- No backfill follows it: the push replaces every row each cycle.
151
+ --
152
+ -- `generation` is which staging round a row belongs to (Task 1910). The push
153
+ -- writes the next generation, flips the pointer below in ONE statement, then
154
+ -- deletes rows of earlier generations. Uniqueness is scoped by generation
155
+ -- because two of them hold the same relPath between the write and the flip;
156
+ -- UNIQUE (accountId, relPath) would reject the staged write outright.
157
+ -- ALTER TABLE directory ADD COLUMN generation INTEGER NOT NULL DEFAULT 0;
115
158
  CREATE TABLE IF NOT EXISTS directory (
116
159
  id INTEGER PRIMARY KEY AUTOINCREMENT,
117
160
  accountId TEXT NOT NULL,
@@ -120,7 +163,27 @@ CREATE TABLE IF NOT EXISTS directory (
120
163
  modifiedAt TEXT NOT NULL,
121
164
  indexedAt TEXT NOT NULL,
122
165
  isDir INTEGER NOT NULL DEFAULT 0,
123
- UNIQUE (accountId, relPath)
166
+ generation INTEGER NOT NULL DEFAULT 0,
167
+ UNIQUE (accountId, relPath, generation)
124
168
  );
125
169
 
126
170
  CREATE INDEX IF NOT EXISTS directory_account ON directory (accountId);
171
+
172
+ -- Which generation of an account's rows is the live one.
173
+ --
174
+ -- The flip of this single row is the atomic point of a publish: a client
175
+ -- listing that races a push reads the old tree or the new one, and never a
176
+ -- half-built one. Cleanup of superseded rows afterwards need not be atomic,
177
+ -- because nothing reads a generation this pointer does not name.
178
+ --
179
+ -- This replaces the delete-then-insert window recorded as backlog Task 1842.
180
+ -- At sixty-second cadence that window would be hit sixty times more often than
181
+ -- it was hourly, which is what turned a known defect into a blocking one.
182
+ --
183
+ -- An account with no row here has never completed a publish, and every reader
184
+ -- joins through this table, so it correctly shows nothing rather than showing
185
+ -- a staged generation that no flip ever blessed.
186
+ CREATE TABLE IF NOT EXISTS directory_state (
187
+ accountId TEXT PRIMARY KEY,
188
+ currentGeneration INTEGER NOT NULL
189
+ );
@@ -25,6 +25,25 @@ function message(accountId, relPath, expiresAtMs) {
25
25
  return `${accountId}\n${relPath}\n${expiresAtMs}`
26
26
  }
27
27
 
28
+ /**
29
+ * The own-upload message (Task 1910).
30
+ *
31
+ * A SECOND shape rather than an extended first one, for two reasons.
32
+ *
33
+ * The literal `uploads` discriminator is what stops an exposed-folder signature
34
+ * ever admitting an uploads path, so the owner binding is load-bearing rather
35
+ * than decorative. And the two ends redeploy at different times — the device
36
+ * through an installer publish, the portal through site-deploy — so a single
37
+ * extended message would break every existing download in the window between
38
+ * them. An already-deployed portal never mints this type, so nothing regresses.
39
+ *
40
+ * @param {string} accountId @param {string} ownerId @param {string} relPath
41
+ * @param {number} expiresAtMs
42
+ */
43
+ function ownUploadMessage(accountId, ownerId, relPath, expiresAtMs) {
44
+ return `${accountId}\nuploads\n${ownerId}\n${relPath}\n${expiresAtMs}`
45
+ }
46
+
28
47
  /** @param {string} secret */
29
48
  async function keyFor(secret) {
30
49
  return crypto.subtle.importKey(
@@ -85,3 +104,58 @@ export async function buildSignedUrl(origin, secret, accountId, relPath, expires
85
104
  url.searchParams.set('sig', sig)
86
105
  return url.toString()
87
106
  }
107
+
108
+ /**
109
+ * Sign one own-upload path. Lowercase hex, same algorithm as signPath — only
110
+ * the message differs.
111
+ * @param {string} secret @param {string} accountId @param {string} ownerId
112
+ * @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>}
113
+ */
114
+ export async function signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs) {
115
+ const key = await keyFor(secret)
116
+ const mac = await crypto.subtle.sign(
117
+ 'HMAC',
118
+ key,
119
+ new TextEncoder().encode(ownUploadMessage(accountId, ownerId, relPath, expiresAtMs)),
120
+ )
121
+ return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
122
+ }
123
+
124
+ /**
125
+ * @param {string} secret @param {string} accountId @param {string} ownerId
126
+ * @param {string} relPath @param {number} expiresAtMs @param {string} signature
127
+ * @param {number} nowMs @returns {Promise<boolean>}
128
+ */
129
+ export async function verifyOwnUpload(
130
+ secret,
131
+ accountId,
132
+ ownerId,
133
+ relPath,
134
+ expiresAtMs,
135
+ signature,
136
+ nowMs,
137
+ ) {
138
+ if (!secret || !signature) return false
139
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
140
+ const expected = await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs)
141
+ return constantTimeEqual(expected, signature)
142
+ }
143
+
144
+ /**
145
+ * The owner travels as a query parameter as well as inside the HMAC: the device
146
+ * needs to know which folder is being claimed before it can verify the claim.
147
+ * The parameter is never trusted on its own — a mismatch simply fails to verify.
148
+ * @param {string} origin @param {string} secret @param {string} accountId
149
+ * @param {string} ownerId @param {string} relPath @param {number} expiresAtMs
150
+ * @returns {Promise<string>}
151
+ */
152
+ export async function buildOwnUploadUrl(origin, secret, accountId, ownerId, relPath, expiresAtMs) {
153
+ const sig = await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs)
154
+ const url = new URL('/api/portal/fetch', origin)
155
+ url.searchParams.set('accountId', accountId)
156
+ url.searchParams.set('owner', ownerId)
157
+ url.searchParams.set('path', relPath)
158
+ url.searchParams.set('exp', String(expiresAtMs))
159
+ url.searchParams.set('sig', sig)
160
+ return url.toString()
161
+ }