@rubytech/create-maxy-code 0.1.488 → 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 (282) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
  3. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +1716 -0
  4. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
  5. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +103 -0
  6. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
  7. package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
  8. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
  9. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
  10. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
  11. package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
  12. package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
  13. package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
  14. package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
  15. package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/graph-style/dist/index.js +25 -1
  17. package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
  18. package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
  19. package/payload/platform/lib/graph-style/src/index.ts +27 -0
  20. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
  21. package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/audit.d.ts +34 -1
  23. package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
  24. package/payload/platform/lib/storage-broker/dist/audit.js +28 -4
  25. package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -0
  27. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -0
  29. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  30. package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
  31. package/payload/platform/lib/storage-broker/src/audit.ts +67 -3
  32. package/payload/platform/lib/storage-broker/src/cf-exec.ts +48 -0
  33. package/payload/platform/neo4j/schema.cypher +105 -0
  34. package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +34 -5
  35. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
  36. package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
  37. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +161 -41
  38. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
  39. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
  40. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +216 -16
  41. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +179 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
  51. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +87 -13
  52. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +87 -9
  53. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +72 -5
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +80 -13
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +12 -1
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +258 -21
  63. package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
  64. package/payload/platform/plugins/docs/references/internals.md +2 -0
  65. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
  66. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
  67. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  68. package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
  69. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  70. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
  71. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
  72. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
  73. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
  74. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
  75. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  76. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
  77. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  78. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
  79. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
  80. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
  81. package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
  82. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
  83. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  84. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
  85. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  86. package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
  87. package/payload/platform/scripts/quarantine-restore.mjs +175 -0
  88. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
  89. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  90. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +52 -74
  91. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  92. package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
  93. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  94. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
  95. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  96. package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
  97. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  98. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
  99. package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
  100. package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
  101. package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
  102. package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
  103. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  104. package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
  105. package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
  106. package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
  107. package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
  108. package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
  109. package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
  110. package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
  111. package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
  112. package/payload/platform/templates/specialists/agents/librarian.md +6 -0
  113. package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
  114. package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
  115. package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
  116. package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
  117. package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
  118. package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
  119. package/payload/server/{chunk-REWXLAPC.js → chunk-KZFE3MOY.js} +52 -4
  120. package/payload/server/public/activity.html +5 -6
  121. package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
  122. package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
  123. package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
  124. package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
  125. package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
  126. package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
  127. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
  128. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
  129. package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
  130. package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
  131. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
  132. package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
  133. package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
  134. package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
  135. package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
  136. package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
  137. package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
  138. package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
  139. package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
  140. package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
  141. package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
  142. package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
  143. package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
  144. package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
  145. package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
  146. package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
  147. package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
  148. package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
  149. package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
  150. package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
  151. package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
  152. package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
  153. package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
  154. package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
  155. package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
  156. package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
  157. package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
  158. package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
  159. package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
  160. package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
  161. package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
  162. package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
  163. package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
  164. package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
  165. package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
  166. package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
  167. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
  168. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
  169. package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
  170. package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
  171. package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
  172. package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
  173. package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
  174. package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
  175. package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
  176. package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
  177. package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
  178. package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
  179. package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
  180. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
  181. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
  182. package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
  183. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
  184. package/payload/server/public/assets/{graph-CNgt7ygi.js → graph-kaxS9a_7.js} +2 -2
  185. package/payload/server/public/assets/graph-labels-GgejRtgV.js +1 -0
  186. package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
  187. package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
  188. package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
  189. package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
  190. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
  191. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
  192. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
  193. package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
  194. package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
  195. package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
  196. package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
  197. package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
  198. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
  199. package/payload/server/public/assets/operator-CldXXilj.js +1 -0
  200. package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
  201. package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
  202. package/payload/server/public/assets/{page-Bvx0A2jB.js → page-BE6DkJP9.js} +1 -1
  203. package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
  204. package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
  205. package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
  206. package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
  207. package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
  208. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
  209. package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
  210. package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
  211. package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
  212. package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
  213. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
  214. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
  215. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
  216. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
  217. package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
  218. package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
  219. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
  220. package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
  221. package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
  222. package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
  223. package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
  224. package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
  225. package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
  226. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
  227. package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
  228. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
  229. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
  230. package/payload/server/public/browser.html +4 -4
  231. package/payload/server/public/calendar.html +6 -6
  232. package/payload/server/public/chat.html +14 -14
  233. package/payload/server/public/data.html +11 -11
  234. package/payload/server/public/graph.html +10 -11
  235. package/payload/server/public/index.html +16 -17
  236. package/payload/server/public/operator.html +15 -16
  237. package/payload/server/public/public.html +14 -14
  238. package/payload/server/public/tasks.html +6 -6
  239. package/payload/server/server.js +1836 -998
  240. package/payload/server/{src-JJ6PHO5I.js → src-UYDFMWF4.js} +1 -1
  241. package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
  242. package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
  243. package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
  244. package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
  245. package/payload/server/public/assets/admin-CThbXaYH.js +0 -1
  246. package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
  247. package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
  248. package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
  249. package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
  250. package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
  251. package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
  252. package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
  253. package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
  254. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
  255. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
  256. package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
  257. package/payload/server/public/assets/data-D5dp6nj6.js +0 -1
  258. package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
  259. package/payload/server/public/assets/graph-labels-DA48HrRR.js +0 -1
  260. package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
  261. package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
  262. package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
  263. package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
  264. package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
  265. package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
  266. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
  267. package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
  268. package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
  269. package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
  270. package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
  271. /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
  272. /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
  273. /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
  274. /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
  275. /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
  276. /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
  277. /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
  278. /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
  279. /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
  280. /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
  281. /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
  282. /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
