@rubytech/create-maxy-code 0.1.489 → 0.1.490

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 (220) 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/cloudflare/bin/portal-index-push.mjs +133 -35
  16. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
  17. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +141 -9
  18. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
  19. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
  20. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
  21. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
  22. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
  23. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
  24. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
  25. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +111 -4
  26. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
  27. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +52 -14
  28. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +70 -7
  29. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
  30. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
  31. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
  32. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
  33. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +59 -4
  34. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +58 -9
  35. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
  36. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -1
  37. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
  38. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +216 -20
  39. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
  40. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  41. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  42. package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
  43. package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
  44. package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
  45. package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
  46. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
  47. package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
  48. package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
  49. package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
  50. package/payload/platform/templates/specialists/agents/librarian.md +6 -0
  51. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
  52. package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
  53. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
  54. package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
  55. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
  56. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
  57. package/payload/server/{chunk-UFXRUKXO.js → chunk-KZFE3MOY.js} +49 -3
  58. package/payload/server/public/activity.html +5 -6
  59. package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
  60. package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
  61. package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
  62. package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
  63. package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
  64. package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
  65. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
  66. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
  67. package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
  68. package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
  69. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
  70. package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
  71. package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
  72. package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
  73. package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
  74. package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
  75. package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
  76. package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
  77. package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
  78. package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
  79. package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
  80. package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
  81. package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
  82. package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
  83. package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
  84. package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
  85. package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
  86. package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
  87. package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
  88. package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
  89. package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
  90. package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
  91. package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
  92. package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
  93. package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
  94. package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
  95. package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
  96. package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
  97. package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
  98. package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
  99. package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
  100. package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
  101. package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
  102. package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
  103. package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
  104. package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
  105. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
  106. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
  107. package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
  108. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
  109. package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
  110. package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
  111. package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
  112. package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
  113. package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
  114. package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
  115. package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
  116. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
  117. package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
  118. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
  119. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
  120. package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
  121. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
  122. package/payload/server/public/assets/{graph-Cyh6FWaV.js → graph-kaxS9a_7.js} +2 -2
  123. package/payload/server/public/assets/{graph-labels-CUQtXFKW.js → graph-labels-GgejRtgV.js} +1 -1
  124. package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
  125. package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
  126. package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
  127. package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
  128. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
  129. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
  130. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
  131. package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
  132. package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
  133. package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
  134. package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
  135. package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
  136. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
  137. package/payload/server/public/assets/operator-CldXXilj.js +1 -0
  138. package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
  139. package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
  140. package/payload/server/public/assets/{page-Cno8NNMk.js → page-BE6DkJP9.js} +1 -1
  141. package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
  142. package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
  143. package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
  144. package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
  145. package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
  146. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
  147. package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
  148. package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
  149. package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
  150. package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
  151. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
  152. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
  153. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
  154. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
  155. package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
  156. package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
  157. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
  158. package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
  159. package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
  160. package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
  161. package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
  162. package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
  163. package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
  164. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
  165. package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
  166. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
  167. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
  168. package/payload/server/public/browser.html +4 -4
  169. package/payload/server/public/calendar.html +6 -6
  170. package/payload/server/public/chat.html +14 -14
  171. package/payload/server/public/data.html +11 -11
  172. package/payload/server/public/graph.html +10 -11
  173. package/payload/server/public/index.html +16 -17
  174. package/payload/server/public/operator.html +15 -16
  175. package/payload/server/public/public.html +14 -14
  176. package/payload/server/public/tasks.html +6 -6
  177. package/payload/server/server.js +1488 -895
  178. package/payload/server/{src-YJF3UJNR.js → src-UYDFMWF4.js} +1 -1
  179. package/payload/platform/lib/active-rules/package-lock.json +0 -12
  180. package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
  181. package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
  182. package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
  183. package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
  184. package/payload/server/public/assets/admin-DvQPTxqe.js +0 -1
  185. package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
  186. package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
  187. package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
  188. package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
  189. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
  190. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
  191. package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
  192. package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
  193. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
  194. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
  195. package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
  196. package/payload/server/public/assets/data-Bu-5SUmR.js +0 -1
  197. package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
  198. package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
  199. package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
  200. package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
  201. package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
  202. package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
  203. package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
  204. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
  205. package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
  206. package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
  207. package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
  208. package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
  209. /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
  210. /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
  211. /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
  212. /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
  213. /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
  214. /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
  215. /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
  216. /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
  217. /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
  218. /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
  219. /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
  220. /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
