@rubytech/create-maxy-code 0.1.489 → 0.1.491
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +1716 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +103 -0
- package/payload/platform/lib/storage-broker/dist/audit.d.ts +29 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.js +25 -2
- package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/audit.ts +59 -1
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +48 -0
- package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +34 -5
- package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +28 -8
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +133 -35
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +141 -9
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +111 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +52 -14
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +70 -7
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +59 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +58 -9
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +216 -20
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
- package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
- package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
- package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
- package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
- package/payload/platform/templates/specialists/agents/librarian.md +6 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
- package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
- package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
- package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
- package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
- package/payload/server/{chunk-UFXRUKXO.js → chunk-KZFE3MOY.js} +49 -3
- package/payload/server/public/activity.html +5 -6
- package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
- package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
- package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
- package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
- package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
- package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
- package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
- package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
- package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
- package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
- package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
- package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
- package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
- package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
- package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
- package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
- package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
- package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
- package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
- package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
- package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
- package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
- package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
- package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
- package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
- package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
- package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
- package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
- package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
- package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
- package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
- package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
- package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
- package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
- package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
- package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
- package/payload/server/public/assets/{graph-Cyh6FWaV.js → graph-kaxS9a_7.js} +2 -2
- package/payload/server/public/assets/{graph-labels-CUQtXFKW.js → graph-labels-GgejRtgV.js} +1 -1
- package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
- package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
- package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
- package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
- package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
- package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
- package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
- package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
- package/payload/server/public/assets/operator-CldXXilj.js +1 -0
- package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
- package/payload/server/public/assets/{page-Cno8NNMk.js → page-BE6DkJP9.js} +1 -1
- package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
- package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
- package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
- package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
- package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
- package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
- package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
- package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
- package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
- package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
- package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +14 -14
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +10 -11
- package/payload/server/public/index.html +16 -17
- package/payload/server/public/operator.html +15 -16
- package/payload/server/public/public.html +14 -14
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1488 -895
- package/payload/server/{src-YJF3UJNR.js → src-UYDFMWF4.js} +1 -1
- package/payload/platform/lib/active-rules/package-lock.json +0 -12
- package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
- package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
- package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
- package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
- package/payload/server/public/assets/admin-DvQPTxqe.js +0 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
- package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
- package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
- package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
- package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
- package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
- package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
- package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
- package/payload/server/public/assets/data-Bu-5SUmR.js +0 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
- package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
- package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
- package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
- package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
- package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
- package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
- package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
- /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
- /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
- /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
- /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
- /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
- /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
- /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
- /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
- /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
- /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
- /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
- /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { processFiles, shouldListUpload } from '../../skills/data-portal/template/functions/api/files'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
const visible = new Set(['jobs', 'output'])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A directory fake that HONOURS the generation pointer, unlike the older
|
|
9
|
+
* listing fake which matches `FROM directory` and returns every row whatever
|
|
10
|
+
* the query says.
|
|
11
|
+
*
|
|
12
|
+
* The distinction is the whole atomicity guarantee: rows staged at a generation
|
|
13
|
+
* the pointer does not name must be invisible. A fake that ignores the join
|
|
14
|
+
* would pass whether or not files.ts actually reads through it — Task 1704's
|
|
15
|
+
* lesson, that a fake modelling only the happy path proves nothing.
|
|
16
|
+
*/
|
|
17
|
+
function envWithGenerations(
|
|
18
|
+
rows: { relPath: string; isDir: number; generation: number }[],
|
|
19
|
+
currentGeneration: number | null,
|
|
20
|
+
manifest: Record<string, unknown>[] = [],
|
|
21
|
+
) {
|
|
22
|
+
return {
|
|
23
|
+
DB: {
|
|
24
|
+
prepare(sql: string) {
|
|
25
|
+
return {
|
|
26
|
+
bind(...bound: unknown[]) {
|
|
27
|
+
return {
|
|
28
|
+
async first() {
|
|
29
|
+
if (sql.includes('FROM sessions')) {
|
|
30
|
+
return { ownerId: 'alice', accountId: 'acct', folders: '' }
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
},
|
|
34
|
+
async all() {
|
|
35
|
+
if (/FROM manifest/i.test(sql)) return { results: manifest }
|
|
36
|
+
if (/FROM directory/i.test(sql)) {
|
|
37
|
+
// The JOIN: no pointer row means nothing is live at all.
|
|
38
|
+
if (currentGeneration === null) return { results: [] }
|
|
39
|
+
if (!/JOIN\s+directory_state/i.test(sql)) {
|
|
40
|
+
throw new Error('directory read must join the generation pointer')
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
results: rows.filter(
|
|
44
|
+
(r) => bound[0] === 'acct' && r.generation === currentGeneration,
|
|
45
|
+
),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { results: [] }
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
} as unknown as PortalEnv
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('the listing reads through the generation pointer', () => {
|
|
59
|
+
it('shows only the generation the pointer names', async () => {
|
|
60
|
+
const env = envWithGenerations(
|
|
61
|
+
[
|
|
62
|
+
{ relPath: 'jobs', isDir: 1, generation: 1 },
|
|
63
|
+
{ relPath: 'output', isDir: 1, generation: 2 },
|
|
64
|
+
],
|
|
65
|
+
1,
|
|
66
|
+
)
|
|
67
|
+
const res = await processFiles('s', env, () => {}, 1000)
|
|
68
|
+
const entries = (res.payload as { entries: { name: string }[] }).entries
|
|
69
|
+
expect(entries.map((e) => e.name)).toEqual(['jobs'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows the whole new tree the instant the pointer flips, never a mixture', async () => {
|
|
73
|
+
const rows = [
|
|
74
|
+
{ relPath: 'jobs', isDir: 1, generation: 1 },
|
|
75
|
+
{ relPath: 'output', isDir: 1, generation: 2 },
|
|
76
|
+
{ relPath: 'quotes', isDir: 1, generation: 2 },
|
|
77
|
+
]
|
|
78
|
+
const before = await processFiles('s', envWithGenerations(rows, 1), () => {}, 1000)
|
|
79
|
+
const after = await processFiles('s', envWithGenerations(rows, 2), () => {}, 1000)
|
|
80
|
+
expect((before.payload as { entries: unknown[] }).entries.length).toBe(1)
|
|
81
|
+
expect((after.payload as { entries: unknown[] }).entries.length).toBe(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('shows nothing for an account that has never completed a publish', async () => {
|
|
85
|
+
const env = envWithGenerations([{ relPath: 'jobs', isDir: 1, generation: 1 }], null)
|
|
86
|
+
const res = await processFiles('s', env, () => {}, 1000)
|
|
87
|
+
expect((res.payload as { entries: unknown[] }).entries).toEqual([])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('the uploads panel and the tree agree on what is reachable', () => {
|
|
92
|
+
it('hides a moved row whose folder is live and lists one whose folder is not', async () => {
|
|
93
|
+
const env = envWithGenerations(
|
|
94
|
+
[{ relPath: 'jobs', isDir: 1, generation: 1 }],
|
|
95
|
+
1,
|
|
96
|
+
[
|
|
97
|
+
{ fileId: 'f1', filename: 'a.pdf', devicePath: 'jobs/a.pdf', deleted: 0 },
|
|
98
|
+
{ fileId: 'f2', filename: 'b.pdf', devicePath: 'uploads/alice/b.pdf', deleted: 0 },
|
|
99
|
+
{ fileId: 'f3', filename: 'c.pdf', devicePath: '', deleted: 0 },
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
const res = await processFiles('s', env, () => {}, 1000)
|
|
103
|
+
const files = (res.payload as { files: { fileId: string }[] }).files
|
|
104
|
+
expect(files.map((f) => f.fileId)).toEqual(['f2', 'f3'])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('shouldListUpload', () => {
|
|
109
|
+
it('lists a row still in transit', () => {
|
|
110
|
+
expect(shouldListUpload({ devicePath: '', deleted: 0 }, visible)).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('hides a moved row whose folder the client can browse', () => {
|
|
114
|
+
// It is not gone, it is shown where it now lives. Listing it here too
|
|
115
|
+
// would put the same file on the page twice.
|
|
116
|
+
expect(shouldListUpload({ devicePath: 'jobs/a.pdf', deleted: 0 }, visible)).toBe(false)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('lists a moved row the client cannot reach in the tree', () => {
|
|
120
|
+
// An untargeted upload lands in uploads/, which is never exposed. Hiding
|
|
121
|
+
// it here would make the file the client just sent unreachable entirely.
|
|
122
|
+
expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 0 }, visible)).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('hides a withdrawn row while the device removal is pending', () => {
|
|
126
|
+
expect(shouldListUpload({ devicePath: 'uploads/alice/a.pdf', deleted: 1 }, visible)).toBe(false)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('hides a withdrawn row that has not moved either', () => {
|
|
130
|
+
expect(shouldListUpload({ devicePath: '', deleted: 1 }, visible)).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('matches on the whole top segment, never a prefix', () => {
|
|
134
|
+
// `job` must not satisfy a visible `jobs`, the same whole-segment rule
|
|
135
|
+
// grantAllows follows.
|
|
136
|
+
expect(shouldListUpload({ devicePath: 'job/a.pdf', deleted: 0 }, visible)).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -217,9 +217,20 @@ describe('refresh control', () => {
|
|
|
217
217
|
expect((body![0].match(/return false/g) || []).length).toBe(2)
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
// Task 1900 asserted "never polls", on the reasoning that a poll against an
|
|
221
|
+
// hourly tree is waste. Task 1910 moved the push to a 60s loop, so that
|
|
222
|
+
// premise no longer holds: the poll is now the thing that makes a device-side
|
|
223
|
+
// change appear without the client clicking anything.
|
|
224
|
+
//
|
|
225
|
+
// What still must hold is that the poll cannot run away. It is gated on tab
|
|
226
|
+
// visibility and on a re-entry guard, so a background tab costs nothing and a
|
|
227
|
+
// slow response cannot stack reads on a portal running on a free plan.
|
|
228
|
+
it('polls once a minute, gated on visibility and not re-entrant', () => {
|
|
229
|
+
const text = portalJs()
|
|
230
|
+
const timer = text.match(/setInterval\(function \(\)[\s\S]*?\}, 60000\)/)
|
|
231
|
+
expect(timer).not.toBeNull()
|
|
232
|
+
expect(timer![0]).toMatch(/document\.hidden/)
|
|
233
|
+
expect(timer![0]).toMatch(/polling/)
|
|
223
234
|
})
|
|
224
235
|
})
|
|
225
236
|
|
|
@@ -235,10 +246,106 @@ describe('an action that outlives its re-list', () => {
|
|
|
235
246
|
it('has both callers read the boolean refresh() returns', () => {
|
|
236
247
|
const text = portalJs()
|
|
237
248
|
const removeBody = text.match(/function remove\(key\)[\s\S]*?\n }\n/)
|
|
238
|
-
const uploadBody = text.match(/function uploadFile\(file\)[\s\S]*?\n }\n/)
|
|
249
|
+
const uploadBody = text.match(/function uploadFile\(file, target\)[\s\S]*?\n }\n/)
|
|
239
250
|
expect(removeBody).not.toBeNull()
|
|
240
251
|
expect(uploadBody).not.toBeNull()
|
|
241
252
|
expect(removeBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
|
|
242
253
|
expect(uploadBody![0]).toMatch(/refresh\(\)\.then\(function \(ok\)/)
|
|
243
254
|
})
|
|
244
255
|
})
|
|
256
|
+
|
|
257
|
+
// The two-way client (Task 1910). These are text assertions over the shipped
|
|
258
|
+
// template, which is the only harness this dependency-free page has — there is
|
|
259
|
+
// no bundler and no DOM test runner for it. They pin the behaviours that make
|
|
260
|
+
// the exchange two-way; the live drag-and-drop itself is an on-device check.
|
|
261
|
+
describe('the client can send a file to a folder', () => {
|
|
262
|
+
it('makes every folder row a drop target', () => {
|
|
263
|
+
// The interaction the whole task exists for: drop onto the folder, not
|
|
264
|
+
// into a modal that decides for you.
|
|
265
|
+
const text = portalJs()
|
|
266
|
+
expect(text).toMatch(/li\.addEventListener\('drop'/)
|
|
267
|
+
expect(text).toMatch(/dp-drop-target/)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('sends the row own path as the target, not the folder merely open', () => {
|
|
271
|
+
const text = portalJs()
|
|
272
|
+
expect(text).toMatch(/uploadMany\(dt\.files, e\.relPath\)/)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('does not upload a row drop twice', () => {
|
|
276
|
+
// A drop on a folder row bubbles to the listing, whose handler would
|
|
277
|
+
// otherwise upload the same file again into whichever folder is open.
|
|
278
|
+
const text = portalJs()
|
|
279
|
+
expect(text).toMatch(/ev\.dpHandled = true/)
|
|
280
|
+
expect(text).toMatch(/if \(e\.dpHandled\) return/)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('takes every dropped file, not the first', () => {
|
|
284
|
+
const text = portalJs()
|
|
285
|
+
expect(text).not.toMatch(/dt\.files\[0\]/)
|
|
286
|
+
expect(text).toMatch(/function uploadMany\(files, target\)/)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('accepts a multiple selection in the picker', () => {
|
|
290
|
+
expect(indexHtml()).toMatch(/id="dp-file"[^>]*multiple/)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('names the destination folder in the modal', () => {
|
|
294
|
+
// An upload whose folder the client has to infer is the defect this task
|
|
295
|
+
// removes; stating it is part of the fix, not decoration.
|
|
296
|
+
expect(portalJs()).toMatch(/Uploading into /)
|
|
297
|
+
expect(indexHtml()).toMatch(/id="dp-upload-target"/)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('the client can take a file out', () => {
|
|
302
|
+
it('offers drag-out on the client own uploads, not only device files', () => {
|
|
303
|
+
// Before this there were two stores and only one of them could be dragged
|
|
304
|
+
// out of. DownloadURL is what makes the OS write real bytes.
|
|
305
|
+
const text = portalJs()
|
|
306
|
+
expect((text.match(/setData\(\s*'DownloadURL'/g) || []).length).toBe(2)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('the client can send a large file', () => {
|
|
311
|
+
it('slices at the same 25 MiB bound the server gates on', () => {
|
|
312
|
+
const text = portalJs()
|
|
313
|
+
expect(text).toMatch(/PART_BYTES = 25 \* 1024 \* 1024/)
|
|
314
|
+
expect(text).toMatch(/MAX_TOTAL_BYTES = 1024 \* 1024 \* 1024/)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('checks the size before sending rather than after', () => {
|
|
318
|
+
// The browser should not spend minutes sending a gigabyte only to be told
|
|
319
|
+
// at the end that it was too large.
|
|
320
|
+
expect(portalJs()).toMatch(/file\.size > MAX_TOTAL_BYTES/)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('runs the three multipart stages', () => {
|
|
324
|
+
const text = portalJs()
|
|
325
|
+
for (const action of ['create', 'part', 'complete']) {
|
|
326
|
+
expect(text).toContain(`'action', '${action}'`)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('shows progress across the parts', () => {
|
|
331
|
+
expect(portalJs()).toMatch(/showProgress\(i \/ total\)/)
|
|
332
|
+
expect(indexHtml()).toMatch(/id="dp-progress-bar"/)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
describe('a failed upload says which failure it was', () => {
|
|
337
|
+
it('maps each status to its own message', () => {
|
|
338
|
+
// Every failure used to read 'Upload failed.', which tells a client nothing
|
|
339
|
+
// about whether to retry, sign in again, or pick a smaller file.
|
|
340
|
+
const text = portalJs()
|
|
341
|
+
const fn = text.match(/function uploadFailureMessage\(status\)[\s\S]*?\n }\n/)
|
|
342
|
+
expect(fn).not.toBeNull()
|
|
343
|
+
for (const status of ['401', '403', '413', '400']) {
|
|
344
|
+
expect(fn![0]).toContain(status)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('distinguishes a network failure from a server refusal', () => {
|
|
349
|
+
expect(portalJs()).toMatch(/Could not reach the server/)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
@@ -78,7 +78,7 @@ describe('processUpload', () => {
|
|
|
78
78
|
const res = await processUpload(
|
|
79
79
|
'sess-a',
|
|
80
80
|
'invoice.pdf',
|
|
81
|
-
new Uint8Array([1, 2, 3]),
|
|
81
|
+
'', new Uint8Array([1, 2, 3]),
|
|
82
82
|
env,
|
|
83
83
|
(l) => lines.push(l),
|
|
84
84
|
() => 'up-1',
|
|
@@ -94,7 +94,7 @@ describe('processUpload', () => {
|
|
|
94
94
|
await processUpload(
|
|
95
95
|
'sess-a',
|
|
96
96
|
'invoice.pdf',
|
|
97
|
-
new Uint8Array([1]),
|
|
97
|
+
'', new Uint8Array([1]),
|
|
98
98
|
env,
|
|
99
99
|
() => {},
|
|
100
100
|
() => 'up-1',
|
|
@@ -110,7 +110,7 @@ describe('processUpload', () => {
|
|
|
110
110
|
await processUpload(
|
|
111
111
|
'sess-a',
|
|
112
112
|
'invoice.pdf',
|
|
113
|
-
new Uint8Array([1]),
|
|
113
|
+
'', new Uint8Array([1]),
|
|
114
114
|
env,
|
|
115
115
|
(l) => lines.push(l),
|
|
116
116
|
() => 'up-1',
|
|
@@ -128,7 +128,7 @@ describe('processUpload', () => {
|
|
|
128
128
|
const res = await processUpload(
|
|
129
129
|
'',
|
|
130
130
|
'invoice.pdf',
|
|
131
|
-
new Uint8Array([1]),
|
|
131
|
+
'', new Uint8Array([1]),
|
|
132
132
|
env,
|
|
133
133
|
() => {},
|
|
134
134
|
() => 'up-1',
|
|
@@ -144,7 +144,7 @@ describe('processUpload', () => {
|
|
|
144
144
|
const res = await processUpload(
|
|
145
145
|
'sess-a',
|
|
146
146
|
'../bob/evil.pdf',
|
|
147
|
-
new Uint8Array([1]),
|
|
147
|
+
'', new Uint8Array([1]),
|
|
148
148
|
env,
|
|
149
149
|
() => {},
|
|
150
150
|
() => 'up-1',
|
|
@@ -162,7 +162,7 @@ describe('processUpload', () => {
|
|
|
162
162
|
await processUpload(
|
|
163
163
|
'sess-a',
|
|
164
164
|
'',
|
|
165
|
-
new Uint8Array([1]),
|
|
165
|
+
'', new Uint8Array([1]),
|
|
166
166
|
env,
|
|
167
167
|
() => {},
|
|
168
168
|
() => 'up-1',
|
|
@@ -179,6 +179,7 @@ describe('processUpload', () => {
|
|
|
179
179
|
const res = await processUpload(
|
|
180
180
|
'sess-a',
|
|
181
181
|
'huge.bin',
|
|
182
|
+
'',
|
|
182
183
|
tooBig,
|
|
183
184
|
env,
|
|
184
185
|
(l) => lines.push(l),
|
|
@@ -198,7 +199,7 @@ describe('processUpload', () => {
|
|
|
198
199
|
await processUpload(
|
|
199
200
|
'sess-a',
|
|
200
201
|
'invoice.pdf',
|
|
201
|
-
new Uint8Array([1]),
|
|
202
|
+
'', new Uint8Array([1]),
|
|
202
203
|
env,
|
|
203
204
|
(l) => lines.push(l),
|
|
204
205
|
() => 'up-1',
|
|
@@ -217,11 +218,11 @@ describe('processUpload', () => {
|
|
|
217
218
|
describe('re-uploading the same filename', () => {
|
|
218
219
|
it('replaces the object, keeps exactly one row, and succeeds', async () => {
|
|
219
220
|
const { env, manifestRows, objects } = makeEnv()
|
|
220
|
-
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
221
|
+
await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
221
222
|
const second = await processUpload(
|
|
222
223
|
'sess-a',
|
|
223
224
|
'invoice.pdf',
|
|
224
|
-
new Uint8Array([9, 9, 9]),
|
|
225
|
+
'', new Uint8Array([9, 9, 9]),
|
|
225
226
|
env,
|
|
226
227
|
() => {},
|
|
227
228
|
() => 'up-2',
|
|
@@ -234,8 +235,8 @@ describe('processUpload', () => {
|
|
|
234
235
|
|
|
235
236
|
it('updates the row to describe the new object, not the old one', async () => {
|
|
236
237
|
const { env, manifestRows } = makeEnv()
|
|
237
|
-
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
238
|
-
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
|
|
238
|
+
await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
239
|
+
await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
|
|
239
240
|
// Column order: fileId, ownerId, filename, objectKey, size, uploadedAt, ingested
|
|
240
241
|
expect(manifestRows[0][4]).toBe(3)
|
|
241
242
|
expect(manifestRows[0][5]).toBe(new Date(2_000).toISOString())
|
|
@@ -246,9 +247,9 @@ describe('processUpload', () => {
|
|
|
246
247
|
// ingested=1, a replacement that left the flag set would never be
|
|
247
248
|
// ingested, and nothing would ever say so.
|
|
248
249
|
const { env, manifestRows } = makeEnv()
|
|
249
|
-
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
250
|
+
await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([1]), env, () => {}, () => 'up-1', () => 1_000)
|
|
250
251
|
manifestRows[0][6] = 1 // the device-side sweep marks it ingested
|
|
251
|
-
await processUpload('sess-a', 'invoice.pdf', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
|
|
252
|
+
await processUpload('sess-a', 'invoice.pdf', '', new Uint8Array([9, 9, 9]), env, () => {}, () => 'up-2', () => 2_000)
|
|
252
253
|
expect(manifestRows[0][6]).toBe(0)
|
|
253
254
|
})
|
|
254
255
|
})
|
|
@@ -210,15 +210,25 @@ dot-prefixed. These extras are added on top of the schema-derived set and, unlik
|
|
|
210
210
|
are not intersected with the `allowed-top-level` block. An empty or absent list behaves exactly as
|
|
211
211
|
before.
|
|
212
212
|
|
|
213
|
-
The
|
|
213
|
+
The admin server runs this every sixty seconds for every account carrying a `data-portal.json`
|
|
214
|
+
. It used to be an hourly spawn from the scheduling heartbeat, which had never been
|
|
215
|
+
observed running on a device and left the client's tree up to an hour stale. Run it once by hand
|
|
214
216
|
after assembly so the client has something to see on their first sign-in, and check the output names
|
|
215
217
|
the folders you expect:
|
|
216
218
|
|
|
217
219
|
```
|
|
218
220
|
[portal-index] op=resolve account=<id> schemaPresent=true allowed=22 domainBuckets=quotes extraFolders=1 exposed=inbound-invoices,output,quotes
|
|
221
|
+
[portal-index] op=stage account=<id> generation=7 inserted=41
|
|
222
|
+
[portal-index] op=flip account=<id> generation=7
|
|
219
223
|
[portal-index] op=verify account=<id> rowsAfter=41 expected=41
|
|
220
224
|
```
|
|
221
225
|
|
|
226
|
+
The publish stages every row at the next generation, moves a pointer in one statement, then sweeps
|
|
227
|
+
the superseded rows. That flip is the atomic point: a client listing mid-publish reads the old tree
|
|
228
|
+
or the new one and never a half-built one. Before this the replace was delete-then-insert, so the
|
|
229
|
+
tree was empty for the length of the insert run, which was tolerable hourly and is not at sixty
|
|
230
|
+
seconds.
|
|
231
|
+
|
|
222
232
|
`exposed=none` on an account that has deliverables means the schema was missing or its ontology
|
|
223
233
|
region was absent — not that the walk found nothing. `extraFolders=<n>` counts the folders the
|
|
224
234
|
operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in
|
|
@@ -233,14 +243,15 @@ upload icon. Filenames render on one line: the stem truncates with an ellipsis
|
|
|
233
243
|
and the extension stays pinned, so `.pdf` and `.html` on two same-named orders
|
|
234
244
|
are still told apart.
|
|
235
245
|
|
|
236
|
-
Refresh re-reads the index in the folder that is already open
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
Refresh re-reads the index in the folder that is already open. The page also
|
|
247
|
+
re-reads itself every sixty seconds while its tab is visible, which is what
|
|
248
|
+
makes a file the device published appear without the client clicking anything.
|
|
249
|
+
That poll was deliberately absent while the push was hourly, when it would have
|
|
250
|
+
been waste; it arrived with the sixty-second push. It is skipped for a hidden
|
|
251
|
+
tab and while a read is already in flight, so a background tab costs nothing and
|
|
252
|
+
a slow response cannot stack requests. The refresh button is inert while its own
|
|
253
|
+
request is in flight, and the status line clears on success or keeps the failure
|
|
254
|
+
message on screen.
|
|
244
255
|
|
|
245
256
|
Delete and upload both re-read the list when they finish. If that re-read
|
|
246
257
|
fails, the status keeps the outcome and adds to it, so a client reads
|
|
@@ -256,11 +267,38 @@ saves the file rather than a JSON body. The three download methods share the one
|
|
|
256
267
|
`op=fetch-through` log line, so a broken method shows as a missing line under the
|
|
257
268
|
same signal.
|
|
258
269
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
A client's own uploaded file drags out the same way, so both lists behave alike
|
|
271
|
+
rather than only the device-held one supporting it.
|
|
272
|
+
|
|
273
|
+
**Uploads go to a folder the client chooses.** Dropping files onto a
|
|
274
|
+
folder row sends them into that folder; dropping onto the open listing sends
|
|
275
|
+
them into the folder currently open; the modal states which folder it will use.
|
|
276
|
+
An upload with no folder chosen lands in the client's own uploads area, which
|
|
277
|
+
stays unexposed to the folder tree. The client still sees and downloads it from
|
|
278
|
+
their own list, through a signed link scoped to them.
|
|
279
|
+
|
|
280
|
+
A legal target is one the client can already see: it must pass their folder
|
|
281
|
+
grant and carry a row in the published index. The device re-derives that set
|
|
282
|
+
itself and refuses anything outside it, so a stale index or a compromised Pages
|
|
283
|
+
project cannot route a file into a folder the device does not expose. A refused
|
|
284
|
+
target leaves the file pending and logs the exact reason; it is never quietly
|
|
285
|
+
rerouted somewhere the client did not choose.
|
|
286
|
+
|
|
287
|
+
Several files may be dropped or picked at once and upload one after another.
|
|
288
|
+
Anything over 25 MiB is sliced into parts against a multipart session, up to
|
|
289
|
+
1 GB, with a progress bar; a file over the limit is refused before any bytes are
|
|
290
|
+
sent. Failures now read differently by cause: an expired session, a folder that
|
|
291
|
+
is not allowed, a file that is too large, and an unreachable server are four
|
|
292
|
+
messages, not one.
|
|
293
|
+
|
|
294
|
+
Once the device has collected an upload it deletes the portal's copy, so the
|
|
295
|
+
file lives in one place rather than two. A targeted file then appears in its
|
|
296
|
+
folder rather than in the uploads list; an untargeted one stays in the uploads
|
|
297
|
+
list and downloads through the device. Deleting a file the device already
|
|
298
|
+
collected removes it from the device too, within a minute; the client is told it
|
|
299
|
+
is pending rather than gone, because for that minute it is.
|
|
300
|
+
|
|
301
|
+
The modal dismisses on the close button, a click on the backdrop, or Escape.
|
|
264
302
|
|
|
265
303
|
## Deploy through site-deploy
|
|
266
304
|
|
|
@@ -58,10 +58,40 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
58
58
|
|
|
59
59
|
CREATE INDEX IF NOT EXISTS sessions_owner ON sessions (ownerId);
|
|
60
60
|
|
|
61
|
-
-- One row per stored object
|
|
62
|
-
--
|
|
63
|
-
--
|
|
64
|
-
--
|
|
61
|
+
-- One row per stored object, carrying a three-state lifecycle (Task 1910).
|
|
62
|
+
--
|
|
63
|
+
-- in transit ingested=0, devicePath='' bytes in R2
|
|
64
|
+
-- moved ingested=1, devicePath set bytes on the device, R2 object gone
|
|
65
|
+
-- withdrawn deleted=1 device file pending removal
|
|
66
|
+
--
|
|
67
|
+
-- `ingested` is the claim flag the admin server's `portal-uploads-pull` loop
|
|
68
|
+
-- reads and sets; a row stays 0 until its object is on the device at the size
|
|
69
|
+
-- recorded here, so the backlog survives the device being offline and a
|
|
70
|
+
-- half-finished transfer never reads as delivered.
|
|
71
|
+
--
|
|
72
|
+
-- `targetPath` is the exposed folder the client chose at upload, '' when they
|
|
73
|
+
-- chose none. It is part of `objectKey` (ownerId/targetPath/filename), because
|
|
74
|
+
-- a flat key collides the same filename dropped into two folders: the upsert
|
|
75
|
+
-- below would overwrite the first object, reset ingested, and the first file
|
|
76
|
+
-- would never land on the device at all.
|
|
77
|
+
--
|
|
78
|
+
-- `devicePath` is written only after a verified device write, so its presence
|
|
79
|
+
-- IS the proof the move happened. A boolean would say a move occurred without
|
|
80
|
+
-- saying where, and the audit's unpublishedRouted check needs the path to
|
|
81
|
+
-- compare against the published index.
|
|
82
|
+
--
|
|
83
|
+
-- `deleted` marks a withdrawal of an already-moved file, whose bytes are on the
|
|
84
|
+
-- device and whose R2 object is already gone. The row is removed outright once
|
|
85
|
+
-- the device file is confirmed absent — not kept as a tombstone, which would
|
|
86
|
+
-- grow this table without bound and force every listing read to filter it while
|
|
87
|
+
-- describing bytes that no longer exist anywhere.
|
|
88
|
+
--
|
|
89
|
+
-- IF NOT EXISTS adds no column to an existing table. A portal deployed before
|
|
90
|
+
-- this needs the three run by hand, once:
|
|
91
|
+
-- ALTER TABLE manifest ADD COLUMN targetPath TEXT NOT NULL DEFAULT '';
|
|
92
|
+
-- ALTER TABLE manifest ADD COLUMN devicePath TEXT NOT NULL DEFAULT '';
|
|
93
|
+
-- ALTER TABLE manifest ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
|
|
94
|
+
-- Their absence is loud: upload names targetPath in its INSERT.
|
|
65
95
|
CREATE TABLE IF NOT EXISTS manifest (
|
|
66
96
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
97
|
fileId TEXT UNIQUE NOT NULL,
|
|
@@ -70,11 +100,17 @@ CREATE TABLE IF NOT EXISTS manifest (
|
|
|
70
100
|
objectKey TEXT UNIQUE NOT NULL,
|
|
71
101
|
size INTEGER NOT NULL,
|
|
72
102
|
uploadedAt TEXT NOT NULL,
|
|
73
|
-
ingested INTEGER NOT NULL DEFAULT 0
|
|
103
|
+
ingested INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
targetPath TEXT NOT NULL DEFAULT '',
|
|
105
|
+
devicePath TEXT NOT NULL DEFAULT '',
|
|
106
|
+
deleted INTEGER NOT NULL DEFAULT 0
|
|
74
107
|
);
|
|
75
108
|
|
|
76
109
|
CREATE INDEX IF NOT EXISTS manifest_uningested ON manifest (ingested);
|
|
77
110
|
CREATE INDEX IF NOT EXISTS manifest_owner ON manifest (ownerId);
|
|
111
|
+
-- The pull selects `ingested = 0 OR deleted = 1`, so the withdrawal half needs
|
|
112
|
+
-- its own index or a withdrawal costs a full scan every sixty seconds.
|
|
113
|
+
CREATE INDEX IF NOT EXISTS manifest_deleted ON manifest (deleted);
|
|
78
114
|
|
|
79
115
|
-- Fixed-window auth attempt counter. Pages Functions are stateless, so the
|
|
80
116
|
-- counter cannot live in process memory the way every other rate limiter in
|
|
@@ -111,7 +147,14 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (s
|
|
|
111
147
|
-- its INSERT, so such a table fails loudly on the next cycle. Add it by hand in
|
|
112
148
|
-- that case:
|
|
113
149
|
-- ALTER TABLE directory ADD COLUMN isDir INTEGER NOT NULL DEFAULT 0;
|
|
114
|
-
-- No backfill follows it: the push
|
|
150
|
+
-- No backfill follows it: the push replaces every row each cycle.
|
|
151
|
+
--
|
|
152
|
+
-- `generation` is which staging round a row belongs to (Task 1910). The push
|
|
153
|
+
-- writes the next generation, flips the pointer below in ONE statement, then
|
|
154
|
+
-- deletes rows of earlier generations. Uniqueness is scoped by generation
|
|
155
|
+
-- because two of them hold the same relPath between the write and the flip;
|
|
156
|
+
-- UNIQUE (accountId, relPath) would reject the staged write outright.
|
|
157
|
+
-- ALTER TABLE directory ADD COLUMN generation INTEGER NOT NULL DEFAULT 0;
|
|
115
158
|
CREATE TABLE IF NOT EXISTS directory (
|
|
116
159
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
117
160
|
accountId TEXT NOT NULL,
|
|
@@ -120,7 +163,27 @@ CREATE TABLE IF NOT EXISTS directory (
|
|
|
120
163
|
modifiedAt TEXT NOT NULL,
|
|
121
164
|
indexedAt TEXT NOT NULL,
|
|
122
165
|
isDir INTEGER NOT NULL DEFAULT 0,
|
|
123
|
-
|
|
166
|
+
generation INTEGER NOT NULL DEFAULT 0,
|
|
167
|
+
UNIQUE (accountId, relPath, generation)
|
|
124
168
|
);
|
|
125
169
|
|
|
126
170
|
CREATE INDEX IF NOT EXISTS directory_account ON directory (accountId);
|
|
171
|
+
|
|
172
|
+
-- Which generation of an account's rows is the live one.
|
|
173
|
+
--
|
|
174
|
+
-- The flip of this single row is the atomic point of a publish: a client
|
|
175
|
+
-- listing that races a push reads the old tree or the new one, and never a
|
|
176
|
+
-- half-built one. Cleanup of superseded rows afterwards need not be atomic,
|
|
177
|
+
-- because nothing reads a generation this pointer does not name.
|
|
178
|
+
--
|
|
179
|
+
-- This replaces the delete-then-insert window recorded as backlog Task 1842.
|
|
180
|
+
-- At sixty-second cadence that window would be hit sixty times more often than
|
|
181
|
+
-- it was hourly, which is what turned a known defect into a blocking one.
|
|
182
|
+
--
|
|
183
|
+
-- An account with no row here has never completed a publish, and every reader
|
|
184
|
+
-- joins through this table, so it correctly shows nothing rather than showing
|
|
185
|
+
-- a staged generation that no flip ever blessed.
|
|
186
|
+
CREATE TABLE IF NOT EXISTS directory_state (
|
|
187
|
+
accountId TEXT PRIMARY KEY,
|
|
188
|
+
currentGeneration INTEGER NOT NULL
|
|
189
|
+
);
|
|
@@ -25,6 +25,25 @@ function message(accountId, relPath, expiresAtMs) {
|
|
|
25
25
|
return `${accountId}\n${relPath}\n${expiresAtMs}`
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The own-upload message (Task 1910).
|
|
30
|
+
*
|
|
31
|
+
* A SECOND shape rather than an extended first one, for two reasons.
|
|
32
|
+
*
|
|
33
|
+
* The literal `uploads` discriminator is what stops an exposed-folder signature
|
|
34
|
+
* ever admitting an uploads path, so the owner binding is load-bearing rather
|
|
35
|
+
* than decorative. And the two ends redeploy at different times — the device
|
|
36
|
+
* through an installer publish, the portal through site-deploy — so a single
|
|
37
|
+
* extended message would break every existing download in the window between
|
|
38
|
+
* them. An already-deployed portal never mints this type, so nothing regresses.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} accountId @param {string} ownerId @param {string} relPath
|
|
41
|
+
* @param {number} expiresAtMs
|
|
42
|
+
*/
|
|
43
|
+
function ownUploadMessage(accountId, ownerId, relPath, expiresAtMs) {
|
|
44
|
+
return `${accountId}\nuploads\n${ownerId}\n${relPath}\n${expiresAtMs}`
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
/** @param {string} secret */
|
|
29
48
|
async function keyFor(secret) {
|
|
30
49
|
return crypto.subtle.importKey(
|
|
@@ -85,3 +104,58 @@ export async function buildSignedUrl(origin, secret, accountId, relPath, expires
|
|
|
85
104
|
url.searchParams.set('sig', sig)
|
|
86
105
|
return url.toString()
|
|
87
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sign one own-upload path. Lowercase hex, same algorithm as signPath — only
|
|
110
|
+
* the message differs.
|
|
111
|
+
* @param {string} secret @param {string} accountId @param {string} ownerId
|
|
112
|
+
* @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>}
|
|
113
|
+
*/
|
|
114
|
+
export async function signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs) {
|
|
115
|
+
const key = await keyFor(secret)
|
|
116
|
+
const mac = await crypto.subtle.sign(
|
|
117
|
+
'HMAC',
|
|
118
|
+
key,
|
|
119
|
+
new TextEncoder().encode(ownUploadMessage(accountId, ownerId, relPath, expiresAtMs)),
|
|
120
|
+
)
|
|
121
|
+
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} secret @param {string} accountId @param {string} ownerId
|
|
126
|
+
* @param {string} relPath @param {number} expiresAtMs @param {string} signature
|
|
127
|
+
* @param {number} nowMs @returns {Promise<boolean>}
|
|
128
|
+
*/
|
|
129
|
+
export async function verifyOwnUpload(
|
|
130
|
+
secret,
|
|
131
|
+
accountId,
|
|
132
|
+
ownerId,
|
|
133
|
+
relPath,
|
|
134
|
+
expiresAtMs,
|
|
135
|
+
signature,
|
|
136
|
+
nowMs,
|
|
137
|
+
) {
|
|
138
|
+
if (!secret || !signature) return false
|
|
139
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
|
|
140
|
+
const expected = await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs)
|
|
141
|
+
return constantTimeEqual(expected, signature)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The owner travels as a query parameter as well as inside the HMAC: the device
|
|
146
|
+
* needs to know which folder is being claimed before it can verify the claim.
|
|
147
|
+
* The parameter is never trusted on its own — a mismatch simply fails to verify.
|
|
148
|
+
* @param {string} origin @param {string} secret @param {string} accountId
|
|
149
|
+
* @param {string} ownerId @param {string} relPath @param {number} expiresAtMs
|
|
150
|
+
* @returns {Promise<string>}
|
|
151
|
+
*/
|
|
152
|
+
export async function buildOwnUploadUrl(origin, secret, accountId, ownerId, relPath, expiresAtMs) {
|
|
153
|
+
const sig = await signOwnUpload(secret, accountId, ownerId, relPath, expiresAtMs)
|
|
154
|
+
const url = new URL('/api/portal/fetch', origin)
|
|
155
|
+
url.searchParams.set('accountId', accountId)
|
|
156
|
+
url.searchParams.set('owner', ownerId)
|
|
157
|
+
url.searchParams.set('path', relPath)
|
|
158
|
+
url.searchParams.set('exp', String(expiresAtMs))
|
|
159
|
+
url.searchParams.set('sig', sig)
|
|
160
|
+
return url.toString()
|
|
161
|
+
}
|