@@ -143,6 +143,16 @@ describe('device binding', () => {
143
143
  expect(text).toMatch(/UNIQUE \(accountId, relPath\)/)
144
144
  expect(text).toMatch(/accountId\s+TEXT NOT NULL DEFAULT ''/)
145
145
  })
146
+
147
+ // IF NOT EXISTS is a no-op on an existing table, so a portal deployed before
148
+ // this column will NOT gain it from a re-apply. The push names isDir in its
149
+ // INSERT, so such a table fails loudly rather than silently publishing
150
+ // nothing — the ALTER has to be findable at the point that happens.
151
+ it('declares isDir and carries the ALTER for a table that predates it', () => {
152
+ const text = schema()
153
+ expect(text).toMatch(/isDir\s+INTEGER NOT NULL DEFAULT 0/)
154
+ expect(text).toContain('ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0')
155
+ })
146
156
  })
147
157
 
148
158
  const portalJs = () => readFileSync(join(TEMPLATE, 'portal.js'), 'utf8')
@@ -170,3 +180,172 @@ describe('login error surfacing and passcode reveal (Task 1872)', () => {
170
180
  expect(portalJs()).toMatch(/dp-reveal/)
171
181
  })
172
182
  })
183
+
184
+ // Task 1900. The device pushes a new tree hourly, so a client who stays signed
185
+ // in reads an index frozen at sign-in. These assertions pin the control that
186
+ // re-reads it: the button in the pinned header, and the click that drives it.
187
+ describe('refresh control', () => {
188
+ it('puts a refresh button inside the pinned header', () => {
189
+ const header = indexHtml().match(/<header class="dp-header">[\s\S]*?<\/header>/)
190
+ expect(header).not.toBeNull()
191
+ expect(header![0]).toMatch(/id="dp-refresh"/)
192
+ expect(header![0]).toMatch(/class="dp-iconbtn"/)
193
+ expect(header![0]).toMatch(/aria-label="Refresh"/)
194
+ })
195
+
196
+ it('sits between the crumbs and the upload control', () => {
197
+ const text = indexHtml()
198
+ expect(text.indexOf('id="dp-crumbs"')).toBeLessThan(text.indexOf('id="dp-refresh"'))
199
+ expect(text.indexOf('id="dp-refresh"')).toBeLessThan(text.indexOf('id="dp-upload-open"'))
200
+ })
201
+
202
+ it('binds a click on it', () => {
203
+ expect(portalJs()).toMatch(/refreshEl\.addEventListener\('click'/)
204
+ })
205
+
206
+ it('drives refresh() rather than reloading the page', () => {
207
+ expect(portalJs()).not.toMatch(/location\.reload/)
208
+ })
209
+
210
+ // The status line has to survive a failed re-read. refresh() catches its own
211
+ // failure, so the promise resolves either way; only a reported outcome tells
212
+ // the handler whether clearing the status would erase an error.
213
+ it('has refresh() report its outcome on all three exits', () => {
214
+ const body = portalJs().match(/function refresh\(\)[\s\S]*?\n }\n/)
215
+ expect(body).not.toBeNull()
216
+ expect(body![0]).toMatch(/return true/)
217
+ expect((body![0].match(/return false/g) || []).length).toBe(2)
218
+ })
219
+
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/)
234
+ })
235
+ })
236
+
237
+ // refresh() now writes its own failure text, which would bury the outcome of
238
+ // the delete or upload the client just asked for. Both callers compose instead,
239
+ // so a stale list is reported without losing what happened to the file.
240
+ describe('an action that outlives its re-list', () => {
241
+ it('keeps the delete and upload outcome when the re-list fails', () => {
242
+ const text = portalJs()
243
+ expect((text.match(/The list could not be reloaded\./g) || []).length).toBe(2)
244
+ })
245
+
246
+ it('has both callers read the boolean refresh() returns', () => {
247
+ const text = portalJs()
248
+ const removeBody = text.match(/function remove\(key\)[\s\S]*?\n }\n/)
249
+ const uploadBody = text.match(/function uploadFile\(file, target\)[\s\S]*?\n }\n/)
250
+ expect(removeBody).not.toBeNull()
251
+ expect(uploadBody).not.toBeNull()
252
+ expect(removeBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
253
+ expect(uploadBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
254
+ })
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
  })