@@ -178,22 +178,71 @@ export async function pushAccount(opts) {
178
178
 
179
179
  const rows = await walkExposed(accountDir, r.exposed)
180
180
 
181
- await client.query('DELETE FROM directory WHERE accountId = ?', [accountId])
181
+ // Stage, flip, then sweep (Task 1910, superseding backlog Task 1842).
182
+ //
183
+ // The replace used to be DELETE-then-INSERT, which left the account's tree
184
+ // empty or half-built for the whole insert run. A client listing inside that
185
+ // window saw a portal with nothing in it. At sixty-second cadence that window
186
+ // is hit sixty times more often than it was hourly, which is what turned a
187
+ // known defect into a blocking one.
188
+ //
189
+ // Now every row is written at the NEXT generation, one statement moves the
190
+ // pointer, and only then are superseded rows removed. The flip is the atomic
191
+ // point: a reader sees the old tree or the new one, never a mixture. The
192
+ // sweep afterwards need not be atomic, because nothing reads a generation the
193
+ // pointer does not name.
194
+ const state = await client.query(
195
+ 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
196
+ [accountId],
197
+ )
198
+ const current = Number(state[0]?.currentGeneration ?? 0)
199
+ const next = current + 1
200
+
182
201
  for (const row of rows) {
183
202
  await client.query(
184
- 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir) VALUES (?, ?, ?, ?, ?, ?)',
185
- [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso, row.isDir ? 1 : 0],
203
+ 'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir, generation) ' +
204
+ 'VALUES (?, ?, ?, ?, ?, ?, ?)',
205
+ [accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso, row.isDir ? 1 : 0, next],
206
+ )
207
+ }
208
+ log(`${TAG} op=stage account=${accountId} generation=${next} inserted=${rows.length}`)
209
+
210
+ // THE atomic point. Upsert rather than insert-or-update, so an account
211
+ // publishing for the first time and one republishing take the same path.
212
+ await client.query(
213
+ 'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
214
+ 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration',
215
+ [accountId, next],
216
+ )
217
+ log(`${TAG} op=flip account=${accountId} generation=${next}`)
218
+
219
+ // Housekeeping only. A failure here leaves stale rows nothing reads, which is
220
+ // waste rather than incorrectness — so it must not fail the publish that has
221
+ // already succeeded.
222
+ try {
223
+ await client.query('DELETE FROM directory WHERE accountId = ? AND generation != ?', [
224
+ accountId,
225
+ next,
226
+ ])
227
+ } catch (err) {
228
+ log(
229
+ `${TAG} op=sweep account=${accountId} generation=${next} result=failed ` +
230
+ `err="${err instanceof Error ? err.message : String(err)}"`,
186
231
  )
187
232
  }
188
- log(`${TAG} op=replace account=${accountId} deleted=all inserted=${rows.length}`)
189
233
 
190
234
  // Read back rather than trusting the write: "issued the inserts" is not "the
191
- // rows are there", and a half-applied batch is otherwise invisible.
192
- const check = await client.query('SELECT COUNT(*) AS n FROM directory WHERE accountId = ?', [accountId])
235
+ // rows are there", and a half-applied batch is otherwise invisible. Counted
236
+ // through the pointer, so this measures what a client would actually see.
237
+ const check = await client.query(
238
+ 'SELECT COUNT(*) AS n FROM directory d JOIN directory_state s ' +
239
+ 'ON s.accountId = d.accountId AND s.currentGeneration = d.generation WHERE d.accountId = ?',
240
+ [accountId],
241
+ )
193
242
  const after = Number(check[0]?.n ?? -1)
194
243
  log(`${TAG} op=verify account=${accountId} rowsAfter=${after} expected=${rows.length}`)
195
244
 
196
- return { exposed: r.exposed, rows: rows.length, schemaPresent: true }
245
+ return { exposed: r.exposed, rows: rows.length, schemaPresent: true, generation: next }
197
246
  }
