@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,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { processDownload } from '../../skills/data-portal/template/functions/api/download'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
const ACC = '098a18a3-1741-447f-9778-65470645d57d'
|
|
6
|
+
|
|
7
|
+
function harness(row: { devicePath: string } | null, opts: { configured?: boolean } = {}) {
|
|
8
|
+
const configured = opts.configured ?? true
|
|
9
|
+
const lines: string[] = []
|
|
10
|
+
const env = {
|
|
11
|
+
BUCKET: {
|
|
12
|
+
async get(key: string) {
|
|
13
|
+
// Only an in-transit object exists. A moved row's bytes are gone from
|
|
14
|
+
// R2 entirely, which is what makes reading them here the wrong move.
|
|
15
|
+
return row && !row.devicePath ? { body: null, size: 4, key } : null
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
DB: {
|
|
19
|
+
prepare(sql: string) {
|
|
20
|
+
return {
|
|
21
|
+
bind() {
|
|
22
|
+
return {
|
|
23
|
+
async first() {
|
|
24
|
+
if (sql.includes('FROM sessions')) {
|
|
25
|
+
return { ownerId: 'alice', accountId: ACC, folders: '' }
|
|
26
|
+
}
|
|
27
|
+
if (sql.includes('devicePath')) return row
|
|
28
|
+
return null
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
PORTAL_FETCH_SECRET: configured ? 'test-secret-value' : undefined,
|
|
36
|
+
PORTAL_INSTALL_ORIGIN: configured ? 'https://install.example' : undefined,
|
|
37
|
+
} as unknown as PortalEnv
|
|
38
|
+
return { env, lines, log: (l: string) => lines.push(l) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('downloading an upload that has moved to the device', () => {
|
|
42
|
+
it('streams R2 bytes while the row is still in transit', async () => {
|
|
43
|
+
const h = harness({ devicePath: '' })
|
|
44
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
45
|
+
expect(res.status).toBe(200)
|
|
46
|
+
expect(res.body).not.toBeNull()
|
|
47
|
+
expect(res.url).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('mints an own-upload link once the row has moved', async () => {
|
|
51
|
+
// The object is gone from R2. Without this branch the client's own upload
|
|
52
|
+
// would 404 the moment the device collected it.
|
|
53
|
+
const h = harness({ devicePath: 'uploads/alice/a.pdf' })
|
|
54
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
55
|
+
expect(res.status).toBe(200)
|
|
56
|
+
expect(res.url).toContain('/api/portal/fetch')
|
|
57
|
+
expect(new URL(res.url!).searchParams.get('owner')).toBe('alice')
|
|
58
|
+
expect(new URL(res.url!).searchParams.get('path')).toBe('uploads/alice/a.pdf')
|
|
59
|
+
expect(h.lines.some((l) => l.includes('op=download') && l.includes('result=moved'))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('refuses another person key before anything is minted', async () => {
|
|
63
|
+
const h = harness({ devicePath: 'uploads/bob/a.pdf' })
|
|
64
|
+
const res = await processDownload('s', 'bob/a.pdf', h.env, h.log, 1000)
|
|
65
|
+
expect(res.status).toBe(403)
|
|
66
|
+
expect(res.url).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports device-offline rather than a server error when unconfigured', async () => {
|
|
70
|
+
// Same reasoning as the indexed path: from the client's side the file
|
|
71
|
+
// genuinely is unreachable, and the operator's signal is the log line.
|
|
72
|
+
const h = harness({ devicePath: 'uploads/alice/a.pdf' }, { configured: false })
|
|
73
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
74
|
+
expect(res.status).toBe(503)
|
|
75
|
+
expect(h.lines.some((l) => l.includes('result=unconfigured'))).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reports absent for a key with no manifest row at all', async () => {
|
|
79
|
+
const h = harness(null)
|
|
80
|
+
const res = await processDownload('s', 'alice/gone.pdf', h.env, h.log, 1000)
|
|
81
|
+
expect(res.status).toBe(404)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
// The schema is applied by the skill, not by any code this suite can call, so
|
|
6
|
+
// its shape is asserted textually. A missing column here is not a test failure
|
|
7
|
+
// in some other file — it is an upload naming a column D1 does not have.
|
|
8
|
+
const SQL = readFileSync(
|
|
9
|
+
join(__dirname, '../../skills/data-portal/schema.sql'),
|
|
10
|
+
'utf8',
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
describe('portal schema shape', () => {
|
|
14
|
+
it('carries the manifest lifecycle columns', () => {
|
|
15
|
+
expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
16
|
+
expect(SQL).toMatch(/devicePath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
17
|
+
expect(SQL).toMatch(/deleted\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('carries the directory generation and its pointer table', () => {
|
|
21
|
+
expect(SQL).toMatch(/generation\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
|
|
22
|
+
expect(SQL).toMatch(/CREATE TABLE IF NOT EXISTS directory_state/)
|
|
23
|
+
expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('keeps objectKey UNIQUE, which the upsert conflict target still needs', () => {
|
|
27
|
+
// objectKey is now ownerId/targetPath/filename. The upsert still conflicts
|
|
28
|
+
// on it, so this pins that the constraint was not dropped by accident while
|
|
29
|
+
// the key shape changed underneath it.
|
|
30
|
+
expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('scopes the directory uniqueness by generation', () => {
|
|
34
|
+
// Two generations hold the same relPath at once between the write and the
|
|
35
|
+
// flip. UNIQUE (accountId, relPath) would reject the second write and the
|
|
36
|
+
// push could never stage a new generation at all.
|
|
37
|
+
expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*relPath,\s*generation\s*\)/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('documents the hand-run ALTER for every added column', () => {
|
|
41
|
+
// IF NOT EXISTS adds no column to an existing table, so a portal deployed
|
|
42
|
+
// before this needs each one by hand. An undocumented column is an operator
|
|
43
|
+
// reading "no such column" with nothing telling them what to run.
|
|
44
|
+
for (const col of ['targetPath', 'devicePath', 'deleted', 'generation']) {
|
|
45
|
+
expect(SQL).toMatch(new RegExp(`ALTER TABLE \\w+ ADD COLUMN ${col}\\b`))
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
signPath,
|
|
5
5
|
verifyPath,
|
|
6
6
|
buildSignedUrl,
|
|
7
|
+
signOwnUpload,
|
|
8
|
+
verifyOwnUpload,
|
|
9
|
+
buildOwnUploadUrl,
|
|
7
10
|
} from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
|
|
8
11
|
|
|
9
12
|
const SECRET = 'test-secret-value'
|
|
@@ -66,3 +69,62 @@ describe('portal-sign', () => {
|
|
|
66
69
|
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
67
70
|
})
|
|
68
71
|
})
|
|
72
|
+
|
|
73
|
+
// The second link type (Task 1910). An untargeted upload moves to
|
|
74
|
+
// `uploads/<ownerId>/`, which the exposed set never contains, so the ordinary
|
|
75
|
+
// signature cannot reach it and the device has no way to know whose folder is
|
|
76
|
+
// being asked for. Binding the owner INSIDE the HMAC is what makes that safe.
|
|
77
|
+
describe('own-upload signatures', () => {
|
|
78
|
+
const OWNER = 'alice'
|
|
79
|
+
const PATH = 'uploads/alice/invoice.pdf'
|
|
80
|
+
|
|
81
|
+
it('verifies a link minted for the same owner', async () => {
|
|
82
|
+
const exp = NOW + 1000
|
|
83
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
84
|
+
expect(await verifyOwnUpload(SECRET, ACC, OWNER, PATH, exp, sig, NOW)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('refuses the same path claimed under a different owner', async () => {
|
|
88
|
+
// The whole point: bob must not be able to present alice's link, and a
|
|
89
|
+
// portal must not be able to mint one into her folder.
|
|
90
|
+
const exp = NOW + 1000
|
|
91
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
92
|
+
expect(await verifyOwnUpload(SECRET, ACC, 'bob', PATH, exp, sig, NOW)).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is a DIFFERENT message from the exposed-folder signature', async () => {
|
|
96
|
+
// If the two shapes collided, an ordinary signed link would admit an
|
|
97
|
+
// uploads path and the owner binding would be decorative.
|
|
98
|
+
const exp = NOW + 1000
|
|
99
|
+
const own = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
100
|
+
const plain = await signPath(SECRET, ACC, PATH, exp)
|
|
101
|
+
expect(own).not.toBe(plain)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not verify under the ordinary scheme', async () => {
|
|
105
|
+
const exp = NOW + 1000
|
|
106
|
+
const own = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
107
|
+
expect(await verifyPath(SECRET, ACC, PATH, exp, own, NOW)).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('refuses an expired link', async () => {
|
|
111
|
+
const exp = NOW - 1
|
|
112
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
113
|
+
expect(await verifyOwnUpload(SECRET, ACC, OWNER, PATH, exp, sig, NOW)).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses a wrong secret', async () => {
|
|
117
|
+
const exp = NOW + 1000
|
|
118
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
119
|
+
expect(await verifyOwnUpload('other-secret', ACC, OWNER, PATH, exp, sig, NOW)).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('carries the owner as a query parameter so the device can name it', async () => {
|
|
123
|
+
const raw = await buildOwnUploadUrl('https://install.example', SECRET, ACC, OWNER, PATH, NOW + 1000)
|
|
124
|
+
const url = new URL(raw)
|
|
125
|
+
expect(url.pathname).toBe('/api/portal/fetch')
|
|
126
|
+
expect(url.searchParams.get('owner')).toBe(OWNER)
|
|
127
|
+
expect(url.searchParams.get('path')).toBe(PATH)
|
|
128
|
+
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
checkTarget,
|
|
4
|
+
isSafeTargetSegment,
|
|
5
|
+
uploadKey,
|
|
6
|
+
} from '../../skills/data-portal/template/functions/api/_lib/target'
|
|
7
|
+
|
|
8
|
+
/** A D1 fake whose `first()` answers from a set of published folder rows. It
|
|
9
|
+
* models the real query's constraint rather than its happy path: a row is a
|
|
10
|
+
* folder only when isDir = 1, so a fake that ignored the filter would let a
|
|
11
|
+
* file path pass as an upload target. */
|
|
12
|
+
function fakeDb(folders: string[]) {
|
|
13
|
+
return {
|
|
14
|
+
prepare(sql: string) {
|
|
15
|
+
return {
|
|
16
|
+
bind(_accountId: string, relPath: string) {
|
|
17
|
+
return {
|
|
18
|
+
async first() {
|
|
19
|
+
if (!sql.includes('isDir = 1')) throw new Error('query must filter isDir')
|
|
20
|
+
return folders.includes(relPath) ? { relPath } : null
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
} as never
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Any query at all is a failure for the cases that must refuse before the
|
|
30
|
+
* index is read. Refusing early is not an optimisation here: it is what keeps
|
|
31
|
+
* an out-of-grant probe from costing a database round trip. */
|
|
32
|
+
const noQueryDb = {
|
|
33
|
+
prepare() {
|
|
34
|
+
throw new Error('must not query')
|
|
35
|
+
},
|
|
36
|
+
} as never
|
|
37
|
+
|
|
38
|
+
describe('isSafeTargetSegment', () => {
|
|
39
|
+
it('rejects traversal, separators, empties and NUL', () => {
|
|
40
|
+
for (const bad of ['', '.', '..', 'a/b', 'a\\b', 'a\0b']) {
|
|
41
|
+
expect(isSafeTargetSegment(bad)).toBe(false)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('accepts an ordinary folder name', () => {
|
|
46
|
+
expect(isSafeTargetSegment('jobs')).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('uploadKey', () => {
|
|
51
|
+
it('collapses to owner/filename when untargeted', () => {
|
|
52
|
+
expect(uploadKey('alice', '', 'a.pdf')).toBe('alice/a.pdf')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('carries the target so two folders never collide', () => {
|
|
56
|
+
expect(uploadKey('alice', 'jobs', 'a.pdf')).toBe('alice/jobs/a.pdf')
|
|
57
|
+
expect(uploadKey('alice', 'jobs/2026', 'a.pdf')).toBe('alice/jobs/2026/a.pdf')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('checkTarget', () => {
|
|
62
|
+
it('admits the empty target without touching the index', async () => {
|
|
63
|
+
expect(await checkTarget(noQueryDb, 'acct', [], '')).toEqual({ ok: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('admits a granted folder that the index publishes', async () => {
|
|
67
|
+
expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'jobs')).toEqual({ ok: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('admits a nested path under a granted top-level folder', async () => {
|
|
71
|
+
expect(await checkTarget(fakeDb(['jobs/2026']), 'acct', ['jobs'], 'jobs/2026')).toEqual({
|
|
72
|
+
ok: true,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('refuses a folder outside the grant before reading the index', async () => {
|
|
77
|
+
expect(await checkTarget(noQueryDb, 'acct', ['output'], 'jobs')).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: 'grant',
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('refuses a folder the index does not publish', async () => {
|
|
84
|
+
expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'output')).toEqual({
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: 'index',
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('refuses a traversal segment before any lookup', async () => {
|
|
91
|
+
expect(await checkTarget(noQueryDb, 'acct', [], 'jobs/../secrets')).toEqual({
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: 'segment',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('refuses a trailing separator, which is an empty segment', async () => {
|
|
98
|
+
expect(await checkTarget(noQueryDb, 'acct', [], 'jobs/')).toEqual({
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: 'segment',
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
MAX_PART_BYTES,
|
|
4
|
+
MAX_TOTAL_BYTES,
|
|
5
|
+
processMultipartComplete,
|
|
6
|
+
processMultipartCreate,
|
|
7
|
+
processMultipartPart,
|
|
8
|
+
} from '../../skills/data-portal/template/functions/api/upload'
|
|
9
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
10
|
+
|
|
11
|
+
function harness(opts: { folders?: string[] } = {}) {
|
|
12
|
+
const folders = opts.folders ?? ['jobs']
|
|
13
|
+
const uploads = new Map<string, { key: string; parts: Map<number, number>; aborted: boolean }>()
|
|
14
|
+
const objects = new Map<string, number>()
|
|
15
|
+
const rows = new Map<string, Record<string, unknown>>()
|
|
16
|
+
const lines: string[] = []
|
|
17
|
+
let seq = 0
|
|
18
|
+
|
|
19
|
+
function session(id: string) {
|
|
20
|
+
const rec = uploads.get(id)!
|
|
21
|
+
return {
|
|
22
|
+
uploadId: id,
|
|
23
|
+
async uploadPart(n: number, body: Uint8Array) {
|
|
24
|
+
rec.parts.set(n, body.length)
|
|
25
|
+
return { partNumber: n, etag: `etag-${n}` }
|
|
26
|
+
},
|
|
27
|
+
async complete(parts: { partNumber: number; etag: string }[]) {
|
|
28
|
+
let total = 0
|
|
29
|
+
for (const p of parts) total += rec.parts.get(p.partNumber) ?? 0
|
|
30
|
+
objects.set(rec.key, total)
|
|
31
|
+
return { size: total }
|
|
32
|
+
},
|
|
33
|
+
async abort() {
|
|
34
|
+
rec.aborted = true
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const env = {
|
|
40
|
+
BUCKET: {
|
|
41
|
+
async createMultipartUpload(key: string) {
|
|
42
|
+
const id = `r2-${++seq}`
|
|
43
|
+
uploads.set(id, { key, parts: new Map(), aborted: false })
|
|
44
|
+
return session(id)
|
|
45
|
+
},
|
|
46
|
+
resumeMultipartUpload(_key: string, id: string) {
|
|
47
|
+
return session(id)
|
|
48
|
+
},
|
|
49
|
+
async head(key: string) {
|
|
50
|
+
const s = objects.get(key)
|
|
51
|
+
return s === undefined ? null : { size: s }
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
DB: {
|
|
55
|
+
prepare(sql: string) {
|
|
56
|
+
return {
|
|
57
|
+
bind(...args: unknown[]) {
|
|
58
|
+
return {
|
|
59
|
+
async first() {
|
|
60
|
+
if (sql.includes('FROM sessions')) {
|
|
61
|
+
return { ownerId: 'alice', accountId: 'acct', folders: '' }
|
|
62
|
+
}
|
|
63
|
+
if (sql.includes('isDir = 1')) {
|
|
64
|
+
return folders.includes(String(args[1])) ? { relPath: args[1] } : null
|
|
65
|
+
}
|
|
66
|
+
if (sql.includes('SELECT fileId FROM manifest')) {
|
|
67
|
+
return rows.get(String(args[0])) ?? null
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
},
|
|
71
|
+
async run() {
|
|
72
|
+
rows.set(String(args[3]), { fileId: args[0], size: args[4] })
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
} as unknown as PortalEnv
|
|
80
|
+
|
|
81
|
+
let n = 0
|
|
82
|
+
return {
|
|
83
|
+
env, uploads, objects, rows, lines,
|
|
84
|
+
newId: () => `uid-${++n}`,
|
|
85
|
+
log: (l: string) => lines.push(l),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('multipart create', () => {
|
|
90
|
+
it('refuses a declared size over the total cap without opening a session', async () => {
|
|
91
|
+
// Checked before createMultipartUpload: an over-cap declaration must not
|
|
92
|
+
// leave an orphan multipart session accruing storage in R2 forever.
|
|
93
|
+
const h = harness()
|
|
94
|
+
const res = await processMultipartCreate(
|
|
95
|
+
's', 'big.zip', '', MAX_TOTAL_BYTES + 1, h.env, h.log, h.newId, () => 1,
|
|
96
|
+
)
|
|
97
|
+
expect(res.status).toBe(413)
|
|
98
|
+
expect(h.uploads.size).toBe(0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('opens a session under the cap and returns its ids', async () => {
|
|
102
|
+
const h = harness()
|
|
103
|
+
const res = await processMultipartCreate(
|
|
104
|
+
's', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1,
|
|
105
|
+
)
|
|
106
|
+
expect(res.status).toBe(200)
|
|
107
|
+
expect(res.payload.r2UploadId).toBe('r2-1')
|
|
108
|
+
expect(res.payload.key).toBe('alice/big.zip')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('applies the same target rule as a single-shot upload', async () => {
|
|
112
|
+
const h = harness({ folders: [] })
|
|
113
|
+
const res = await processMultipartCreate(
|
|
114
|
+
's', 'big.zip', 'jobs', 1024, h.env, h.log, h.newId, () => 1,
|
|
115
|
+
)
|
|
116
|
+
expect(res.status).toBe(403)
|
|
117
|
+
expect(h.uploads.size).toBe(0)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keys a targeted session under its folder', async () => {
|
|
121
|
+
const h = harness()
|
|
122
|
+
const res = await processMultipartCreate(
|
|
123
|
+
's', 'big.zip', 'jobs', 1024, h.env, h.log, h.newId, () => 1,
|
|
124
|
+
)
|
|
125
|
+
expect(res.payload.key).toBe('alice/jobs/big.zip')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('multipart part', () => {
|
|
130
|
+
it('refuses a part over the per-part gate', async () => {
|
|
131
|
+
const h = harness()
|
|
132
|
+
await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
|
|
133
|
+
const res = await processMultipartPart(
|
|
134
|
+
's', 'alice/big.zip', 'r2-1', 1, new Uint8Array(MAX_PART_BYTES + 1), h.env, h.log,
|
|
135
|
+
)
|
|
136
|
+
expect(res.status).toBe(413)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('refuses a part whose key is not the caller own', async () => {
|
|
140
|
+
// The key arrives on EVERY part request, so it is re-authorized every time.
|
|
141
|
+
// A session that may write alice/ must not be able to append to bob/.
|
|
142
|
+
const h = harness()
|
|
143
|
+
const res = await processMultipartPart(
|
|
144
|
+
's', 'bob/big.zip', 'r2-1', 1, new Uint8Array(4), h.env, h.log,
|
|
145
|
+
)
|
|
146
|
+
expect(res.status).toBe(403)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('accepts a part within the gate and returns its etag', async () => {
|
|
150
|
+
const h = harness()
|
|
151
|
+
await processMultipartCreate('s', 'big.zip', '', 200 * 1024 * 1024, h.env, h.log, h.newId, () => 1)
|
|
152
|
+
const res = await processMultipartPart(
|
|
153
|
+
's', 'alice/big.zip', 'r2-1', 1, new Uint8Array(8), h.env, h.log,
|
|
154
|
+
)
|
|
155
|
+
expect(res.status).toBe(200)
|
|
156
|
+
expect(res.payload.etag).toBe('etag-1')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('multipart complete', () => {
|
|
161
|
+
it('assembles the object and writes one manifest row', async () => {
|
|
162
|
+
const h = harness()
|
|
163
|
+
await processMultipartCreate('s', 'big.zip', '', 100, h.env, h.log, h.newId, () => 1)
|
|
164
|
+
await processMultipartPart('s', 'alice/big.zip', 'r2-1', 1, new Uint8Array(60), h.env, h.log)
|
|
165
|
+
await processMultipartPart('s', 'alice/big.zip', 'r2-1', 2, new Uint8Array(40), h.env, h.log)
|
|
166
|
+
const res = await processMultipartComplete(
|
|
167
|
+
's', 'alice/big.zip', 'r2-1', '',
|
|
168
|
+
[{ partNumber: 1, etag: 'etag-1' }, { partNumber: 2, etag: 'etag-2' }],
|
|
169
|
+
h.env, h.log, h.newId, () => 1,
|
|
170
|
+
)
|
|
171
|
+
expect(res.status).toBe(200)
|
|
172
|
+
expect(h.objects.get('alice/big.zip')).toBe(100)
|
|
173
|
+
expect(h.rows.size).toBe(1)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('aborts rather than assembling when the summed parts exceed the cap', async () => {
|
|
177
|
+
// The declared size is a claim; the summed parts are the measurement. A
|
|
178
|
+
// client that under-declares must not get a 1 GiB+ object stored anyway.
|
|
179
|
+
const h = harness()
|
|
180
|
+
await processMultipartCreate('s', 'big.zip', '', 100, h.env, h.log, h.newId, () => 1)
|
|
181
|
+
const res = await processMultipartComplete(
|
|
182
|
+
's', 'alice/big.zip', 'r2-1', '',
|
|
183
|
+
[{ partNumber: 1, etag: 'e', size: MAX_TOTAL_BYTES }, { partNumber: 2, etag: 'e', size: 1 }],
|
|
184
|
+
h.env, h.log, h.newId, () => 1,
|
|
185
|
+
)
|
|
186
|
+
expect(res.status).toBe(413)
|
|
187
|
+
expect(h.uploads.get('r2-1')!.aborted).toBe(true)
|
|
188
|
+
expect(h.rows.size).toBe(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('refuses a complete for a key that is not the caller own', async () => {
|
|
192
|
+
const h = harness()
|
|
193
|
+
const res = await processMultipartComplete(
|
|
194
|
+
's', 'bob/big.zip', 'r2-1', '', [{ partNumber: 1, etag: 'e' }], h.env, h.log, h.newId, () => 1,
|
|
195
|
+
)
|
|
196
|
+
expect(res.status).toBe(403)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('logs op=complete with a re-read post-condition', async () => {
|
|
200
|
+
const h = harness()
|
|
201
|
+
await processMultipartCreate('s', 'big.zip', '', 10, h.env, h.log, h.newId, () => 1)
|
|
202
|
+
await processMultipartPart('s', 'alice/big.zip', 'r2-1', 1, new Uint8Array(10), h.env, h.log)
|
|
203
|
+
await processMultipartComplete(
|
|
204
|
+
's', 'alice/big.zip', 'r2-1', '', [{ partNumber: 1, etag: 'etag-1' }],
|
|
205
|
+
h.env, h.log, h.newId, () => 1,
|
|
206
|
+
)
|
|
207
|
+
expect(
|
|
208
|
+
h.lines.some((l) => l.includes('op=complete') && l.includes('objectPresent=true')),
|
|
209
|
+
).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { processUpload } from '../../skills/data-portal/template/functions/api/upload'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The manifest is a Map keyed on objectKey, so the schema's `objectKey UNIQUE`
|
|
7
|
+
* is real rather than assumed. Task 1704's lesson: a fake that accepts what the
|
|
8
|
+
* store rejects passes a test the production path fails.
|
|
9
|
+
*/
|
|
10
|
+
function harness(opts: { folders?: string[]; grant?: string[] } = {}) {
|
|
11
|
+
const folders = opts.folders ?? ['jobs']
|
|
12
|
+
const objects = new Map<string, Uint8Array>()
|
|
13
|
+
const rows = new Map<string, Record<string, unknown>>()
|
|
14
|
+
const lines: string[] = []
|
|
15
|
+
|
|
16
|
+
const env = {
|
|
17
|
+
BUCKET: {
|
|
18
|
+
async put(key: string, body: Uint8Array) {
|
|
19
|
+
objects.set(key, body)
|
|
20
|
+
},
|
|
21
|
+
async head(key: string) {
|
|
22
|
+
const b = objects.get(key)
|
|
23
|
+
return b ? { size: b.length } : null
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
DB: {
|
|
27
|
+
prepare(sql: string) {
|
|
28
|
+
return {
|
|
29
|
+
bind(...args: unknown[]) {
|
|
30
|
+
return {
|
|
31
|
+
async first() {
|
|
32
|
+
if (sql.includes('FROM sessions')) {
|
|
33
|
+
return {
|
|
34
|
+
ownerId: 'alice',
|
|
35
|
+
accountId: 'acct',
|
|
36
|
+
folders: (opts.grant ?? []).join(','),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (sql.includes('isDir = 1')) {
|
|
40
|
+
return folders.includes(String(args[1])) ? { relPath: args[1] } : null
|
|
41
|
+
}
|
|
42
|
+
if (sql.includes('SELECT fileId FROM manifest')) {
|
|
43
|
+
return rows.get(String(args[0])) ?? null
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
},
|
|
47
|
+
async run() {
|
|
48
|
+
// The upsert: conflict on objectKey replaces in place, which is
|
|
49
|
+
// what the real ON CONFLICT (objectKey) does.
|
|
50
|
+
const key = String(args[3])
|
|
51
|
+
rows.set(key, {
|
|
52
|
+
fileId: args[0],
|
|
53
|
+
ownerId: args[1],
|
|
54
|
+
filename: args[2],
|
|
55
|
+
objectKey: key,
|
|
56
|
+
size: args[4],
|
|
57
|
+
ingested: 0,
|
|
58
|
+
targetPath: args[7],
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
} as unknown as PortalEnv
|
|
67
|
+
|
|
68
|
+
let n = 0
|
|
69
|
+
return {
|
|
70
|
+
env,
|
|
71
|
+
objects,
|
|
72
|
+
rows,
|
|
73
|
+
lines,
|
|
74
|
+
newId: () => `id-${++n}`,
|
|
75
|
+
log: (l: string) => lines.push(l),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('targeted upload', () => {
|
|
80
|
+
it('stores under the target and records it on the row', async () => {
|
|
81
|
+
const h = harness()
|
|
82
|
+
const res = await processUpload(
|
|
83
|
+
's', 'a.pdf', 'jobs', new Uint8Array([1, 2]), h.env, h.log, h.newId, () => 1000,
|
|
84
|
+
)
|
|
85
|
+
expect(res.status).toBe(200)
|
|
86
|
+
expect([...h.objects.keys()]).toEqual(['alice/jobs/a.pdf'])
|
|
87
|
+
expect(h.rows.get('alice/jobs/a.pdf')!.targetPath).toBe('jobs')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('stores at the owner root when untargeted', async () => {
|
|
91
|
+
const h = harness()
|
|
92
|
+
const res = await processUpload(
|
|
93
|
+
's', 'a.pdf', '', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000,
|
|
94
|
+
)
|
|
95
|
+
expect(res.status).toBe(200)
|
|
96
|
+
expect([...h.objects.keys()]).toEqual(['alice/a.pdf'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps the same filename in two folders as two distinct objects', async () => {
|
|
100
|
+
// The reason the target is in the key. A flat key would collide these and
|
|
101
|
+
// the upsert would destroy the first upload.
|
|
102
|
+
const h = harness({ folders: ['jobs', 'output'] })
|
|
103
|
+
await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
|
|
104
|
+
await processUpload('s', 'a.pdf', 'output', new Uint8Array([2]), h.env, h.log, h.newId, () => 1000)
|
|
105
|
+
expect([...h.objects.keys()].sort()).toEqual(['alice/jobs/a.pdf', 'alice/output/a.pdf'])
|
|
106
|
+
expect(h.rows.size).toBe(2)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('replaces on the same owner, folder and filename', async () => {
|
|
110
|
+
const h = harness()
|
|
111
|
+
await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
|
|
112
|
+
await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([2, 2]), h.env, h.log, h.newId, () => 2000)
|
|
113
|
+
expect(h.rows.size).toBe(1)
|
|
114
|
+
expect(h.rows.get('alice/jobs/a.pdf')!.size).toBe(2)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('refuses an out-of-grant target and stores nothing', async () => {
|
|
118
|
+
const h = harness({ grant: ['output'] })
|
|
119
|
+
const res = await processUpload(
|
|
120
|
+
's', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000,
|
|
121
|
+
)
|
|
122
|
+
expect(res.status).toBe(403)
|
|
123
|
+
expect(h.objects.size).toBe(0)
|
|
124
|
+
expect(h.rows.size).toBe(0)
|
|
125
|
+
expect(
|
|
126
|
+
h.lines.some((l) => l.includes('op=target-check') && l.includes('result=denied-grant')),
|
|
127
|
+
).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('refuses a target the index does not publish', async () => {
|
|
131
|
+
const h = harness({ folders: [] })
|
|
132
|
+
const res = await processUpload(
|
|
133
|
+
's', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000,
|
|
134
|
+
)
|
|
135
|
+
expect(res.status).toBe(403)
|
|
136
|
+
expect(h.objects.size).toBe(0)
|
|
137
|
+
expect(h.lines.some((l) => l.includes('result=denied-index'))).toBe(true)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('refuses a traversal target and stores nothing', async () => {
|
|
141
|
+
const h = harness()
|
|
142
|
+
const res = await processUpload(
|
|
143
|
+
's', 'a.pdf', 'jobs/../..', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000,
|
|
144
|
+
)
|
|
145
|
+
expect(res.status).toBe(403)
|
|
146
|
+
expect(h.objects.size).toBe(0)
|
|
147
|
+
expect(h.lines.some((l) => l.includes('result=denied-segment'))).toBe(true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('logs op=target-check result=allowed on the happy path', async () => {
|
|
151
|
+
const h = harness()
|
|
152
|
+
await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
|
|
153
|
+
expect(
|
|
154
|
+
h.lines.some((l) => l.includes('op=target-check') && l.includes('result=allowed')),
|
|
155
|
+
).toBe(true)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('checks the target before any store write', async () => {
|
|
159
|
+
// Ordering is the point: a denied caller must not be able to cause an R2
|
|
160
|
+
// put, the same rule the owner gate already follows.
|
|
161
|
+
const h = harness({ folders: [] })
|
|
162
|
+
await processUpload('s', 'a.pdf', 'jobs', new Uint8Array([1]), h.env, h.log, h.newId, () => 1000)
|
|
163
|
+
const targetAt = h.lines.findIndex((l) => l.includes('op=target-check'))
|
|
164
|
+
const putAt = h.lines.findIndex((l) => l.includes('op=r2-put'))
|
|
165
|
+
expect(targetAt).toBeGreaterThanOrEqual(0)
|
|
166
|
+
expect(putAt).toBe(-1)
|
|
167
|
+
})
|
|
168
|
+
})
|