@@ -13,9 +13,11 @@ references with `plugin-read` before issuing their commands.
13
13
  ## What this skill produces, and what it does not
14
14
 
15
15
  It produces a Pages project on a custom domain, an R2 bucket holding one prefix per enrolled person,
16
- and a D1 database holding the manifest, the enrolment records, and the sessions. It does not ingest
17
- the dropped files: a separate device-side sweep reads un-ingested manifest rows and pulls them into
18
- the graph. This skill delivers the manifest that sweep consumes.
16
+ and a D1 database holding the manifest, the enrolment records, and the sessions. It does not carry a
17
+ dropped file onto the device: the `portal-uploads-pull` loop in the admin server does that, every
18
+ minute, writing each un-ingested manifest row's object to `uploads/<ownerName>/` and setting
19
+ `ingested = 1` only once the bytes are on disk at the right size. This skill delivers the manifest
20
+ that loop consumes. Nothing here or there ingests a pulled file into the graph.
19
21
 
20
22
  ## Why the portal is on Pages and not the install
21
23
 
@@ -110,6 +112,17 @@ with the same `bin/cf-store-name.sh` scheme as the bucket, and apply
110
112
  `platform/plugins/cloudflare/skills/data-portal/schema.sql` to the remote database from there. It is
111
113
  `IF NOT EXISTS` throughout, so re-applying is safe.
112
114
 
115
+ Safe, but not a migration: `IF NOT EXISTS` adds no column to a table that already exists. A portal
116
+ whose `directory` table predates the `isDir` column keeps failing every index push, because the push
117
+ names the column in its `INSERT`. Add it once by hand against that database:
118
+
119
+ ```bash
120
+ npx wrangler d1 execute <portalDbName> --remote \
121
+ --command "ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0"
122
+ ```
123
+
124
+ No backfill follows it. The push replaces every row for the account each cycle.
125
+
113
126
  The copied `portal.css` carries the template's default palette, which is nobody's brand. Apply the