198
247
 
199
248
  /**
@@ -323,39 +372,44 @@ function resolveCloudflareCredentials(platformRoot) {
323
372
  return { cfAccountId: house.CLOUDFLARE_ACCOUNT_ID, token }
324
373
  }
325
374
 
326
- async function main() {
327
- const platformRoot = process.env.PLATFORM_ROOT
328
- if (!platformRoot) {
329
- console.error(`${TAG} op=usage reason=no-platform-root PLATFORM_ROOT is required`)
330
- process.exit(1)
331
- }
375
+ /**
376
+ * Publish every portal-serving account's index.
377
+ *
378
+ * Exported so the admin server's 60s loop and this script's CLI entry run the
379
+ * SAME code (Task 1910). The loop replaced an hourly heartbeat spawn whose own
380
+ * header recorded it had never been observed running on a device; two
381
+ * implementations of the publish would have reintroduced exactly that class of
382
+ * problem one layer along.
383
+ *
384
+ * Returns a summary rather than exiting, so a caller inside a long-lived
385
+ * process can report it. Throwing is reserved for a genuine break — an absent
386
+ * house credential where portals exist — which the loop registry surfaces as a
387
+ * failed run rather than a silent one.
388
+ *
389
+ * @param {string} platformRoot
390
+ * @returns {Promise<{accounts: number, pushed: number, failed: number}>}
391
+ */
392
+ export async function pushAllAccounts(platformRoot) {
393
+ const accountsRoot = resolve(platformRoot, '..', 'data', 'accounts')
394
+ const targets = await portalAccounts(accountsRoot)
395
+ console.error(`${TAG} op=enumerate accountsRoot="${accountsRoot}" accounts=${targets.length}`)
396
+ // Before minting a credential, exactly as the pull and the audit do: most
397
+ // installs have neither a portal nor a house credential, and minting
398
+ // unconditionally would make this loop report an error on every one forever.
399
+ if (targets.length === 0) return { accounts: 0, pushed: 0, failed: 0 }
332
400
 
333
401
  const cred = resolveCloudflareCredentials(platformRoot)
334
402
  if ('error' in cred) {
335
- console.error(`${TAG} op=skip reason=${cred.error}`)
336
- process.exit(1)
337
- }
338
-
339
- /** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ let targets
340
- if (process.argv.includes('--all-accounts')) {
341
- // Same derivation as the platform's own DATA_ROOT (data-path.ts:19), from
342
- // the PLATFORM_ROOT the heartbeat already passes.
343
- const accountsRoot = resolve(platformRoot, '..', 'data', 'accounts')
344
- targets = await portalAccounts(accountsRoot)
345
- console.error(`${TAG} op=enumerate accountsRoot="${accountsRoot}" accounts=${targets.length}`)
346
- } else {
347
- const accountDir = arg('--account-dir')
348
- const accountId = arg('--account')
349
- const dbName = arg('--db')
350
- if (!accountDir || !accountId || !dbName) {
351
- console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
352
- process.exit(1)
353
- }
354
- targets = [{ accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) }]
403
+ throw new Error(cred.error)
355
404
  }
405
+ return runTargets(platformRoot, cred, targets)
406
+ }
356
407
 
408
+ /** @returns {Promise<{accounts: number, pushed: number, failed: number}>} */
409
+ async function runTargets(platformRoot, cred, targets) {
357
410
  const state = readState(platformRoot)
358
411
  let failed = 0
412
+ let pushed = 0
359
413
  for (const t of targets) {
360
414
  try {
361
415
  // cfAccountId addresses the D1 endpoint; t.accountId scopes the rows.
@@ -382,6 +436,7 @@ async function main() {
382
436
  exposed: r.exposed,
383
437
  })
384
438
  }
439
+ pushed++
385
440
  console.error(`${TAG} op=done account=${t.accountId} rows=${r.rows}`)
386
441
  } catch (err) {
387
442
  // One account's failure must not stop the rest, and it must never be
@@ -392,10 +447,53 @@ async function main() {
392
447
  )
393
448
  }
394
449
  }