114
127
  install's before handing the tree to the deploy:
115
128
 
@@ -164,7 +177,7 @@ anyway. That check is what contains a stale index or a compromised project, and
164
177
 
165
178
  ## Publish the folder index
166
179
 
167
- The device publishes file metadata — names, sizes, times, never bytes:
180
+ The device publishes file and folder metadata — names, sizes, times, never bytes:
168
181
 
169
182
  ```bash
170
183
  node platform/plugins/cloudflare/bin/portal-index-push.mjs \
@@ -178,6 +191,13 @@ brand with a different vertical publishes different folders with no change to th
178
191
  data (`documents/`, `contacts/`, `projects/`), the client's own `uploads/`, plugin workspaces and
179
192
  everything platform-internal are in neither source and are never published.
180
193
 
194
+ Every walked folder gets a row of its own, not only the files inside it. An exposed folder holding
195
+ nothing at any depth is therefore a real entry: it renders in the tree and opens to an empty listing,
196
+ rather than reading as absent to the person who has to put the first file into it. A folder row
197
+ carries no bytes, so the download path refuses it and logs `result=is-directory`. A folder named in
198
+ the schema but absent from the device's disk still publishes nothing, so the tree never offers a
199
+ folder the device does not have.
200
+
181
201
  An account with no `SCHEMA.md` publishes nothing at all. That is deliberate and it is the opposite of
182
202
  what the write-path guard does — the guard fails open so an unseeded account is not write-blocked,
183
203
  while a surface facing a client must fail closed.
@@ -190,15 +210,25 @@ dot-prefixed. These extras are added on top of the schema-derived set and, unlik
190
210
  are not intersected with the `allowed-top-level` block. An empty or absent list behaves exactly as
191
211
  before.
192
212
 
193
- 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
194
216
  after assembly so the client has something to see on their first sign-in, and check the output names
195
217
  the folders you expect:
196
218
 
197
219
  ```
198
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
199
223
  [portal-index] op=verify account=<id> rowsAfter=41 expected=41