395
- if (failed > 0) process.exit(1)
450
+ return { accounts: targets.length, pushed, failed }
451
+ }
452
+
453
+ async function main() {
454
+ const platformRoot = process.env.PLATFORM_ROOT
455
+ if (!platformRoot) {
456
+ console.error(`${TAG} op=usage reason=no-platform-root PLATFORM_ROOT is required`)
457
+ process.exit(1)
458
+ }
459
+
460
+ if (process.argv.includes('--all-accounts')) {
461
+ try {
462
+ const r = await pushAllAccounts(platformRoot)
463
+ if (r.failed > 0) process.exit(1)
464
+ } catch (err) {
465
+ console.error(`${TAG} op=skip reason=${err instanceof Error ? err.message : String(err)}`)
466
+ process.exit(1)
467
+ }
468
+ return
469
+ }
470
+
471
+ const accountDir = arg('--account-dir')
472
+ const accountId = arg('--account')
473
+ const dbName = arg('--db')
474
+ if (!accountDir || !accountId || !dbName) {
475
+ console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
476
+ process.exit(1)
477
+ }
478
+ const cred = resolveCloudflareCredentials(platformRoot)
479
+ if ('error' in cred) {
480
+ console.error(`${TAG} op=skip reason=${cred.error}`)
481
+ process.exit(1)
482
+ }
483
+ const r = await runTargets(platformRoot, cred, [
484
+ { accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) },
485
+ ])
486
+ if (r.failed > 0) process.exit(1)
396
487
  }
397
488
 
398
489
  // Only when invoked directly, so the module stays importable by tests.
399
- if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
490
+ // The basename check is what survives the UI bundler inlining this file into
491
+ // server.js: when bundled, `process.argv[1]` (server.js) and `import.meta.url`
492
+ // (server.js after esbuild's transform) resolve to the same path, and a pure
493
+ // path-equality guard fires main() at server boot, exiting the payload before
494
+ // its first line served. Matching on the mjs basename keeps this cheap CLI
495
+ // entry point from tripping in-process.
496
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''
497
+ if (invokedPath && invokedPath.endsWith('/portal-index-push.mjs') && invokedPath === resolve(fileURLToPath(import.meta.url))) {
400
498
  void main()
401
499
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { processDelete } from '../../skills/data-portal/template/functions/api/delete'
3
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
+
5
+ /** `row` is what the manifest holds for the key under test, or null when the
6
+ * key is unknown. The fake tracks which of the three writes actually happened
7
+ * so a test can assert the R2 object was never touched. */
8
+ function harness(row: { devicePath: string } | null) {
9
+ const state = { objectDeleted: false, rowDeleted: false, marked: false }
10
+ const lines: string[] = []
11
+
12
+ const env = {
13
+ BUCKET: {
14
+ async delete() {
15
+ state.objectDeleted = true
16
+ },
17
+ async head() {
18
+ return state.objectDeleted ? null : { size: 1 }
19
+ },
20
+ },
21
+ DB: {
22
+ prepare(sql: string) {
23
+ return {
24
+ bind() {
25
+ return {
26
+ async first() {
27
+ if (sql.includes('FROM sessions')) {
28
+ return { ownerId: 'alice', accountId: 'acct', folders: '' }
29
+ }
30
+ if (sql.includes('SELECT devicePath')) return row
31
+ if (sql.includes('SELECT deleted')) return { deleted: state.marked ? 1 : 0 }
32
+ if (sql.includes('SELECT fileId FROM manifest')) {
33
+ return state.rowDeleted || !row ? null : { fileId: 'f1' }
34
+ }
35
+ return null
36
+ },
37
+ async run() {
38
+ if (/^UPDATE manifest/.test(sql)) state.marked = true
39
+ if (/^DELETE FROM manifest/.test(sql)) state.rowDeleted = true
40
+ },
41
+ }
42
+ },
43
+ }
44
+ },
45
+ },
46
+ } as unknown as PortalEnv
47
+
48
+ return { env, state, lines, log: (l: string) => lines.push(l) }
49
+ }
50
+
51
+ describe('delete branches on whether the file has moved', () => {
52
+ it('deletes object and row outright when the file is still in transit', async () => {
53
+ const h = harness({ devicePath: '' })
54
+ const res = await processDelete('s', 'alice/a.pdf', h.env, h.log, () => 1)
55
+ expect(res.status).toBe(200)
56
+ expect(h.state.objectDeleted).toBe(true)
57
+ expect(h.state.rowDeleted).toBe(true)
58
+ expect(h.state.marked).toBe(false)
59
+ })
60
+
61
+ it('marks a moved file for withdrawal instead of touching R2', async () => {
62
+ // The bytes are on the device and the R2 object is already gone. Deleting
63
+ // here would report success while the client's file stayed on the device
64
+ // forever — the exact failure this branch exists to prevent.
65
+ const h = harness({ devicePath: 'jobs/a.pdf' })
66
+ const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
67
+ expect(res.status).toBe(200)
68
+ expect(h.state.marked).toBe(true)
69
+ expect(h.state.rowDeleted).toBe(false)
70
+ expect(h.state.objectDeleted).toBe(false)
71
+ expect(h.lines.some((l) => l.includes('op=withdraw') && l.includes('result=marked'))).toBe(true)
72
+ })
73
+
74
+ it('tells the client the withdrawal is pending, not complete', async () => {
75
+ const h = harness({ devicePath: 'jobs/a.pdf' })
76
+ const res = await processDelete('s', 'alice/jobs/a.pdf', h.env, h.log, () => 1)
77
+ expect(res.payload).toMatchObject({ ok: true, pending: true })
78
+ })
79
+
80
+ it('reports an unknown key as not found without writing anything', async () => {
81
+ const h = harness(null)
82
+ const res = await processDelete('s', 'alice/nope.pdf', h.env, h.log, () => 1)
83
+ expect(res.status).toBe(404)
84
+ expect(h.state.objectDeleted).toBe(false)
85
+ expect(h.state.marked).toBe(false)
86
+ expect(h.state.rowDeleted).toBe(false)
87
+ })
88
+
89
+ it('still refuses another person key before reading anything', async () => {
90
+ const h = harness({ devicePath: '' })
91
+ const res = await processDelete('s', 'bob/a.pdf', h.env, h.log, () => 1)
92
+ expect(res.status).toBe(403)
93
+ expect(h.state.objectDeleted).toBe(false)
94
+ })
95
+ })
@@ -49,12 +49,13 @@ function fakeClient() {
49
49
  calls,
50
50
  async query(sql: string, params: unknown[] = []) {
51
51
  calls.push({ sql, params })
52
- if (/DELETE FROM directory/i.test(sql)) {
53
- rows.clear()
52
+ // The open paren is load-bearing: `INSERT INTO directory_state` would
53
+ // otherwise register as a row insert and inflate every count.
54
+ if (/INSERT INTO directory \(/i.test(sql)) {
55
+ rows.add(String(params[1]))
54
56
  return []
55
57
  }
56
- if (/INSERT INTO directory/i.test(sql)) {
57
- rows.add(String(params[1]))
58
+ if (/DELETE FROM directory\b/i.test(sql)) {
58
59
  return []
59
60
  }
60
61
  if (/SELECT COUNT/i.test(sql)) return [{ n: rows.size }]
@@ -140,14 +141,17 @@ describe('walkExposed', () => {
140
141
 
141
142
  describe('pushAccount', () => {
142
143
  // 7 rows: the 3 files plus output/, quotes/, quotes/CR2969/, quotes/CR2974/.
143
- it('deletes the account rows then inserts the current walk', async () => {
144
+ it('stages the current walk, then flips the pointer to it', async () => {
145
+ // Task 1910 replaced delete-then-insert with stage-flip-sweep. The first
146
+ // statement now READS the pointer rather than emptying the table, which is
147
+ // what removes the window where a client saw nothing.
144
148
  const c = fakeClient()
145
149
  const r = await push(c)
146
150
  expect(r.exposed).toEqual(['output', 'quotes'])
147
151
  expect(r.rows).toBe(7)
148
- expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
152
+ expect(/SELECT currentGeneration/i.test(c.calls[0].sql)).toBe(true)
149
153
  expect(c.calls[0].params).toEqual(['acc-1'])
150
- expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(7)
154
+ expect(c.calls.filter((x) => /INSERT INTO directory \(/i.test(x.sql)).length).toBe(7)
151
155
  })
152
156
 
153
157
  // The folder itself survives its last file: emptied is not the same as gone,
@@ -201,12 +205,13 @@ describe('pushAccount', () => {
201
205
  expect(c.calls.length).toBe(0)
202
206
  })
203
207
 
204
- it('logs resolve, replace and verify lines', async () => {
208
+ it('logs resolve, stage, flip and verify lines', async () => {
205
209
  const lines: string[] = []
206
210
  const c = fakeClient()
207
211
  await push(c, { log: (l: string) => lines.push(l) })
208
212
  expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
209
- expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=7'))).toBe(true)
213
+ expect(lines.some((l) => l.includes('op=stage') && l.includes('inserted=7'))).toBe(true)
214
+ expect(lines.some((l) => l.includes('op=flip') && l.includes('generation=1'))).toBe(true)
210
215
  expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=7') && l.includes('expected=7'))).toBe(true)
211
216
  })
212
217
 
@@ -265,3 +270,130 @@ describe('pushAccount', () => {
265
270
  expect(lines.some((l) => /op=resolve .*extraFolders=0/.test(l))).toBe(true)
266
271
  })
267
272
  })
273
+
274
+ // The atomic replace (Task 1910), superseding backlog Task 1842.
275
+ //
276
+ // A generation-aware client: rows carry a generation, and a reader sees only
277
+ // the generation the pointer names. That is what makes "never a half-built
278
+ // tree" a measurable property rather than an assertion.
279
+ function generationClient() {
280
+ const calls: { sql: string; params: unknown[] }[] = []
281
+ /** @type {{accountId: string, relPath: string, generation: number}[]} */
282
+ const rows: { accountId: string; relPath: string; generation: number }[] = []
283
+ let pointer: number | null = null
284
+ return {
285
+ calls,
286
+ rows,
287
+ /** What a client listing right now would see. */
288
+ visible() {
289
+ return pointer === null ? [] : rows.filter((r) => r.generation === pointer)
290
+ },
291
+ pointerValue() {
292
+ return pointer
293
+ },
294
+ async query(sql: string, params: unknown[] = []) {
295
+ calls.push({ sql, params })
296
+ if (/SELECT\s+currentGeneration/i.test(sql)) {
297
+ return pointer === null ? [] : [{ currentGeneration: pointer }]
298
+ }
299
+ if (/INSERT INTO directory_state|UPDATE directory_state/i.test(sql)) {
300
+ pointer = Number(params[1] ?? params[0])
301
+ return []
302
+ }
303
+ if (/INSERT INTO directory\b/i.test(sql)) {
304
+ rows.push({
305
+ accountId: String(params[0]),
306
+ relPath: String(params[1]),
307
+ generation: Number(params[6]),
308
+ })
309
+ return []
310
+ }
311
+ if (/DELETE FROM directory\b/i.test(sql)) {
312
+ const keep = Number(params[1])
313
+ for (let i = rows.length - 1; i >= 0; i--) {
314
+ if (rows[i].generation !== keep) rows.splice(i, 1)
315
+ }
316
+ return []
317
+ }
318
+ if (/SELECT COUNT/i.test(sql)) {
319
+ return [{ n: rows.filter((r) => r.generation === pointer).length }]
320
+ }
321
+ return []
322
+ },
323
+ }
324
+ }
325
+
326
+ describe('the index replace is atomic', () => {
327
+ it('never issues a delete before the pointer flip', async () => {
328
+ // The delete-then-insert window this replaces was Task 1842: a client
329
+ // listing mid-push saw an empty or half-built tree. At 60s cadence that
330
+ // window would be hit sixty times more often than it was hourly.
331
+ const c = generationClient()
332
+ await push(c)
333
+ const flipAt = c.calls.findIndex((x) => /INSERT INTO directory_state/i.test(x.sql))
334
+ const deleteAt = c.calls.findIndex((x) => /^DELETE FROM directory\b/i.test(x.sql.trim()))
335
+ expect(flipAt).toBeGreaterThanOrEqual(0)
336
+ expect(deleteAt).toBeGreaterThan(flipAt)
337
+ })
338
+
339
+ it('writes every row before the flip', async () => {
340
+ const c = generationClient()
341
+ await push(c)
342
+ const flipAt = c.calls.findIndex((x) => /INSERT INTO directory_state/i.test(x.sql))
343
+ const lastInsert = c.calls.map((x) => /INSERT INTO directory\b/i.test(x.sql)).lastIndexOf(true)
344
+ expect(lastInsert).toBeLessThan(flipAt)
345
+ })
346
+
347
+ it('a reader mid-push sees the old tree, never a partial one', async () => {
348
+ // First publish establishes generation 1.
349
+ const c = generationClient()
350
+ await push(c)
351
+ const first = c.visible().map((r) => r.relPath).sort()
352
+ expect(first.length).toBeGreaterThan(0)
353
+
354
+ // A second publish stages generation 2. Until the flip, `visible()` must
355
+ // still be exactly the first tree — not a mixture, not an empty set.
356
+ const seen: string[][] = []
357
+ const watched = {
358
+ ...c,
359
+ async query(sql: string, params: unknown[] = []) {
360
+ const out = await c.query(sql, params)
361
+ seen.push(c.visible().map((r) => r.relPath).sort())
362
+ return out
363
+ },
364
+ }
365
+ await push(watched)
366
+
367
+ const flipIdx = c.calls.findIndex(
368
+ (x, i) => i > first.length && /INSERT INTO directory_state/i.test(x.sql),
369
+ )
370
+ for (let i = 0; i < flipIdx; i++) {
371
+ expect(seen[i]).toEqual(first)
372
+ }
373
+ })
374
+
375
+ it('advances the generation on each publish', async () => {
376
+ const c = generationClient()
377
+ await push(c)
378
+ expect(c.pointerValue()).toBe(1)
379
+ await push(c)
380
+ expect(c.pointerValue()).toBe(2)
381
+ })
382
+
383
+ it('deletes superseded generations after the flip', async () => {
384
+ const c = generationClient()
385
+ await push(c)
386
+ await push(c)
387
+ expect(c.rows.every((r) => r.generation === 2)).toBe(true)
388
+ })
389
+
390
+ it('writes nothing at all when the exposed set is empty', async () => {
391
+ // Fail closed must not delete the account rows: that state would be
392
+ // indistinguishable from a successful publish of an account with no
393
+ // deliverables.
394
+ rmSync(join(dir, 'SCHEMA.md'))
395
+ const c = generationClient()
396
+ await push(c)
397
+ expect(c.calls.filter((x) => /INSERT|DELETE|directory_state/i.test(x.sql))).toEqual([])
398
+ })
399
+ })
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { processDownload } from '../../skills/data-portal/template/functions/api/download'
3
+ import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
4
+
5
+ const ACC = '098a18a3-1741-447f-9778-65470645d57d'
6
+
7
+ function harness(row: { devicePath: string } | null, opts: { configured?: boolean } = {}) {
8
+ const configured = opts.configured ?? true
9
+ const lines: string[] = []
10
+ const env = {
11
+ BUCKET: {
12
+ async get(key: string) {
13
+ // Only an in-transit object exists. A moved row's bytes are gone from
14
+ // R2 entirely, which is what makes reading them here the wrong move.
15
+ return row && !row.devicePath ? { body: null, size: 4, key } : null
16
+ },
17
+ },
18
+ DB: {
19
+ prepare(sql: string) {
20
+ return {
21
+ bind() {
22
+ return {
23
+ async first() {
24
+ if (sql.includes('FROM sessions')) {
25
+ return { ownerId: 'alice', accountId: ACC, folders: '' }
26
+ }
27
+ if (sql.includes('devicePath')) return row
28
+ return null
29
+ },
30
+ }
31
+ },
32
+ }
33
+ },
34
+ },
35
+ PORTAL_FETCH_SECRET: configured ? 'test-secret-value' : undefined,
36
+ PORTAL_INSTALL_ORIGIN: configured ? 'https://install.example' : undefined,
37
+ } as unknown as PortalEnv
38
+ return { env, lines, log: (l: string) => lines.push(l) }
39
+ }
40
+
41
+ describe('downloading an upload that has moved to the device', () => {
42
+ it('streams R2 bytes while the row is still in transit', async () => {
43
+ const h = harness({ devicePath: '' })
44
+ const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
45
+ expect(res.status).toBe(200)
46
+ expect(res.body).not.toBeNull()
47
+ expect(res.url).toBeUndefined()
48
+ })
49
+
50
+ it('mints an own-upload link once the row has moved', async () => {
51
+ // The object is gone from R2. Without this branch the client's own upload
52
+ // would 404 the moment the device collected it.
53
+ const h = harness({ devicePath: 'uploads/alice/a.pdf' })
54
+ const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
55
+ expect(res.status).toBe(200)
56
+ expect(res.url).toContain('/api/portal/fetch')
57
+ expect(new URL(res.url!).searchParams.get('owner')).toBe('alice')
58
+ expect(new URL(res.url!).searchParams.get('path')).toBe('uploads/alice/a.pdf')
59
+ expect(h.lines.some((l) => l.includes('op=download') && l.includes('result=moved'))).toBe(true)
60
+ })
61
+
62
+ it('refuses another person key before anything is minted', async () => {
63
+ const h = harness({ devicePath: 'uploads/bob/a.pdf' })
64
+ const res = await processDownload('s', 'bob/a.pdf', h.env, h.log, 1000)
65
+ expect(res.status).toBe(403)
66
+ expect(res.url).toBeUndefined()
67
+ })
68
+
69
+ it('reports device-offline rather than a server error when unconfigured', async () => {
70
+ // Same reasoning as the indexed path: from the client's side the file
71
+ // genuinely is unreachable, and the operator's signal is the log line.
72
+ const h = harness({ devicePath: 'uploads/alice/a.pdf' }, { configured: false })
73
+ const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
74
+ expect(res.status).toBe(503)
75
+ expect(h.lines.some((l) => l.includes('result=unconfigured'))).toBe(true)
76
+ })
77
+
78
+ it('reports absent for a key with no manifest row at all', async () => {
79
+ const h = harness(null)
80
+ const res = await processDownload('s', 'alice/gone.pdf', h.env, h.log, 1000)
81
+ expect(res.status).toBe(404)
82
+ })
83
+ })
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ // The schema is applied by the skill, not by any code this suite can call, so
6
+ // its shape is asserted textually. A missing column here is not a test failure
7
+ // in some other file — it is an upload naming a column D1 does not have.
8
+ const SQL = readFileSync(
9
+ join(__dirname, '../../skills/data-portal/schema.sql'),
10
+ 'utf8',
11
+ )
12
+
13
+ describe('portal schema shape', () => {
14
+ it('carries the manifest lifecycle columns', () => {
15
+ expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
16
+ expect(SQL).toMatch(/devicePath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
17
+ expect(SQL).toMatch(/deleted\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
18
+ })
19
+
20
+ it('carries the directory generation and its pointer table', () => {
21
+ expect(SQL).toMatch(/generation\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
22
+ expect(SQL).toMatch(/CREATE TABLE IF NOT EXISTS directory_state/)
23
+ expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
24
+ })
25
+
26
+ it('keeps objectKey UNIQUE, which the upsert conflict target still needs', () => {
27
+ // objectKey is now ownerId/targetPath/filename. The upsert still conflicts
28
+ // on it, so this pins that the constraint was not dropped by accident while
29
+ // the key shape changed underneath it.
30
+ expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
31
+ })
32
+
33
+ it('scopes the directory uniqueness by generation', () => {
34
+ // Two generations hold the same relPath at once between the write and the
35
+ // flip. UNIQUE (accountId, relPath) would reject the second write and the
36
+ // push could never stage a new generation at all.
37
+ expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*relPath,\s*generation\s*\)/)
38
+ })
39
+
40
+ it('documents the hand-run ALTER for every added column', () => {
41
+ // IF NOT EXISTS adds no column to an existing table, so a portal deployed
42
+ // before this needs each one by hand. An undocumented column is an operator
43
+ // reading "no such column" with nothing telling them what to run.
44
+ for (const col of ['targetPath', 'devicePath', 'deleted', 'generation']) {
45
+ expect(SQL).toMatch(new RegExp(`ALTER TABLE \\w+ ADD COLUMN ${col}\\b`))
46
+ }
47
+ })
48
+ })