200
224
  ```
201
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
+
202
232
  `exposed=none` on an account that has deliverables means the schema was missing or its ontology
203
233
  region was absent — not that the walk found nothing. `extraFolders=<n>` counts the folders the
204
234
  operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in
@@ -208,9 +238,26 @@ operator requested in `exposeFolders`; a non-zero `extraFolders` with none of th
208
238
 
209
239
  Signed in, the panel has a pinned header that stays put while the file lists
210
240
  scroll under it. The header carries the folder breadcrumbs, an up control that
211
- moves to the parent folder (disabled at the root), and an upload icon. Filenames
212
- render on one line: the stem truncates with an ellipsis and the extension stays
213
- pinned, so `.pdf` and `.html` on two same-named orders are still told apart.
241
+ moves to the parent folder (disabled at the root), a refresh control, and an
242
+ upload icon. Filenames render on one line: the stem truncates with an ellipsis
243
+ and the extension stays pinned, so `.pdf` and `.html` on two same-named orders
244
+ are still told apart.
245
+
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.
255
+
256
+ Delete and upload both re-read the list when they finish. If that re-read
257
+ fails, the status keeps the outcome and adds to it, so a client reads
258
+ `Deleted. The list could not be reloaded.` rather than a bare load error that
259
+ would leave them unsure whether the file went. The rows on screen are then
260
+ known to be stale, and refresh is how they clear that.
214
261
 
215
262
  A shared (device-held) file downloads three ways, all through the same signed
216
263
  `/api/download?path=` route: a left click, a right-click that opens a one-item
@@ -220,11 +267,38 @@ saves the file rather than a JSON body. The three download methods share the one
220
267
  `op=fetch-through` log line, so a broken method shows as a missing line under the
221
268
  same signal.
222
269
 
223
- Upload opens from the header icon into a dismissable modal holding the file
224
- picker and Upload button. It posts to `/api/upload` exactly as before; a kept
225
- upload refreshes the list and closes the modal. Dropping a file onto the modal's
226
- upload area uploads the same way. The modal dismisses on the close button, a
227
- 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.
228
302
 
229
303
  ## Deploy through site-deploy
230
304
 
@@ -58,9 +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 device-side
62
- -- sweep (a separate task) reads and sets; a row stays 0 until its file has
63
- -- been pulled into the graph, so the backlog survives the device being offline.
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.
64
95
  CREATE TABLE IF NOT EXISTS manifest (
65
96
  id INTEGER PRIMARY KEY AUTOINCREMENT,
66
97
  fileId TEXT UNIQUE NOT NULL,
@@ -69,11 +100,17 @@ CREATE TABLE IF NOT EXISTS manifest (
69
100
  objectKey TEXT UNIQUE NOT NULL,
70
101
  size INTEGER NOT NULL,
71
102
  uploadedAt TEXT NOT NULL,
72
- 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
73
107
  );
74
108
 
75
109
  CREATE INDEX IF NOT EXISTS manifest_uningested ON manifest (ingested);
76
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);
77
114
 
78
115
  -- Fixed-window auth attempt counter. Pages Functions are stateless, so the
79
116
  -- counter cannot live in process memory the way every other rate limiter in
@@ -88,16 +125,36 @@ CREATE TABLE IF NOT EXISTS auth_attempts (
88
125
 
89
126
  CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (scope, windowStart);
90
127
 
91
- -- One row per file the device has published to this portal. Metadata only: the
92
- -- bytes stay on the device and are fetched on demand through a short-lived
93
- -- signed link, which is what lets the folder tree render while the device is
94
- -- offline.
128
+ -- One row per file OR folder the device has published to this portal. Metadata
129
+ -- only: the bytes stay on the device and are fetched on demand through a
130
+ -- short-lived signed link, which is what lets the folder tree render while the
131
+ -- device is offline.
95
132
  --
96
133
  -- Written by a full replace per account (delete-all, then insert the current
97
134
  -- walk), which is why there is no tombstone column: a file deleted or renamed on
98
135
  -- the device simply stops being inserted on the next cycle. The UNIQUE key is
99
136
  -- what makes that replace safe to retry — a double insert of the same path
100
137
  -- fails loudly instead of silently doubling the row.
138
+ --
139
+ -- `isDir` marks a folder row. Folders are stored rather than derived from file
140
+ -- path prefixes, because a folder holding no files at any depth has no prefix
141
+ -- to derive it from and would not exist to the client at all. A folder row
142
+ -- carries sizeBytes 0 and the folder's own modified time, and the download path
143
+ -- refuses to mint a signed link for one.
144
+ --
145
+ -- Like `people.accountId` and `people.folders`, IF NOT EXISTS will NOT add this
146
+ -- column to a `directory` table that predates it, and the push names `isDir` in
147
+ -- its INSERT, so such a table fails loudly on the next cycle. Add it by hand in
148
+ -- that case:
149
+ -- ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0;
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;
101
158
  CREATE TABLE IF NOT EXISTS directory (
102
159
  id INTEGER PRIMARY KEY AUTOINCREMENT,
103
160
  accountId TEXT NOT NULL,
@@ -105,7 +162,28 @@ CREATE TABLE IF NOT EXISTS directory (
105
162
  sizeBytes INTEGER NOT NULL,
106
163
  modifiedAt TEXT NOT NULL,
107
164
  indexedAt TEXT NOT NULL,
108
- UNIQUE (accountId, relPath)
165
+ isDir INTEGER NOT NULL DEFAULT 0,
166
+ generation INTEGER NOT NULL DEFAULT 0,
167
+ UNIQUE (accountId, relPath, generation)
109
168
  );
110
169
 
111
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
+ }