@rubytech/create-maxy-code 0.1.478 → 0.1.480
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-20-task-1789-reseat-channel-row-fork.md +1244 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +462 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +321 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1829-platform-ui-typecheck.md +830 -0
- package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +2177 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +201 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +217 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +80 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1829-platform-ui-typecheck-design.md +163 -0
- package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +163 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +22 -0
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/parity.test.ts +6 -0
- package/payload/platform/lib/graph-style/src/index.ts +20 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +155 -12
- package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +3 -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 +68 -28
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +194 -16
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +74 -29
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +13 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +26 -1
- package/payload/platform/plugins/cloudflare/bin/d1-http.mjs +80 -0
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +17 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +357 -0
- package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +120 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +59 -4
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +176 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-state-contract.test.ts +75 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +131 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +68 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +88 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +39 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +68 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +30 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +87 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +8 -4
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +10 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +107 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +82 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +9 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +56 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +98 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +12 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +12 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js +41 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js +249 -2
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/graph-client.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js +127 -3
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts +17 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js +26 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts +37 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js +126 -5
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts +18 -0
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +123 -18
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
- package/payload/platform/plugins/outlook/references/auth.md +45 -2
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js +66 -0
- package/payload/platform/plugins/scheduling/mcp/dist/__tests__/portal-index-audit.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts +41 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js +75 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/portal-index-audit.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +122 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/server/{chunk-3XLLTG6R.js → chunk-REWXLAPC.js} +30 -10
- package/payload/server/package.json +1 -1
- package/payload/server/public/activity.html +7 -6
- package/payload/server/public/assets/{AdminLoginScreens-DR5eLF-a.js → AdminLoginScreens-DXYJWXaX.js} +1 -1
- package/payload/server/public/assets/AdminShell-UDgNN4CA.js +2 -0
- package/payload/server/public/assets/Checkbox-C08Ulc_a.js +1 -0
- package/payload/server/public/assets/activity-06gsRDVv.js +1 -0
- package/payload/server/public/assets/admin-BhiHyORH.js +1 -0
- package/payload/server/public/assets/{arc-BrHWN2Kr.js → arc-BHXuzTwg.js} +1 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-Ct5q6ks1.js +1 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-Clt9NOr0.js → architectureDiagram-Q4EWVU46-DsN_vxau.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-BOe_jmXi.js → blockDiagram-DXYQGD6D-CvNat1uB.js} +1 -1
- package/payload/server/public/assets/browser-Cz4IKS47.js +1 -0
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-B64FaKMV.js → c4Diagram-AHTNJAMY-E9yqTPTW.js} +1 -1
- package/payload/server/public/assets/calendar-D0xtNqH8.js +1 -0
- package/payload/server/public/assets/channel-PE9vBKEQ.js +1 -0
- package/payload/server/public/assets/chat-DwLJ830n.js +1 -0
- package/payload/server/public/assets/chevron-left-Cv-TFuEf.js +1 -0
- package/payload/server/public/assets/{chunk-2KRD3SAO-BGAUfWvM.js → chunk-2KRD3SAO-DpzI_0EY.js} +1 -1
- package/payload/server/public/assets/{chunk-336JU56O-PFi6uQZ0.js → chunk-336JU56O-eR_I9Eu4.js} +2 -2
- package/payload/server/public/assets/chunk-426QAEUC-BDNZtJsz.js +1 -0
- package/payload/server/public/assets/{chunk-4BX2VUAB-CkxXXCiF.js → chunk-4BX2VUAB-D-6xk1t8.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-B-7tN4IA.js → chunk-4TB4RGXK-BkqUcFpL.js} +1 -1
- package/payload/server/public/assets/{chunk-55IACEB6-CBkNmqrt.js → chunk-55IACEB6-ChCl2pSP.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-wa9ujn7i.js → chunk-5FUZZQ4R-DasGsg54.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DIk-xBaE.js → chunk-5PVQY5BW-DXlHUX1h.js} +1 -1
- package/payload/server/public/assets/{chunk-67CJDMHE-BbMV1lgW.js → chunk-67CJDMHE-BZfVpTsE.js} +1 -1
- package/payload/server/public/assets/{chunk-7N4EOEYR-C49Mv-nQ.js → chunk-7N4EOEYR-C6U5TIgT.js} +1 -1
- package/payload/server/public/assets/{chunk-AA7GKIK3-Dy_pJOp_.js → chunk-AA7GKIK3-CA7cVDmw.js} +1 -1
- package/payload/server/public/assets/{chunk-BSJP7CBP-Ce-OmPqn.js → chunk-BSJP7CBP-DxN2GC3p.js} +1 -1
- package/payload/server/public/assets/{chunk-CIAEETIT-tnjq-APC.js → chunk-CIAEETIT-C0-4wA8y.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-Bvv_deZ2.js → chunk-EDXVE4YY--kyb1rLc.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-CmxCPKyU.js → chunk-ENJZ2VHE-FBN6hR7V.js} +1 -1
- package/payload/server/public/assets/{chunk-FMBD7UC4-Top6y08S.js → chunk-FMBD7UC4-Lpge4M5g.js} +1 -1
- package/payload/server/public/assets/{chunk-FOC6F5B3-B9LPNCUS.js → chunk-FOC6F5B3-CM1rwOou.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-Bi-_c351.js → chunk-ICPOFSXX-JdYLY3yu.js} +2 -2
- package/payload/server/public/assets/{chunk-K5T4RW27-DAnRM04h.js → chunk-K5T4RW27-DCUblGBr.js} +1 -1
- package/payload/server/public/assets/{chunk-KGLVRYIC-C0QYcbLe.js → chunk-KGLVRYIC-BP_cide2.js} +1 -1
- package/payload/server/public/assets/{chunk-LIHQZDEY-BjvA5ykt.js → chunk-LIHQZDEY-DjMa1lOl.js} +1 -1
- package/payload/server/public/assets/{chunk-ORNJ4GCN-Cs46Gj7z.js → chunk-ORNJ4GCN-D51eJAoV.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-BBpimab5.js → chunk-OYMX7WX6-4hWIO544.js} +1 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-B_Hris8x.js +1 -0
- package/payload/server/public/assets/{chunk-U2HBQHQK-CQUSBHoK.js → chunk-U2HBQHQK-BwdQf9cS.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-Dm8TevG0.js → chunk-X2U36JSP-DmDxwLOg.js} +1 -1
- package/payload/server/public/assets/{chunk-XPW4576I-B10QuxYb.js → chunk-XPW4576I-JQyAB7ND.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-vaGsu4Wk.js → chunk-YZCP3GAM-BonLdCzI.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-D1Qi22bL.js → chunk-ZZ45TVLE-BXKB2wSk.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-D8Q05iMI.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-Dm3hAxfm.js +1 -0
- package/payload/server/public/assets/clone-C-wOwvpY.js +1 -0
- package/payload/server/public/assets/{cose-bilkent-S5V4N54A-CRDvLRXs.js → cose-bilkent-S5V4N54A-BDnd4Blw.js} +1 -1
- package/payload/server/public/assets/{dagre-CEmqJTt3.js → dagre-BmexzuER.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-LXMTo7Fp.js → dagre-KV5264BT-D4WVuwPj.js} +1 -1
- package/payload/server/public/assets/data-DUDJfnjI.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-zyis6nPt.js → diagram-5BDNPKRD-Bbf4mT8a.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-BiU0XAAD.js → diagram-G4DWMVQ6-BpqVTUmx.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-rGFiz1SX.js → diagram-MMDJMWI5-DY-k5k6r.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-B0wak1ZJ.js → diagram-TYMM5635-CXHxwacS.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8AUxLyX.js → erDiagram-SMLLAGMA-BNKEc3l8.js} +1 -1
- package/payload/server/public/assets/{flatten-naJ5PJ4f.js → flatten-jn8bZgcI.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-BtdUf68b.js → flowDiagram-DWJPFMVM-CbAeuJsi.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-C-lkzDmf.js → ganttDiagram-T4ZO3ILL-CeeNnL4l.js} +1 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-BBHsY16R.js +1 -0
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DRUKumq2.js → gitGraphDiagram-UUTBAWPF-DG8RCtfg.js} +1 -1
- package/payload/server/public/assets/{graph-DRloNic4.js → graph-BAoktBcx.js} +19 -19
- package/payload/server/public/assets/graph-labels-LeIhl7ZG.js +1 -0
- package/payload/server/public/assets/{graphlib-zRsaoFEC.js → graphlib-BkDExPQi.js} +1 -1
- package/payload/server/public/assets/info-OMHHGYJF-C1j5I_zx.js +1 -0
- package/payload/server/public/assets/infoDiagram-42DDH7IO-ZWkwHWEu.js +2 -0
- package/payload/server/public/assets/{isEmpty-Bnpg7b8f.js → isEmpty-Dkk6_L-B.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-BguMgoR8.js → ishikawaDiagram-UXIWVN3A-D_iMHV64.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CXhyK1n-.js → journeyDiagram-VCZTEJTY-BvPRpulx.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-C0UCWVx-.js → kanban-definition-6JOO6SKY-CkWaXWo2.js} +1 -1
- package/payload/server/public/assets/{line--uhS9YDE.js → line-BGQT9unx.js} +1 -1
- package/payload/server/public/assets/{linear-CAOWPodQ.js → linear-DzrYI8-8.js} +1 -1
- package/payload/server/public/assets/{maximize-2-CzDaBugs.js → maximize-2-2MgPSwlx.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-D6kPx7vc.js → mermaid-parser.core-DrMY7FMe.js} +2 -2
- package/payload/server/public/assets/{mermaid.core-CTd4DS6O.js → mermaid.core-BWdf6fZ7.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-_AyvyG8C.js → mindmap-definition-QFDTVHPH-CgqM9h97.js} +1 -1
- package/payload/server/public/assets/operator-CZifmNhp.js +1 -0
- package/payload/server/public/assets/{ordinal-Wx4m9zHk.js → ordinal-DuJYM7p1.js} +1 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-CGiTbe0N.js +1 -0
- package/payload/server/public/assets/{page-ef-6rRPL.js → page-B4kllGd_.js} +6 -6
- package/payload/server/public/assets/page-DbfE7QwJ.js +1 -0
- package/payload/server/public/assets/{pdf-render-CeX6d2v7.js → pdf-render-riHufgrn.js} +1 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-BrK023dE.js +1 -0
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-6TsUDkkQ.js → pieDiagram-DEJITSTG-BeUGoYS7.js} +1 -1
- package/payload/server/public/assets/public-VtmyYz8E.js +1 -0
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-CWw9gOI4.js → quadrantDiagram-34T5L4WZ-CX82WgKy.js} +1 -1
- package/payload/server/public/assets/radar-PYXPWWZC-p7NRamXX.js +1 -0
- package/payload/server/public/assets/{reduce-BUmc8RDR.js → reduce-COyotL2V.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-DCOlxNvx.js → requirementDiagram-MS252O5E-CzqebQXj.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-DXEKUhXP.js → rotate-ccw-DaZdB2cz.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CW4Xbir3.js → sankeyDiagram-XADWPNL6-CaHi1yzU.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-vhb_pBLG.js → sequenceDiagram-FGHM5R23-BF2AeiKW.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-Cb1WExEX.js → stateDiagram-FHFEXIEX-DtUCA8Ih.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a4FvB3L8.js +1 -0
- package/payload/server/public/assets/tasks-CvqVDbfO.js +1 -0
- package/payload/server/public/assets/{time-entry-format-CsYAjplS.js → time-entry-format-TD-PPEIu.js} +1 -1
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BAHTf_AF.js → timeline-definition-GMOUNBTQ-D3Q1YrKY.js} +1 -1
- package/payload/server/public/assets/treeView-SZITEDCU-CoWk8VoG.js +1 -0
- package/payload/server/public/assets/treemap-W4RFUUIX-BhWeGj8Y.js +1 -0
- package/payload/server/public/assets/{triangle-alert-0lSBIUg-.js → triangle-alert-BaLhVNnn.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-BhjPzRa3.js +1 -0
- package/payload/server/public/assets/useSubAccountSwitcher-CWRpVt1-.css +1 -0
- package/payload/server/public/assets/{useSubAccountSwitcher-BZnzc5C3.js → useSubAccountSwitcher-HEVLb6Z9.js} +2 -2
- package/payload/server/public/assets/useVoiceRecorder-CfGgj3vm.js +2 -0
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-DtEttvW4.js → vennDiagram-DHZGUBPP-DnjzQDGf.js} +1 -1
- package/payload/server/public/assets/wardley-RL74JXVD-C0q3Hqng.js +1 -0
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-K06y6CVJ.js → wardleyDiagram-NUSXRM2D-T3WHrUvi.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-D66c40lp.js → xychartDiagram-5P7HB3ND-2Efyv4h2.js} +1 -1
- package/payload/server/public/brand-defaults.css +1 -0
- package/payload/server/public/browser.html +4 -5
- package/payload/server/public/calendar.html +5 -6
- package/payload/server/public/chat.html +12 -13
- package/payload/server/public/data.html +11 -12
- package/payload/server/public/graph.html +10 -11
- package/payload/server/public/index.html +14 -15
- package/payload/server/public/operator.html +14 -15
- package/payload/server/public/privacy.html +1 -0
- package/payload/server/public/public.html +12 -13
- package/payload/server/public/tasks.html +7 -5
- package/payload/server/public/vnc-popout.html +1 -0
- package/payload/server/server.js +833 -389
- package/payload/server/{src-XQ63FPRE.js → src-JJ6PHO5I.js} +1 -1
- package/payload/server/public/assets/AdminShell-D9zaUxPd.js +0 -2
- package/payload/server/public/assets/Checkbox-DcbbAIe5.js +0 -1
- package/payload/server/public/assets/activity-C9P5NjEZ.js +0 -1
- package/payload/server/public/assets/admin-D2KLZnQW.js +0 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-CZQzGRSM.js +0 -1
- package/payload/server/public/assets/browser-CDWqEiCD.js +0 -1
- package/payload/server/public/assets/calendar-BG7fOpbB.js +0 -1
- package/payload/server/public/assets/channel-zDUAN9Ks.js +0 -1
- package/payload/server/public/assets/chat-RyH-WYQC.js +0 -1
- package/payload/server/public/assets/chevron-left-DmXCr6nB.js +0 -1
- package/payload/server/public/assets/chunk-426QAEUC-iJnqNRug.js +0 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-Qlc2PFAj.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CFsKL52p.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BWBUUCiO.js +0 -1
- package/payload/server/public/assets/clone-h5XhkDUL.js +0 -1
- package/payload/server/public/assets/data-BaY_LkLR.js +0 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-CBZPQJLj.js +0 -1
- package/payload/server/public/assets/graph-labels-DD-GqS4v.js +0 -1
- package/payload/server/public/assets/info-OMHHGYJF-1vIoX-kP.js +0 -1
- package/payload/server/public/assets/infoDiagram-42DDH7IO-KHcXRaan.js +0 -2
- package/payload/server/public/assets/operator-BZeeyait.js +0 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-CTU0gCGb.js +0 -1
- package/payload/server/public/assets/page-B0obcg3b.js +0 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-D-EEHxhC.js +0 -1
- package/payload/server/public/assets/public-9D3MMOkC.js +0 -1
- package/payload/server/public/assets/radar-PYXPWWZC-DHXATZGB.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CxOIKvXh.js +0 -1
- package/payload/server/public/assets/tasks-BB0L7e5a.js +0 -1
- package/payload/server/public/assets/treeView-SZITEDCU-CC7ouMFd.js +0 -1
- package/payload/server/public/assets/treemap-W4RFUUIX-CaCg27im.js +0 -1
- package/payload/server/public/assets/useCopyFeedback-jTBQ2h0V.js +0 -1
- package/payload/server/public/assets/useSelectionMode-BooArySZ.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-D9knHOKq.css +0 -1
- package/payload/server/public/assets/useVoiceRecorder-PUT8q_sm.js +0 -2
- package/payload/server/public/assets/wardley-RL74JXVD-bMPsqQZ7.js +0 -1
- /package/payload/server/public/assets/{_baseFor-k8hYPe-U.js → _baseFor-Bjj9It3Q.js} +0 -0
- /package/payload/server/public/assets/{array-BFDiaBgf.js → array-BrrxEo12.js} +0 -0
- /package/payload/server/public/assets/{cytoscape.esm-Ch0xptA4.js → cytoscape.esm-CTWJxUxo.js} +0 -0
- /package/payload/server/public/assets/{defaultLocale-ZeknFqNe.js → defaultLocale-CFDbRV4k.js} +0 -0
- /package/payload/server/public/assets/{dist-B2N0v8hZ.js → dist-BATfy7Sn.js} +0 -0
- /package/payload/server/public/assets/{init-0VJVrkRJ.js → init-dXCzGgLP.js} +0 -0
- /package/payload/server/public/assets/{katex-Br7bH10N.js → katex-CHA1JFYJ.js} +0 -0
- /package/payload/server/public/assets/{path-INs8XTPH.js → path-CCRpdXhK.js} +0 -0
- /package/payload/server/public/assets/{preload-helper-D_sPnjeL.js → preload-helper-CH6UZRzu.js} +0 -0
- /package/payload/server/public/assets/{rough.esm-DU9bZLvZ.js → rough.esm-Bud4g4fh.js} +0 -0
- /package/payload/server/public/assets/{src-DHSQDYx-.js → src-DNdBRXXx.js} +0 -0
|
@@ -0,0 +1,2177 @@
|
|
|
1
|
+
# Data portal class-derived allowlist — implementation plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Let an enrolled portal user browse and download their sub-account's deliverable folders, where "deliverable" is derived per account from that account's own `SCHEMA.md` rather than a hardcoded list.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The device walks only the exposed folders and full-replaces a D1 `directory` table with metadata (no bytes), so listing survives the device being offline. Download mints a five-minute HMAC-signed link to a new public install route, which re-derives the exposed set itself rather than trusting the path it was handed.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, vitest, Cloudflare Pages Functions + D1, Node 22 ESM (`.mjs` with JSDoc types), Hono.
|
|
10
|
+
|
|
11
|
+
**Spec:** [`../specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md`](../specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md)
|
|
12
|
+
**Task:** [`../../../../.tasks/1831-data-portal-exposes-only-uploads-not-the-sub-accounts-folder-tree.md`](../../../../.tasks/1831-data-portal-exposes-only-uploads-not-the-sub-accounts-folder-tree.md)
|
|
13
|
+
|
|
14
|
+
## Global Constraints
|
|
15
|
+
|
|
16
|
+
- **Node 22 required.** Every command below assumes `export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"`.
|
|
17
|
+
- **Test runner is vitest**, invoked from the plugin dir: `cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run <file>`.
|
|
18
|
+
- **Baseline is 139 tests across 10 files, all green.** Never let that regress.
|
|
19
|
+
- **`npm run typecheck:templates` must stay exit 0.** It typechecks `template/functions/api/*.ts` and `_lib/*.mjs` under `--strict --checkJs`, so every `.mjs` needs JSDoc types.
|
|
20
|
+
- **`.mjs` modules in `_lib/` are the shared-code precedent** (`passcode.mjs` is imported by both `bin/portal-enrol.mjs` and the template). Follow it.
|
|
21
|
+
- **Never spawn `wrangler` per statement.** That is the Task 1805 defect. All D1 access from the device goes over HTTP with bound parameters.
|
|
22
|
+
- **No production import from `platform/ui` into `platform/plugins`.** Only a test does that today (`server/lib/calendar-ics.test.ts:12`) and this sprint does not change it.
|
|
23
|
+
- **Signature is not the security boundary.** The install re-derives the exposed dir set on every request. A valid signature over a non-exposed path is still refused.
|
|
24
|
+
- **Fail closed on a missing `SCHEMA.md`** — expose nothing. This deliberately inverts `fs-schema-guard.sh:76-78`, which fails open; every place that does it carries that reason in a comment.
|
|
25
|
+
- **Log tags:** `[portal-index]` device side, `[data-portal]` portal side, `[portal-fetch]` install side.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## File Structure
|
|
30
|
+
|
|
31
|
+
| File | Status | Responsibility |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs` | create | `SCHEMA.md` text → exposed dir names. Pure. |
|
|
34
|
+
| `platform/plugins/cloudflare/bin/d1-http.mjs` | create | D1 over HTTP with bound params |
|
|
35
|
+
| `platform/plugins/cloudflare/bin/portal-index-push.mjs` | create | walk exposed dirs, full-replace `directory` rows |
|
|
36
|
+
| `platform/plugins/cloudflare/bin/portal-enrol.mjs` | modify | write `accountId` on the `people` row |
|
|
37
|
+
| `.../data-portal/schema.sql` | modify | `directory` table, `people.accountId` |
|
|
38
|
+
| `.../template/functions/api/_lib/portal-sign.mjs` | create | mint + verify signed links (portal side) |
|
|
39
|
+
| `.../template/functions/api/_lib/types.ts` | modify | `PORTAL_FETCH_SECRET`, `PORTAL_INSTALL_ORIGIN` on `PortalEnv` |
|
|
40
|
+
| `.../template/functions/api/_lib/session.ts` | modify | `resolveSession` also returns `accountId` |
|
|
41
|
+
| `.../template/functions/api/files.ts` | modify | return `entries` beside `files` |
|
|
42
|
+
| `.../template/functions/api/download.ts` | modify | indexed-file branch: HEAD pre-flight, then signed link |
|
|
43
|
+
| `.../template/portal.js`, `index.html` | modify | breadcrumb folder navigation |
|
|
44
|
+
| `platform/ui/server/routes/portal-fetch.ts` | create | verify signature, re-resolve exposure, serve bytes |
|
|
45
|
+
| `platform/ui/server/index.ts` | modify | register the subapp |
|
|
46
|
+
| `.../scheduling/mcp/src/scripts/check-due-events.ts` | modify | spawn the push hourly |
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
### Task 1: Exposed-dir resolver
|
|
51
|
+
|
|
52
|
+
The one place that decides which folders a client may see. Pure text in, names out — no filesystem, so it is trivially testable.
|
|
53
|
+
|
|
54
|
+
**Files:**
|
|
55
|
+
- Create: `platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs`
|
|
56
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts`
|
|
57
|
+
|
|
58
|
+
**Interfaces:**
|
|
59
|
+
- Produces: `resolveExposedDirs(schemaText: string | null): { schemaPresent: boolean, allowed: string[], domainBuckets: string[], pluginOwned: string[], collisions: string[], exposed: string[] }`
|
|
60
|
+
|
|
61
|
+
- [ ] **Step 1: Write the failing test**
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts
|
|
65
|
+
import { describe, it, expect } from 'vitest'
|
|
66
|
+
import { resolveExposedDirs } from '../../bin/schema-exposed-dirs.mjs'
|
|
67
|
+
|
|
68
|
+
const ALLOWED = ['projects', 'contacts', 'documents', 'output', 'uploads', 'quotes', 'sites']
|
|
69
|
+
|
|
70
|
+
function schema(opts: { allowed?: string[]; ontology?: string; pluginOwned?: string } = {}): string {
|
|
71
|
+
const allowed = opts.allowed ?? ALLOWED
|
|
72
|
+
return [
|
|
73
|
+
'# Account schema',
|
|
74
|
+
'',
|
|
75
|
+
'```allowed-top-level',
|
|
76
|
+
...allowed,
|
|
77
|
+
'```',
|
|
78
|
+
'',
|
|
79
|
+
opts.pluginOwned ?? '',
|
|
80
|
+
'',
|
|
81
|
+
opts.ontology ?? '',
|
|
82
|
+
'',
|
|
83
|
+
].join('\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The generator writes this region with an ASCII hyphen separator
|
|
87
|
+
// (account-schema-owned-dirs.py:294).
|
|
88
|
+
const ONTOLOGY = [
|
|
89
|
+
'<!-- ontology-buckets:start -->',
|
|
90
|
+
'## Domain entity buckets (from the graph ontology)',
|
|
91
|
+
'',
|
|
92
|
+
'- `quotes/` - one folder per Quotation record.',
|
|
93
|
+
'<!-- ontology-buckets:end -->',
|
|
94
|
+
].join('\n')
|
|
95
|
+
|
|
96
|
+
// The plugin-owned region uses an EM DASH (account-schema-owned-dirs.py:281).
|
|
97
|
+
const PLUGIN_OWNED = [
|
|
98
|
+
'<!-- plugin-owned-dirs:start -->',
|
|
99
|
+
'## Plugin-owned top-level workspaces',
|
|
100
|
+
'',
|
|
101
|
+
'- `sites/` — Owned by the cloudflare plugin.',
|
|
102
|
+
'<!-- plugin-owned-dirs:end -->',
|
|
103
|
+
].join('\n')
|
|
104
|
+
|
|
105
|
+
describe('resolveExposedDirs', () => {
|
|
106
|
+
it('exposes ontology buckets plus output', () => {
|
|
107
|
+
const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
|
|
108
|
+
expect(r.exposed).toEqual(['output', 'quotes'])
|
|
109
|
+
expect(r.schemaPresent).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('exposes output alone when there is no ontology region', () => {
|
|
113
|
+
expect(resolveExposedDirs(schema()).exposed).toEqual(['output'])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('drops a bucket that is absent from the allowed block', () => {
|
|
117
|
+
const r = resolveExposedDirs(schema({ allowed: ['output', 'documents'], ontology: ONTOLOGY }))
|
|
118
|
+
expect(r.exposed).toEqual(['output'])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// The contract that fails silently if got wrong: an em dash in the ontology
|
|
122
|
+
// region must NOT parse, because the generator never writes one there.
|
|
123
|
+
it('does not parse an em-dash separator as a domain bucket', () => {
|
|
124
|
+
const emDash = ONTOLOGY.replace('- one folder per', '— one folder per')
|
|
125
|
+
expect(resolveExposedDirs(schema({ ontology: emDash })).domainBuckets).toEqual([])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('never exposes operator-data or upload buckets', () => {
|
|
129
|
+
const r = resolveExposedDirs(schema({ ontology: ONTOLOGY }))
|
|
130
|
+
for (const d of ['documents', 'contacts', 'projects', 'uploads']) {
|
|
131
|
+
expect(r.exposed).not.toContain(d)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('reports a plugin-owned collision and keeps the dir out of exposed', () => {
|
|
136
|
+
const collide = ONTOLOGY.replace('`quotes/`', '`sites/`').replace('Quotation', 'Site')
|
|
137
|
+
const r = resolveExposedDirs(schema({ ontology: collide, pluginOwned: PLUGIN_OWNED }))
|
|
138
|
+
expect(r.collisions).toEqual(['sites'])
|
|
139
|
+
expect(r.exposed).toEqual(['output'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('fails closed on a missing schema', () => {
|
|
143
|
+
const r = resolveExposedDirs(null)
|
|
144
|
+
expect(r.schemaPresent).toBe(false)
|
|
145
|
+
expect(r.exposed).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('fails closed when the allowed block is absent', () => {
|
|
149
|
+
const r = resolveExposedDirs('# no fence here')
|
|
150
|
+
expect(r.exposed).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"
|
|
159
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/schema-exposed-dirs.test.ts
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Expected: FAIL — `Failed to resolve import "../../bin/schema-exposed-dirs.mjs"`.
|
|
163
|
+
|
|
164
|
+
- [ ] **Step 3: Write the implementation**
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
// platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs
|
|
168
|
+
// Which folders of an account may a portal client see?
|
|
169
|
+
//
|
|
170
|
+
// Not a list in this file. The answer is derived per account from that
|
|
171
|
+
// account's own SCHEMA.md, which is generated by
|
|
172
|
+
// platform/scripts/lib/account-schema-owned-dirs.py: one bucket per top-level
|
|
173
|
+
// entity of the brand's vertical ontology, plus whatever plugins claim, inside
|
|
174
|
+
// a closed allowed-top-level set.
|
|
175
|
+
//
|
|
176
|
+
// Exposed = ontology buckets + `output`, intersected with the allowed set.
|
|
177
|
+
// Ontology buckets hold per-entity artifacts ("File a job's artifacts under its
|
|
178
|
+
// entity folder", the region's own prose); `output` is the delivery convention
|
|
179
|
+
// (doc-source-resolve.ts:14 calls it agent-authored). Operator-data buckets,
|
|
180
|
+
// uploads, plugin workspaces and platform-internal dirs are in neither source
|
|
181
|
+
// and so are never exposed.
|
|
182
|
+
|
|
183
|
+
const ONT_START = '<!-- ontology-buckets:start -->'
|
|
184
|
+
const ONT_END = '<!-- ontology-buckets:end -->'
|
|
185
|
+
const OWNED_START = '<!-- plugin-owned-dirs:start -->'
|
|
186
|
+
const OWNED_END = '<!-- plugin-owned-dirs:end -->'
|
|
187
|
+
|
|
188
|
+
/** The delivery convention, always exposed when the account allows it. */
|
|
189
|
+
export const ALWAYS_EXPOSED = 'output'
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The generator writes the ontology region with an ASCII hyphen and the
|
|
193
|
+
* plugin-owned region with an em dash (account-schema-owned-dirs.py:294 vs
|
|
194
|
+
* :281). Matching the wrong one yields zero buckets and reads as "this account
|
|
195
|
+
* has no deliverables", which is why each region has its own pattern.
|
|
196
|
+
*/
|
|
197
|
+
const DOMAIN_LINE = /^- `([^`/]+)\/` - one folder per /
|
|
198
|
+
const OWNED_LINE = /^- `([^`/]+)\/` — /
|
|
199
|
+
|
|
200
|
+
/** @param {string} text @param {string} start @param {string} end */
|
|
201
|
+
function region(text, start, end) {
|
|
202
|
+
const i = text.indexOf(start)
|
|
203
|
+
if (i === -1) return null
|
|
204
|
+
const j = text.indexOf(end, i)
|
|
205
|
+
return text.slice(i, j === -1 ? text.length : j)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @param {string} text @returns {string[] | null} */
|
|
209
|
+
function allowedBlockOrNull(text) {
|
|
210
|
+
const lines = text.split('\n')
|
|
211
|
+
const start = lines.findIndex((l) => l.trim() === '```allowed-top-level')
|
|
212
|
+
if (start === -1) return null
|
|
213
|
+
for (let j = start + 1; j < lines.length; j++) {
|
|
214
|
+
if (lines[j].trim() === '```') {
|
|
215
|
+
return lines.slice(start + 1, j).map((l) => l.trim()).filter(Boolean)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {string | null} schemaText Contents of accounts/<id>/SCHEMA.md, or null when absent.
|
|
223
|
+
* @returns {{ schemaPresent: boolean, allowed: string[], domainBuckets: string[],
|
|
224
|
+
* pluginOwned: string[], collisions: string[], exposed: string[] }}
|
|
225
|
+
*/
|
|
226
|
+
export function resolveExposedDirs(schemaText) {
|
|
227
|
+
const empty = {
|
|
228
|
+
schemaPresent: false,
|
|
229
|
+
allowed: /** @type {string[]} */ ([]),
|
|
230
|
+
domainBuckets: /** @type {string[]} */ ([]),
|
|
231
|
+
pluginOwned: /** @type {string[]} */ ([]),
|
|
232
|
+
collisions: /** @type {string[]} */ ([]),
|
|
233
|
+
exposed: /** @type {string[]} */ ([]),
|
|
234
|
+
}
|
|
235
|
+
// Fail CLOSED. fs-schema-guard.sh:76-78 fails open on a missing schema so an
|
|
236
|
+
// unseeded legacy account is not write-blocked; a read surface facing a
|
|
237
|
+
// client must do the opposite. Do not "fix" this to match the guard.
|
|
238
|
+
if (typeof schemaText !== 'string' || schemaText.length === 0) return empty
|
|
239
|
+
|
|
240
|
+
const allowed = allowedBlockOrNull(schemaText)
|
|
241
|
+
if (allowed === null) return { ...empty, schemaPresent: true }
|
|
242
|
+
|
|
243
|
+
const ont = region(schemaText, ONT_START, ONT_END)
|
|
244
|
+
const owned = region(schemaText, OWNED_START, OWNED_END)
|
|
245
|
+
|
|
246
|
+
/** @param {string | null} text @param {RegExp} re */
|
|
247
|
+
const dirsIn = (text, re) => {
|
|
248
|
+
if (!text) return []
|
|
249
|
+
/** @type {string[]} */ const out = []
|
|
250
|
+
for (const line of text.split('\n')) {
|
|
251
|
+
const m = re.exec(line.trim())
|
|
252
|
+
if (m && !out.includes(m[1])) out.push(m[1])
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const domainBuckets = dirsIn(ont, DOMAIN_LINE)
|
|
258
|
+
const pluginOwned = dirsIn(owned, OWNED_LINE)
|
|
259
|
+
|
|
260
|
+
// The generator omits a plugin-claimed dir from the ontology region
|
|
261
|
+
// (account-schema-owned-dirs.py:287), so this should be empty in practice.
|
|
262
|
+
// When it is not, the bucket silently vanishes from the client's view — the
|
|
263
|
+
// caller logs it rather than guessing which side should win.
|
|
264
|
+
const collisions = domainBuckets.filter((d) => pluginOwned.includes(d))
|
|
265
|
+
|
|
266
|
+
const candidates = [ALWAYS_EXPOSED, ...domainBuckets.filter((d) => !collisions.includes(d))]
|
|
267
|
+
const exposed = [...new Set(candidates)].filter((d) => allowed.includes(d)).sort()
|
|
268
|
+
|
|
269
|
+
return { schemaPresent: true, allowed, domainBuckets, pluginOwned, collisions, exposed }
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/schema-exposed-dirs.test.ts
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Expected: PASS, 7 tests.
|
|
280
|
+
|
|
281
|
+
- [ ] **Step 5: Commit**
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
git add platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts
|
|
285
|
+
git commit -m "feat(data-portal): derive exposed dirs from the account's own SCHEMA.md"
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
### Task 2: Signed-link minting and verification (portal side)
|
|
291
|
+
|
|
292
|
+
**Files:**
|
|
293
|
+
- Create: `platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs`
|
|
294
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts`
|
|
295
|
+
|
|
296
|
+
**Interfaces:**
|
|
297
|
+
- Consumes: nothing.
|
|
298
|
+
- Produces:
|
|
299
|
+
- `SIGNED_TTL_MS: number` (300000)
|
|
300
|
+
- `signPath(secret: string, accountId: string, relPath: string, expiresAtMs: number): Promise<string>` — hex HMAC-SHA256
|
|
301
|
+
- `verifyPath(secret, accountId, relPath, expiresAtMs, signature, nowMs): Promise<boolean>`
|
|
302
|
+
- `buildSignedUrl(origin: string, secret: string, accountId: string, relPath: string, expiresAtMs: number): Promise<string>`
|
|
303
|
+
|
|
304
|
+
- [ ] **Step 1: Write the failing test**
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
// platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts
|
|
308
|
+
import { describe, it, expect } from 'vitest'
|
|
309
|
+
import {
|
|
310
|
+
SIGNED_TTL_MS,
|
|
311
|
+
signPath,
|
|
312
|
+
verifyPath,
|
|
313
|
+
buildSignedUrl,
|
|
314
|
+
} from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
|
|
315
|
+
|
|
316
|
+
const SECRET = 'test-secret-value'
|
|
317
|
+
const ACC = '098a18a3-1741-447f-9778-65470645d57d'
|
|
318
|
+
const NOW = 1_800_000_000_000
|
|
319
|
+
|
|
320
|
+
describe('portal-sign', () => {
|
|
321
|
+
it('round-trips a signature', async () => {
|
|
322
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
323
|
+
const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp)
|
|
324
|
+
expect(await verifyPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('refuses an expired signature', async () => {
|
|
328
|
+
const exp = NOW - 1
|
|
329
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
330
|
+
expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('refuses a tampered path', async () => {
|
|
334
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
335
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
336
|
+
expect(await verifyPath(SECRET, ACC, 'documents/private.pdf', exp, sig, NOW)).toBe(false)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('refuses a tampered account', async () => {
|
|
340
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
341
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
342
|
+
expect(await verifyPath(SECRET, 'other-account', 'output/a.pdf', exp, sig, NOW)).toBe(false)
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('refuses a tampered expiry', async () => {
|
|
346
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
347
|
+
const sig = await signPath(SECRET, ACC, 'output/a.pdf', exp)
|
|
348
|
+
expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp + 60_000, sig, NOW)).toBe(false)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('refuses a signature made with a different secret', async () => {
|
|
352
|
+
const exp = NOW + SIGNED_TTL_MS
|
|
353
|
+
const sig = await signPath('other-secret', ACC, 'output/a.pdf', exp)
|
|
354
|
+
expect(await verifyPath(SECRET, ACC, 'output/a.pdf', exp, sig, NOW)).toBe(false)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('builds a url carrying account, path, expiry and signature', async () => {
|
|
358
|
+
const url = new URL(await buildSignedUrl('https://install.example', SECRET, ACC, 'output/a b.pdf', NOW + 1000))
|
|
359
|
+
expect(url.pathname).toBe('/api/portal/fetch')
|
|
360
|
+
expect(url.searchParams.get('accountId')).toBe(ACC)
|
|
361
|
+
expect(url.searchParams.get('path')).toBe('output/a b.pdf')
|
|
362
|
+
expect(url.searchParams.get('exp')).toBe(String(NOW + 1000))
|
|
363
|
+
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-sign.test.ts
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Expected: FAIL — module not found.
|
|
375
|
+
|
|
376
|
+
- [ ] **Step 3: Write the implementation**
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
// platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs
|
|
380
|
+
// Signed links for device-held files.
|
|
381
|
+
//
|
|
382
|
+
// The portal holds the account's folder INDEX but not its bytes, so a download
|
|
383
|
+
// has to reach the install. It does that with a link the install can verify
|
|
384
|
+
// rather than a shared secret in a header: the secret never travels, and each
|
|
385
|
+
// grant covers one file for five minutes.
|
|
386
|
+
//
|
|
387
|
+
// The signature proves the request came from the portal. It is NOT the security
|
|
388
|
+
// boundary — the install re-derives the account's exposed folder set on every
|
|
389
|
+
// request, so a validly-signed link for a non-exposed path is still refused.
|
|
390
|
+
// Keep both checks; neither is redundant.
|
|
391
|
+
//
|
|
392
|
+
// Web Crypto only. Cloudflare Workers and Node 22 both provide it, so the
|
|
393
|
+
// install can verify with an identical algorithm without this module crossing
|
|
394
|
+
// the plugin boundary into platform/ui.
|
|
395
|
+
|
|
396
|
+
/** Five minutes. Long enough for a click to become a download, short enough
|
|
397
|
+
* that a leaked link is worthless by the time it is found in a log. */
|
|
398
|
+
export const SIGNED_TTL_MS = 300_000
|
|
399
|
+
|
|
400
|
+
/** @param {string} accountId @param {string} relPath @param {number} expiresAtMs */
|
|
401
|
+
function message(accountId, relPath, expiresAtMs) {
|
|
402
|
+
// Newline-separated so no field can absorb another: an accountId ending in a
|
|
403
|
+
// path fragment cannot forge a different (accountId, relPath) pair.
|
|
404
|
+
return `${accountId}\n${relPath}\n${expiresAtMs}`
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** @param {string} secret */
|
|
408
|
+
async function keyFor(secret) {
|
|
409
|
+
return crypto.subtle.importKey(
|
|
410
|
+
'raw',
|
|
411
|
+
new TextEncoder().encode(secret),
|
|
412
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
413
|
+
false,
|
|
414
|
+
['sign'],
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @param {string} secret @param {string} accountId @param {string} relPath
|
|
420
|
+
* @param {number} expiresAtMs @returns {Promise<string>} lowercase hex
|
|
421
|
+
*/
|
|
422
|
+
export async function signPath(secret, accountId, relPath, expiresAtMs) {
|
|
423
|
+
const key = await keyFor(secret)
|
|
424
|
+
const mac = await crypto.subtle.sign(
|
|
425
|
+
'HMAC',
|
|
426
|
+
key,
|
|
427
|
+
new TextEncoder().encode(message(accountId, relPath, expiresAtMs)),
|
|
428
|
+
)
|
|
429
|
+
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Length-independent, value-constant comparison. A `===` on the hex would leak
|
|
433
|
+
* the matching prefix length through timing.
|
|
434
|
+
* @param {string} a @param {string} b */
|
|
435
|
+
function constantTimeEqual(a, b) {
|
|
436
|
+
if (a.length !== b.length) return false
|
|
437
|
+
let diff = 0
|
|
438
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
439
|
+
return diff === 0
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* @param {string} secret @param {string} accountId @param {string} relPath
|
|
444
|
+
* @param {number} expiresAtMs @param {string} signature @param {number} nowMs
|
|
445
|
+
* @returns {Promise<boolean>}
|
|
446
|
+
*/
|
|
447
|
+
export async function verifyPath(secret, accountId, relPath, expiresAtMs, signature, nowMs) {
|
|
448
|
+
if (!secret || !signature) return false
|
|
449
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
|
|
450
|
+
const expected = await signPath(secret, accountId, relPath, expiresAtMs)
|
|
451
|
+
return constantTimeEqual(expected, signature)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @param {string} origin @param {string} secret @param {string} accountId
|
|
456
|
+
* @param {string} relPath @param {number} expiresAtMs @returns {Promise<string>}
|
|
457
|
+
*/
|
|
458
|
+
export async function buildSignedUrl(origin, secret, accountId, relPath, expiresAtMs) {
|
|
459
|
+
const sig = await signPath(secret, accountId, relPath, expiresAtMs)
|
|
460
|
+
const url = new URL('/api/portal/fetch', origin)
|
|
461
|
+
url.searchParams.set('accountId', accountId)
|
|
462
|
+
url.searchParams.set('path', relPath)
|
|
463
|
+
url.searchParams.set('exp', String(expiresAtMs))
|
|
464
|
+
url.searchParams.set('sig', sig)
|
|
465
|
+
return url.toString()
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
- [ ] **Step 4: Run test and typecheck**
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-sign.test.ts && npm run typecheck:templates
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Expected: PASS, 7 tests; typecheck exit 0.
|
|
476
|
+
|
|
477
|
+
- [ ] **Step 5: Commit**
|
|
478
|
+
|
|
479
|
+
```bash
|
|
480
|
+
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts
|
|
481
|
+
git commit -m "feat(data-portal): five-minute signed links for device-held files"
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
### Task 3: D1 schema and enrolment binding
|
|
487
|
+
|
|
488
|
+
**Files:**
|
|
489
|
+
- Modify: `platform/plugins/cloudflare/skills/data-portal/schema.sql`
|
|
490
|
+
- Modify: `platform/plugins/cloudflare/bin/portal-enrol.mjs`
|
|
491
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts` (extend)
|
|
492
|
+
|
|
493
|
+
**Interfaces:**
|
|
494
|
+
- Produces: `directory(accountId, relPath, sizeBytes, modifiedAt, indexedAt)` with `UNIQUE (accountId, relPath)`; `people.accountId`; `portal-enrol.mjs --account <uuid>`.
|
|
495
|
+
|
|
496
|
+
- [ ] **Step 1: Write the failing test** (append to the existing describe block)
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
// platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts — append
|
|
500
|
+
it('emits accountId in the INSERT and the upsert', () => {
|
|
501
|
+
const out = runEnrol(['--owner', 'alice', '--name', 'Alice', '--account', 'acc-1'])
|
|
502
|
+
expect(out.stdout).toContain("INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId)")
|
|
503
|
+
expect(out.stdout).toContain("'acc-1'")
|
|
504
|
+
expect(out.stdout).toContain('accountId=excluded.accountId')
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it('refuses enrolment with no --account, because a person with no account sees nothing', () => {
|
|
508
|
+
const out = runEnrol(['--owner', 'alice', '--name', 'Alice'])
|
|
509
|
+
expect(out.status).not.toBe(0)
|
|
510
|
+
expect(out.stderr).toContain('--account')
|
|
511
|
+
})
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
If `runEnrol` does not already exist in that file, add it:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { spawnSync } from 'node:child_process'
|
|
518
|
+
import { resolve } from 'node:path'
|
|
519
|
+
|
|
520
|
+
function runEnrol(args: string[]) {
|
|
521
|
+
const script = resolve(__dirname, '../../bin/portal-enrol.mjs')
|
|
522
|
+
const r = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' })
|
|
523
|
+
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status }
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
528
|
+
|
|
529
|
+
```bash
|
|
530
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-enrol.test.ts
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Expected: FAIL — the INSERT has no `accountId`, and a missing `--account` currently exits 0.
|
|
534
|
+
|
|
535
|
+
- [ ] **Step 3a: Add the schema**
|
|
536
|
+
|
|
537
|
+
Append to `platform/plugins/cloudflare/skills/data-portal/schema.sql`:
|
|
538
|
+
|
|
539
|
+
```sql
|
|
540
|
+
-- One row per file the device has published to this portal. Metadata only: the
|
|
541
|
+
-- bytes stay on the device and are fetched on demand through a signed link, so
|
|
542
|
+
-- the folder tree still renders when the device is offline.
|
|
543
|
+
--
|
|
544
|
+
-- Written by a full replace per account (delete-all then insert), which is why
|
|
545
|
+
-- there is no tombstone column: a file deleted or renamed on the device simply
|
|
546
|
+
-- stops being inserted on the next cycle.
|
|
547
|
+
CREATE TABLE IF NOT EXISTS directory (
|
|
548
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
549
|
+
accountId TEXT NOT NULL,
|
|
550
|
+
relPath TEXT NOT NULL,
|
|
551
|
+
sizeBytes INTEGER NOT NULL,
|
|
552
|
+
modifiedAt TEXT NOT NULL,
|
|
553
|
+
indexedAt TEXT NOT NULL,
|
|
554
|
+
UNIQUE (accountId, relPath)
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
CREATE INDEX IF NOT EXISTS directory_account ON directory (accountId);
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Then change the `people` table in the same file to carry the account:
|
|
561
|
+
|
|
562
|
+
```sql
|
|
563
|
+
CREATE TABLE IF NOT EXISTS people (
|
|
564
|
+
ownerId TEXT PRIMARY KEY,
|
|
565
|
+
name TEXT NOT NULL,
|
|
566
|
+
salt TEXT NOT NULL,
|
|
567
|
+
hash TEXT NOT NULL,
|
|
568
|
+
createdAt TEXT NOT NULL,
|
|
569
|
+
accountId TEXT NOT NULL DEFAULT ''
|
|
570
|
+
);
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Add this note directly above that table, matching the file's existing habit of recording migration hazards inline:
|
|
574
|
+
|
|
575
|
+
```sql
|
|
576
|
+
-- `accountId` binds a person to the sub-account whose folders they may read
|
|
577
|
+
-- (Task 1831). It carries a DEFAULT so IF NOT EXISTS stays a no-op on a table
|
|
578
|
+
-- that predates it — but an existing table will NOT gain the column, and a
|
|
579
|
+
-- person whose row has the default empty value sees an empty folder list. Enrol
|
|
580
|
+
-- those people again; the upsert on ownerId makes that a re-run, not a migration.
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
- [ ] **Step 3b: Update the enrolment script**
|
|
584
|
+
|
|
585
|
+
In `platform/plugins/cloudflare/bin/portal-enrol.mjs`, after the existing `--name` handling, add:
|
|
586
|
+
|
|
587
|
+
```js
|
|
588
|
+
const accountId = arg('--account')
|
|
589
|
+
// A person with no account resolves to an empty folder list, which is
|
|
590
|
+
// indistinguishable from "this account has no deliverables". Refuse at
|
|
591
|
+
// enrolment rather than mint a row that fails silently later.
|
|
592
|
+
if (!accountId) die('--account <accountId> is required')
|
|
593
|
+
if (!/^[A-Za-z0-9-]+$/.test(accountId)) die('--account must be an account id')
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Replace the emitted statement with:
|
|
597
|
+
|
|
598
|
+
```js
|
|
599
|
+
console.log(
|
|
600
|
+
`INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId;`,
|
|
601
|
+
)
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
Update the usage comment at the top of the file to include `--account <accountId>`.
|
|
605
|
+
|
|
606
|
+
- [ ] **Step 4: Run tests**
|
|
607
|
+
|
|
608
|
+
```bash
|
|
609
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-enrol.test.ts
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Expected: PASS, including the pre-existing enrolment tests.
|
|
613
|
+
|
|
614
|
+
- [ ] **Step 5: Commit**
|
|
615
|
+
|
|
616
|
+
```bash
|
|
617
|
+
git add platform/plugins/cloudflare/skills/data-portal/schema.sql platform/plugins/cloudflare/bin/portal-enrol.mjs platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts
|
|
618
|
+
git commit -m "feat(data-portal): directory table and per-person account binding"
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
### Task 4: D1 over HTTP with bound parameters
|
|
624
|
+
|
|
625
|
+
The cloudflare plugin owns Cloudflare, so its D1 client lives here. `cf-exec.ts:256` spawns `wrangler` per call and takes raw SQL — the Task 1805 defect plus an injection risk on filenames — so it is not reused. Convergence is a follow-up (Task 1834).
|
|
626
|
+
|
|
627
|
+
**Files:**
|
|
628
|
+
- Create: `platform/plugins/cloudflare/bin/d1-http.mjs`
|
|
629
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts`
|
|
630
|
+
|
|
631
|
+
**Interfaces:**
|
|
632
|
+
- Produces: `createD1Client({ accountId, token, dbName, fetchFn? }): { query(sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>, batch(statements: {sql: string, params?: unknown[]}[]): Promise<void>, readonly statements: number }`
|
|
633
|
+
|
|
634
|
+
- [ ] **Step 1: Write the failing test**
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
// platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts
|
|
638
|
+
import { describe, it, expect } from 'vitest'
|
|
639
|
+
import { createD1Client } from '../../bin/d1-http.mjs'
|
|
640
|
+
|
|
641
|
+
type Call = { url: string; init: { method?: string; body?: string; headers?: Record<string, string> } }
|
|
642
|
+
|
|
643
|
+
function fakeFetch(calls: Call[], results: unknown[] = [{ results: [] }]) {
|
|
644
|
+
return async (url: string, init: Call['init']) => {
|
|
645
|
+
calls.push({ url, init })
|
|
646
|
+
if (url.includes('?name=')) {
|
|
647
|
+
return { ok: true, status: 200, json: async () => ({ result: [{ name: 'portal-db', uuid: 'db-uuid' }] }) }
|
|
648
|
+
}
|
|
649
|
+
return { ok: true, status: 200, json: async () => ({ result: results }) }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
describe('createD1Client', () => {
|
|
654
|
+
it('resolves the database by exact name then queries by uuid', async () => {
|
|
655
|
+
const calls: Call[] = []
|
|
656
|
+
const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fakeFetch(calls) as never })
|
|
657
|
+
await c.query('SELECT 1')
|
|
658
|
+
expect(calls[0].url).toContain('/accounts/acc/d1/database?name=portal-db')
|
|
659
|
+
expect(calls[1].url).toContain('/d1/database/db-uuid/query')
|
|
660
|
+
expect(calls[1].init.headers?.Authorization).toBe('Bearer t')
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it('sends values as params, never interpolated into sql', async () => {
|
|
664
|
+
const calls: Call[] = []
|
|
665
|
+
const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fakeFetch(calls) as never })
|
|
666
|
+
await c.query('INSERT INTO directory (relPath) VALUES (?)', ["o'brien.pdf"])
|
|
667
|
+
const body = JSON.parse(calls[1].init.body ?? '{}')
|
|
668
|
+
expect(body.sql).toBe('INSERT INTO directory (relPath) VALUES (?)')
|
|
669
|
+
expect(body.params).toEqual(["o'brien.pdf"])
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('caches the name lookup across statements', async () => {
|
|
673
|
+
const calls: Call[] = []
|
|
674
|
+
const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fakeFetch(calls) as never })
|
|
675
|
+
await c.query('SELECT 1')
|
|
676
|
+
await c.query('SELECT 2')
|
|
677
|
+
expect(calls.filter((x) => x.url.includes('?name=')).length).toBe(1)
|
|
678
|
+
expect(c.statements).toBe(2)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('throws with the response body when the api fails', async () => {
|
|
682
|
+
const fetchFn = async () => ({ ok: false, status: 403, json: async () => ({ errors: [{ message: 'nope' }] }) })
|
|
683
|
+
const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fetchFn as never })
|
|
684
|
+
await expect(c.query('SELECT 1')).rejects.toThrow(/403|nope/)
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('refuses a database whose name only nearly matches', async () => {
|
|
688
|
+
const fetchFn = async (url: string) => ({
|
|
689
|
+
ok: true, status: 200,
|
|
690
|
+
json: async () => (url.includes('?name=') ? { result: [{ name: 'portal-db-old', uuid: 'x' }] } : { result: [] }),
|
|
691
|
+
})
|
|
692
|
+
const c = createD1Client({ accountId: 'acc', token: 't', dbName: 'portal-db', fetchFn: fetchFn as never })
|
|
693
|
+
await expect(c.query('SELECT 1')).rejects.toThrow(/no D1 database named/)
|
|
694
|
+
})
|
|
695
|
+
})
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
699
|
+
|
|
700
|
+
```bash
|
|
701
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/d1-http.test.ts
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Expected: FAIL — module not found.
|
|
705
|
+
|
|
706
|
+
- [ ] **Step 3: Write the implementation**
|
|
707
|
+
|
|
708
|
+
```js
|
|
709
|
+
// platform/plugins/cloudflare/bin/d1-http.mjs
|
|
710
|
+
// D1 over the Cloudflare HTTP API, with bound parameters.
|
|
711
|
+
//
|
|
712
|
+
// Not `wrangler d1 execute`: that spawns a process per statement, which is the
|
|
713
|
+
// CPU-burn defect Task 1805 fixed, and it takes a raw SQL string, which would
|
|
714
|
+
// mean interpolating filenames into SQL. The index push writes one row per file,
|
|
715
|
+
// so both matter here.
|
|
716
|
+
//
|
|
717
|
+
// storage-broker's cf-exec.d1Query still spawns wrangler. Converging the two is
|
|
718
|
+
// Task 1834; this client does not depend on it.
|
|
719
|
+
|
|
720
|
+
const API = 'https://api.cloudflare.com/client/v4'
|
|
721
|
+
|
|
722
|
+
/** @param {unknown} parsed @param {number} status @param {string} label */
|
|
723
|
+
function assertOk(parsed, status, label) {
|
|
724
|
+
const errs = /** @type {{errors?: {message?: string}[]}} */ (parsed)?.errors
|
|
725
|
+
const detail = Array.isArray(errs) && errs.length ? errs.map((e) => e?.message).join('; ') : ''
|
|
726
|
+
// Never discard the body — a bare status turns every failure into the same
|
|
727
|
+
// unactionable line.
|
|
728
|
+
throw new Error(`${label} failed: HTTP ${status}${detail ? ` — ${detail}` : ''}`)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* @param {{ accountId: string, token: string, dbName: string,
|
|
733
|
+
* fetchFn?: (url: string, init: object) => Promise<{ ok: boolean, status: number, json: () => Promise<unknown> }> }} opts
|
|
734
|
+
*/
|
|
735
|
+
export function createD1Client(opts) {
|
|
736
|
+
const fetchFn = opts.fetchFn ?? /** @type {never} */ (globalThis.fetch)
|
|
737
|
+
const headers = { Authorization: `Bearer ${opts.token}`, 'Content-Type': 'application/json' }
|
|
738
|
+
/** @type {string | undefined} */ let databaseId
|
|
739
|
+
let statements = 0
|
|
740
|
+
|
|
741
|
+
async function resolveId() {
|
|
742
|
+
if (databaseId !== undefined) return databaseId
|
|
743
|
+
const url = `${API}/accounts/${opts.accountId}/d1/database?name=${encodeURIComponent(opts.dbName)}`
|
|
744
|
+
const res = await fetchFn(url, { method: 'GET', headers })
|
|
745
|
+
const parsed = await res.json()
|
|
746
|
+
if (!res.ok) assertOk(parsed, res.status, 'd1 database lookup')
|
|
747
|
+
const list = /** @type {{result?: {name?: string, uuid?: string}[]}} */ (parsed).result ?? []
|
|
748
|
+
// Exact name match, never "the first result": `?name=` is a filter whose
|
|
749
|
+
// matching rule is not documented, so position would risk running this
|
|
750
|
+
// account's statements against a near-named database.
|
|
751
|
+
const hit = list.find((d) => d.name === opts.dbName)
|
|
752
|
+
if (!hit || typeof hit.uuid !== 'string') {
|
|
753
|
+
throw new Error(`no D1 database named "${opts.dbName}" in account ${opts.accountId}`)
|
|
754
|
+
}
|
|
755
|
+
databaseId = hit.uuid
|
|
756
|
+
return databaseId
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** @param {string} sql @param {unknown[]} [params] */
|
|
760
|
+
async function query(sql, params = []) {
|
|
761
|
+
const id = await resolveId()
|
|
762
|
+
statements++
|
|
763
|
+
const res = await fetchFn(`${API}/accounts/${opts.accountId}/d1/database/${id}/query`, {
|
|
764
|
+
method: 'POST',
|
|
765
|
+
headers,
|
|
766
|
+
body: JSON.stringify({ sql, params }),
|
|
767
|
+
})
|
|
768
|
+
const parsed = await res.json()
|
|
769
|
+
if (!res.ok) assertOk(parsed, res.status, 'd1 query')
|
|
770
|
+
const first = (/** @type {{result?: {results?: unknown}[]}} */ (parsed).result ?? [])[0]
|
|
771
|
+
return /** @type {Record<string, unknown>[]} */ (first?.results ?? [])
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
query,
|
|
776
|
+
/** @param {{sql: string, params?: unknown[]}[]} stmts */
|
|
777
|
+
async batch(stmts) {
|
|
778
|
+
for (const s of stmts) await query(s.sql, s.params ?? [])
|
|
779
|
+
},
|
|
780
|
+
get statements() {
|
|
781
|
+
return statements
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
788
|
+
|
|
789
|
+
```bash
|
|
790
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/d1-http.test.ts
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Expected: PASS, 5 tests.
|
|
794
|
+
|
|
795
|
+
- [ ] **Step 5: Commit**
|
|
796
|
+
|
|
797
|
+
```bash
|
|
798
|
+
git add platform/plugins/cloudflare/bin/d1-http.mjs platform/plugins/cloudflare/mcp/__tests__/d1-http.test.ts
|
|
799
|
+
git commit -m "feat(data-portal): D1 http client with bound params for the index push"
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
---
|
|
803
|
+
|
|
804
|
+
### Task 5: The index push
|
|
805
|
+
|
|
806
|
+
**Files:**
|
|
807
|
+
- Create: `platform/plugins/cloudflare/bin/portal-index-push.mjs`
|
|
808
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`
|
|
809
|
+
|
|
810
|
+
**Interfaces:**
|
|
811
|
+
- Consumes: `resolveExposedDirs` (Task 1), `createD1Client` (Task 4).
|
|
812
|
+
- Produces: `walkExposed(accountDir: string, exposed: string[]): Promise<{relPath: string, sizeBytes: number, modifiedAt: string}[]>` and `pushAccount({ accountDir, accountId, dbName, client, log, nowIso }): Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>`
|
|
813
|
+
|
|
814
|
+
- [ ] **Step 1: Write the failing test**
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
// platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
|
|
818
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
819
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
820
|
+
import { tmpdir } from 'node:os'
|
|
821
|
+
import { join } from 'node:path'
|
|
822
|
+
import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
|
|
823
|
+
|
|
824
|
+
let dir: string
|
|
825
|
+
|
|
826
|
+
function file(rel: string, body = 'x') {
|
|
827
|
+
const abs = join(dir, rel)
|
|
828
|
+
mkdirSync(join(abs, '..'), { recursive: true })
|
|
829
|
+
writeFileSync(abs, body)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const SCHEMA = [
|
|
833
|
+
'```allowed-top-level',
|
|
834
|
+
'output', 'quotes', 'documents', 'uploads',
|
|
835
|
+
'```',
|
|
836
|
+
'',
|
|
837
|
+
'<!-- ontology-buckets:start -->',
|
|
838
|
+
'- `quotes/` - one folder per Quotation record.',
|
|
839
|
+
'<!-- ontology-buckets:end -->',
|
|
840
|
+
].join('\n')
|
|
841
|
+
|
|
842
|
+
beforeEach(() => {
|
|
843
|
+
dir = mkdtempSync(join(tmpdir(), 'portal-push-'))
|
|
844
|
+
writeFileSync(join(dir, 'SCHEMA.md'), SCHEMA)
|
|
845
|
+
file('output/report.pdf')
|
|
846
|
+
file('quotes/CR2969/quote.pdf')
|
|
847
|
+
file('quotes/CR2974/quote.pdf')
|
|
848
|
+
file('documents/private.pdf')
|
|
849
|
+
file('uploads/client-sent.pdf')
|
|
850
|
+
file('.claude/settings.json')
|
|
851
|
+
file('output/.uploads-tmp/partial.bin')
|
|
852
|
+
})
|
|
853
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
854
|
+
|
|
855
|
+
function fakeClient() {
|
|
856
|
+
const calls: { sql: string; params: unknown[] }[] = []
|
|
857
|
+
return {
|
|
858
|
+
calls,
|
|
859
|
+
async query(sql: string, params: unknown[] = []) {
|
|
860
|
+
calls.push({ sql, params })
|
|
861
|
+
if (/SELECT COUNT/i.test(sql)) return [{ n: calls.filter((c) => /INSERT/i.test(c.sql)).length }]
|
|
862
|
+
return []
|
|
863
|
+
},
|
|
864
|
+
async batch(stmts: { sql: string; params?: unknown[] }[]) {
|
|
865
|
+
for (const s of stmts) await this.query(s.sql, s.params ?? [])
|
|
866
|
+
},
|
|
867
|
+
statements: 0,
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
describe('walkExposed', () => {
|
|
872
|
+
it('returns files only from exposed dirs, at any depth', async () => {
|
|
873
|
+
const rows = await walkExposed(dir, ['output', 'quotes'])
|
|
874
|
+
const paths = rows.map((r) => r.relPath).sort()
|
|
875
|
+
expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
it('never returns operator data, uploads, dot dirs or upload temps', async () => {
|
|
879
|
+
const paths = (await walkExposed(dir, ['output', 'quotes'])).map((r) => r.relPath)
|
|
880
|
+
expect(paths.some((p) => p.startsWith('documents/'))).toBe(false)
|
|
881
|
+
expect(paths.some((p) => p.startsWith('uploads/'))).toBe(false)
|
|
882
|
+
expect(paths.some((p) => p.includes('.uploads-tmp'))).toBe(false)
|
|
883
|
+
expect(paths.some((p) => p.includes('.claude'))).toBe(false)
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it('returns nothing for a dir that does not exist', async () => {
|
|
887
|
+
expect(await walkExposed(dir, ['nope'])).toEqual([])
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('carries size and an iso modified time', async () => {
|
|
891
|
+
const row = (await walkExposed(dir, ['output']))[0]
|
|
892
|
+
expect(row.sizeBytes).toBe(1)
|
|
893
|
+
expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
describe('pushAccount', () => {
|
|
898
|
+
it('deletes the account rows then inserts the current walk', async () => {
|
|
899
|
+
const c = fakeClient()
|
|
900
|
+
const r = await pushAccount({ accountDir: dir, accountId: 'acc-1', dbName: 'db', client: c as never, log: () => {}, nowIso: '2026-07-20T00:00:00.000Z' })
|
|
901
|
+
expect(r.exposed).toEqual(['output', 'quotes'])
|
|
902
|
+
expect(r.rows).toBe(3)
|
|
903
|
+
expect(/DELETE FROM directory/i.test(c.calls[0].sql)).toBe(true)
|
|
904
|
+
expect(c.calls[0].params).toEqual(['acc-1'])
|
|
905
|
+
expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(3)
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('exposes nothing and writes nothing when the schema is missing', async () => {
|
|
909
|
+
rmSync(join(dir, 'SCHEMA.md'))
|
|
910
|
+
const c = fakeClient()
|
|
911
|
+
const r = await pushAccount({ accountDir: dir, accountId: 'acc-1', dbName: 'db', client: c as never, log: () => {}, nowIso: 'now' })
|
|
912
|
+
expect(r.schemaPresent).toBe(false)
|
|
913
|
+
expect(r.exposed).toEqual([])
|
|
914
|
+
expect(c.calls.length).toBe(0)
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
it('logs resolve, replace and verify lines', async () => {
|
|
918
|
+
const lines: string[] = []
|
|
919
|
+
const c = fakeClient()
|
|
920
|
+
await pushAccount({ accountDir: dir, accountId: 'acc-1', dbName: 'db', client: c as never, log: (l: string) => lines.push(l), nowIso: 'now' })
|
|
921
|
+
expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
|
|
922
|
+
expect(lines.some((l) => l.includes('op=replace') && l.includes('inserted=3'))).toBe(true)
|
|
923
|
+
expect(lines.some((l) => l.includes('op=verify'))).toBe(true)
|
|
924
|
+
})
|
|
925
|
+
})
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
929
|
+
|
|
930
|
+
```bash
|
|
931
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-index-push.test.ts
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
Expected: FAIL — module not found.
|
|
935
|
+
|
|
936
|
+
- [ ] **Step 3: Write the implementation**
|
|
937
|
+
|
|
938
|
+
```js
|
|
939
|
+
// platform/plugins/cloudflare/bin/portal-index-push.mjs
|
|
940
|
+
// Publish a sub-account's deliverable file INDEX to its portal's D1.
|
|
941
|
+
//
|
|
942
|
+
// Metadata only. The bytes stay on the device and are fetched on demand through
|
|
943
|
+
// a signed link, which is what lets the portal render the folder tree while the
|
|
944
|
+
// device is offline.
|
|
945
|
+
//
|
|
946
|
+
// Full replace per account rather than a diff: a delete or a rename on the
|
|
947
|
+
// device converges on the next cycle with no tombstone protocol to get wrong.
|
|
948
|
+
//
|
|
949
|
+
// Runnable standalone as well as from the heartbeat, because the heartbeat spawn
|
|
950
|
+
// this copies has never been observed running on a device (Task 1828).
|
|
951
|
+
//
|
|
952
|
+
// node portal-index-push.mjs --account-dir <dir> --account <id> --db <name> --token <cf-token>
|
|
953
|
+
|
|
954
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
955
|
+
import { join, resolve } from 'node:path'
|
|
956
|
+
import { resolveExposedDirs } from './schema-exposed-dirs.mjs'
|
|
957
|
+
import { createD1Client } from './d1-http.mjs'
|
|
958
|
+
|
|
959
|
+
const TAG = '[portal-index]'
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Files under `exposed` dirs, at any depth. Dot-prefixed entries are skipped at
|
|
963
|
+
* every level: `.uploads-tmp/` holds in-flight upload partials (files.ts:25) and
|
|
964
|
+
* must never be published as a deliverable.
|
|
965
|
+
* @param {string} accountDir @param {string[]} exposed
|
|
966
|
+
* @returns {Promise<{relPath: string, sizeBytes: number, modifiedAt: string}[]>}
|
|
967
|
+
*/
|
|
968
|
+
export async function walkExposed(accountDir, exposed) {
|
|
969
|
+
/** @type {{relPath: string, sizeBytes: number, modifiedAt: string}[]} */
|
|
970
|
+
const out = []
|
|
971
|
+
|
|
972
|
+
/** @param {string} rel */
|
|
973
|
+
async function walk(rel) {
|
|
974
|
+
/** @type {import('node:fs').Dirent[]} */ let entries
|
|
975
|
+
try {
|
|
976
|
+
entries = await readdir(join(accountDir, rel), { withFileTypes: true })
|
|
977
|
+
} catch {
|
|
978
|
+
return // absent or unreadable dir contributes nothing
|
|
979
|
+
}
|
|
980
|
+
for (const e of entries) {
|
|
981
|
+
if (e.name.startsWith('.')) continue
|
|
982
|
+
const childRel = `${rel}/${e.name}`
|
|
983
|
+
if (e.isDirectory()) {
|
|
984
|
+
await walk(childRel)
|
|
985
|
+
} else if (e.isFile()) {
|
|
986
|
+
try {
|
|
987
|
+
const st = await stat(join(accountDir, childRel))
|
|
988
|
+
out.push({ relPath: childRel, sizeBytes: st.size, modifiedAt: st.mtime.toISOString() })
|
|
989
|
+
} catch {
|
|
990
|
+
// vanished mid-walk; the next cycle settles it
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
for (const dir of exposed) await walk(dir)
|
|
997
|
+
return out
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* @param {{ accountDir: string, accountId: string, dbName: string,
|
|
1002
|
+
* client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
|
|
1003
|
+
* log: (line: string) => void, nowIso: string }} opts
|
|
1004
|
+
* @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
|
|
1005
|
+
*/
|
|
1006
|
+
export async function pushAccount(opts) {
|
|
1007
|
+
const { accountDir, accountId, client, log, nowIso } = opts
|
|
1008
|
+
|
|
1009
|
+
/** @type {string | null} */ let schemaText = null
|
|
1010
|
+
try {
|
|
1011
|
+
schemaText = await readFile(join(accountDir, 'SCHEMA.md'), 'utf8')
|
|
1012
|
+
} catch {
|
|
1013
|
+
schemaText = null
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const r = resolveExposedDirs(schemaText)
|
|
1017
|
+
log(
|
|
1018
|
+
`${TAG} op=resolve account=${accountId} schemaPresent=${r.schemaPresent} allowed=${r.allowed.length} ` +
|
|
1019
|
+
`domainBuckets=${r.domainBuckets.join(',') || 'none'} exposed=${r.exposed.join(',') || 'none'}`,
|
|
1020
|
+
)
|
|
1021
|
+
// A dir the generator gave to a plugin never reaches the ontology region
|
|
1022
|
+
// (account-schema-owned-dirs.py:287), so this is normally silent. When it is
|
|
1023
|
+
// not, a bucket has silently vanished from the client's view and nothing else
|
|
1024
|
+
// would say so.
|
|
1025
|
+
for (const c of r.collisions) {
|
|
1026
|
+
log(`${TAG} op=collision account=${accountId} dir=${c} claimedBy=plugin-owned`)
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Fail closed: no schema means expose nothing, and writing nothing is the
|
|
1030
|
+
// correct outcome — NOT a full delete, which would look identical to a
|
|
1031
|
+
// successful empty publish.
|
|
1032
|
+
if (!r.schemaPresent || r.exposed.length === 0) return { exposed: r.exposed, rows: 0, schemaPresent: r.schemaPresent }
|
|
1033
|
+
|
|
1034
|
+
const rows = await walkExposed(accountDir, r.exposed)
|
|
1035
|
+
|
|
1036
|
+
await client.query('DELETE FROM directory WHERE accountId = ?', [accountId])
|
|
1037
|
+
for (const row of rows) {
|
|
1038
|
+
await client.query(
|
|
1039
|
+
'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt) VALUES (?, ?, ?, ?, ?)',
|
|
1040
|
+
[accountId, row.relPath, row.sizeBytes, row.modifiedAt, nowIso],
|
|
1041
|
+
)
|
|
1042
|
+
}
|
|
1043
|
+
log(`${TAG} op=replace account=${accountId} deleted=all inserted=${rows.length}`)
|
|
1044
|
+
|
|
1045
|
+
// Read back rather than trusting the write: "issued the inserts" is not "the
|
|
1046
|
+
// rows are there", and a half-applied batch is otherwise invisible.
|
|
1047
|
+
const check = await client.query('SELECT COUNT(*) AS n FROM directory WHERE accountId = ?', [accountId])
|
|
1048
|
+
const after = Number(check[0]?.n ?? -1)
|
|
1049
|
+
log(`${TAG} op=verify account=${accountId} rowsAfter=${after} expected=${rows.length}`)
|
|
1050
|
+
|
|
1051
|
+
return { exposed: r.exposed, rows: rows.length, schemaPresent: true }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** @param {string} flag */
|
|
1055
|
+
function arg(flag) {
|
|
1056
|
+
const i = process.argv.indexOf(flag)
|
|
1057
|
+
return i === -1 ? undefined : process.argv[i + 1]
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Only runs when invoked directly, so the module stays importable by tests.
|
|
1061
|
+
if (process.argv[1] && resolve(process.argv[1]).endsWith('portal-index-push.mjs')) {
|
|
1062
|
+
const accountDir = arg('--account-dir')
|
|
1063
|
+
const accountId = arg('--account')
|
|
1064
|
+
const dbName = arg('--db')
|
|
1065
|
+
const token = arg('--token') ?? process.env.CLOUDFLARE_API_TOKEN
|
|
1066
|
+
if (!accountDir || !accountId || !dbName || !token) {
|
|
1067
|
+
console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name> --token <cf-token>`)
|
|
1068
|
+
process.exit(1)
|
|
1069
|
+
}
|
|
1070
|
+
const client = createD1Client({ accountId, token, dbName })
|
|
1071
|
+
pushAccount({ accountDir, accountId, dbName, client, log: (l) => console.error(l), nowIso: new Date().toISOString() })
|
|
1072
|
+
.then((r) => {
|
|
1073
|
+
console.error(`${TAG} op=done account=${accountId} rows=${r.rows}`)
|
|
1074
|
+
})
|
|
1075
|
+
.catch((err) => {
|
|
1076
|
+
console.error(`${TAG} op=failed account=${accountId} err="${err instanceof Error ? err.message : String(err)}"`)
|
|
1077
|
+
process.exit(1)
|
|
1078
|
+
})
|
|
1079
|
+
}
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
1083
|
+
|
|
1084
|
+
```bash
|
|
1085
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-index-push.test.ts
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
Expected: PASS, 7 tests.
|
|
1089
|
+
|
|
1090
|
+
- [ ] **Step 5: Commit**
|
|
1091
|
+
|
|
1092
|
+
```bash
|
|
1093
|
+
git add platform/plugins/cloudflare/bin/portal-index-push.mjs platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
|
|
1094
|
+
git commit -m "feat(data-portal): device-side index push over exposed dirs only"
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
### Task 6: Portal listing returns folder entries
|
|
1100
|
+
|
|
1101
|
+
**Files:**
|
|
1102
|
+
- Modify: `.../template/functions/api/_lib/session.ts` (`resolveSession` returns `accountId`)
|
|
1103
|
+
- Modify: `.../template/functions/api/files.ts`
|
|
1104
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts`
|
|
1105
|
+
|
|
1106
|
+
**Interfaces:**
|
|
1107
|
+
- Consumes: `directory` table (Task 3), `people.accountId` (Task 3).
|
|
1108
|
+
- Produces: `processFiles(sessionId, env, log, nowMs, prefix?)` returning payload `{ ok, files, entries }` where `entries: { name: string, kind: 'file' | 'directory', relPath: string, sizeBytes: number | null, modifiedAt: string | null }[]`.
|
|
1109
|
+
|
|
1110
|
+
- [ ] **Step 1: Write the failing test**
|
|
1111
|
+
|
|
1112
|
+
```ts
|
|
1113
|
+
// platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts
|
|
1114
|
+
import { describe, it, expect } from 'vitest'
|
|
1115
|
+
import { processFiles } from '../../skills/data-portal/template/functions/api/files'
|
|
1116
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
1117
|
+
|
|
1118
|
+
const DIRECTORY = [
|
|
1119
|
+
{ accountId: 'acc-a', relPath: 'output/report.pdf', sizeBytes: 10, modifiedAt: '2026-07-20T10:00:00.000Z' },
|
|
1120
|
+
{ accountId: 'acc-a', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
|
|
1121
|
+
{ accountId: 'acc-a', relPath: 'quotes/CR2974/quote.pdf', sizeBytes: 30, modifiedAt: '2026-07-20T12:00:00.000Z' },
|
|
1122
|
+
{ accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
|
|
1123
|
+
]
|
|
1124
|
+
|
|
1125
|
+
function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }>) {
|
|
1126
|
+
return {
|
|
1127
|
+
DB: {
|
|
1128
|
+
prepare(query: string) {
|
|
1129
|
+
let bound: unknown[] = []
|
|
1130
|
+
const stmt = {
|
|
1131
|
+
bind(...v: unknown[]) { bound = v; return stmt },
|
|
1132
|
+
async run() { return { meta: { changes: 0 } } },
|
|
1133
|
+
async all<T>() {
|
|
1134
|
+
if (/FROM directory/i.test(query)) {
|
|
1135
|
+
return { results: DIRECTORY.filter((d) => d.accountId === bound[0]) as T[] }
|
|
1136
|
+
}
|
|
1137
|
+
return { results: [] as T[] }
|
|
1138
|
+
},
|
|
1139
|
+
async first<T>() {
|
|
1140
|
+
if (/FROM sessions/i.test(query)) return (sessions[bound[0] as string] ?? null) as T | null
|
|
1141
|
+
return null as T | null
|
|
1142
|
+
},
|
|
1143
|
+
}
|
|
1144
|
+
return stmt
|
|
1145
|
+
},
|
|
1146
|
+
},
|
|
1147
|
+
} as unknown as PortalEnv
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a' } }
|
|
1151
|
+
|
|
1152
|
+
describe('portal directory listing', () => {
|
|
1153
|
+
it('synthesises top-level folders from row paths', async () => {
|
|
1154
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
|
|
1155
|
+
const entries = r.payload.entries as { name: string; kind: string }[]
|
|
1156
|
+
expect(entries.map((e) => e.name).sort()).toEqual(['output', 'quotes'])
|
|
1157
|
+
expect(entries.every((e) => e.kind === 'directory')).toBe(true)
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('lists folders inside a prefix', async () => {
|
|
1161
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, 'quotes')
|
|
1162
|
+
const entries = r.payload.entries as { name: string; kind: string }[]
|
|
1163
|
+
expect(entries.map((e) => e.name).sort()).toEqual(['CR2969', 'CR2974'])
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
it('lists files inside a leaf folder with size and time', async () => {
|
|
1167
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, 'quotes/CR2969')
|
|
1168
|
+
const entries = r.payload.entries as { name: string; kind: string; relPath: string; sizeBytes: number }[]
|
|
1169
|
+
expect(entries).toEqual([
|
|
1170
|
+
{ name: 'quote.pdf', kind: 'file', relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' },
|
|
1171
|
+
])
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('never leaks another account rows', async () => {
|
|
1175
|
+
const r = await processFiles('sess-a', makeEnv(SESSIONS), () => {}, 0, '')
|
|
1176
|
+
expect(JSON.stringify(r.payload)).not.toContain('secret.pdf')
|
|
1177
|
+
})
|
|
1178
|
+
|
|
1179
|
+
it('returns an empty entries list when the person has no account bound', async () => {
|
|
1180
|
+
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: '' } })
|
|
1181
|
+
const r = await processFiles('sess-a', env, () => {}, 0, '')
|
|
1182
|
+
expect(r.status).toBe(200)
|
|
1183
|
+
expect(r.payload.entries).toEqual([])
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('logs the account and the prefix', async () => {
|
|
1187
|
+
const lines: string[] = []
|
|
1188
|
+
await processFiles('sess-a', makeEnv(SESSIONS), (l) => lines.push(l), 0, 'quotes')
|
|
1189
|
+
expect(lines.some((l) => l.includes('account=acc-a') && l.includes('prefix=quotes'))).toBe(true)
|
|
1190
|
+
})
|
|
1191
|
+
})
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1195
|
+
|
|
1196
|
+
```bash
|
|
1197
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-directory-listing.test.ts
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
Expected: FAIL — `processFiles` takes four arguments and returns no `entries`.
|
|
1201
|
+
|
|
1202
|
+
- [ ] **Step 3a: Widen `resolveSession`**
|
|
1203
|
+
|
|
1204
|
+
In `_lib/session.ts`, change the query and return type so the session carries the account it may read:
|
|
1205
|
+
|
|
1206
|
+
```ts
|
|
1207
|
+
export async function resolveSession(
|
|
1208
|
+
db: D1Database,
|
|
1209
|
+
sessionId: string,
|
|
1210
|
+
nowMs: number,
|
|
1211
|
+
): Promise<{ ownerId: string; accountId: string } | null> {
|
|
1212
|
+
if (!sessionId) return null
|
|
1213
|
+
const row = await db
|
|
1214
|
+
.prepare(
|
|
1215
|
+
`SELECT s.ownerId AS ownerId, p.accountId AS accountId
|
|
1216
|
+
FROM sessions s
|
|
1217
|
+
JOIN people p ON p.ownerId = s.ownerId
|
|
1218
|
+
WHERE s.sessionId = ?
|
|
1219
|
+
AND s.expiresAt > ?
|
|
1220
|
+
AND s.pcCheck = substr(p.hash, 1, 16)`,
|
|
1221
|
+
)
|
|
1222
|
+
.bind(sessionId, nowMs)
|
|
1223
|
+
.first<{ ownerId: string; accountId: string }>()
|
|
1224
|
+
if (!row || typeof row.ownerId !== 'string') return null
|
|
1225
|
+
// A row predating Task 1831 has no accountId. Treat it as "no folders", never
|
|
1226
|
+
// as "all folders".
|
|
1227
|
+
return { ownerId: row.ownerId, accountId: typeof row.accountId === 'string' ? row.accountId : '' }
|
|
1228
|
+
}
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
- [ ] **Step 3b: Return entries from `files.ts`**
|
|
1232
|
+
|
|
1233
|
+
Replace the body of `processFiles` and add the folder synthesis:
|
|
1234
|
+
|
|
1235
|
+
```ts
|
|
1236
|
+
interface DirRow {
|
|
1237
|
+
relPath: string
|
|
1238
|
+
sizeBytes: number
|
|
1239
|
+
modifiedAt: string
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
export interface DirEntry {
|
|
1243
|
+
name: string
|
|
1244
|
+
kind: 'file' | 'directory'
|
|
1245
|
+
relPath: string
|
|
1246
|
+
sizeBytes: number | null
|
|
1247
|
+
modifiedAt: string | null
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Collapse flat `directory` rows into one level of listing under `prefix`.
|
|
1252
|
+
* Folders are synthesised from path segments rather than stored, so the device
|
|
1253
|
+
* publishes files alone and an empty folder simply does not exist.
|
|
1254
|
+
*/
|
|
1255
|
+
export function entriesUnder(rows: DirRow[], prefix: string): DirEntry[] {
|
|
1256
|
+
const base = prefix ? `${prefix}/` : ''
|
|
1257
|
+
const dirs = new Map<string, DirEntry>()
|
|
1258
|
+
const files: DirEntry[] = []
|
|
1259
|
+
for (const row of rows) {
|
|
1260
|
+
if (base && !row.relPath.startsWith(base)) continue
|
|
1261
|
+
const rest = row.relPath.slice(base.length)
|
|
1262
|
+
if (!rest) continue
|
|
1263
|
+
const slash = rest.indexOf('/')
|
|
1264
|
+
if (slash === -1) {
|
|
1265
|
+
files.push({
|
|
1266
|
+
name: rest,
|
|
1267
|
+
kind: 'file',
|
|
1268
|
+
relPath: row.relPath,
|
|
1269
|
+
sizeBytes: row.sizeBytes,
|
|
1270
|
+
modifiedAt: row.modifiedAt,
|
|
1271
|
+
})
|
|
1272
|
+
} else {
|
|
1273
|
+
const name = rest.slice(0, slash)
|
|
1274
|
+
if (!dirs.has(name)) {
|
|
1275
|
+
dirs.set(name, { name, kind: 'directory', relPath: `${base}${name}`, sizeBytes: null, modifiedAt: null })
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
const dirList = [...dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
1280
|
+
files.sort((a, b) => (b.modifiedAt ?? '').localeCompare(a.modifiedAt ?? '') || a.name.localeCompare(b.name))
|
|
1281
|
+
return [...dirList, ...files]
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
export async function processFiles(
|
|
1285
|
+
sessionId: string,
|
|
1286
|
+
env: PortalEnv,
|
|
1287
|
+
log: Logger,
|
|
1288
|
+
nowMs: number,
|
|
1289
|
+
prefix = '',
|
|
1290
|
+
): Promise<Handler> {
|
|
1291
|
+
const session = await resolveSession(env.DB, sessionId, nowMs)
|
|
1292
|
+
if (!session) {
|
|
1293
|
+
log('[data-portal] op=list owner=none result=denied')
|
|
1294
|
+
return { status: 401, payload: { ok: false, error: 'denied' } }
|
|
1295
|
+
}
|
|
1296
|
+
// Scoped by ownerId, not by key prefix: the owner is the authority.
|
|
1297
|
+
const rows = await env.DB.prepare(
|
|
1298
|
+
'SELECT fileId, filename, objectKey, size, uploadedAt, ingested FROM manifest WHERE ownerId = ? ORDER BY uploadedAt DESC',
|
|
1299
|
+
)
|
|
1300
|
+
.bind(session.ownerId)
|
|
1301
|
+
.all<Record<string, unknown>>()
|
|
1302
|
+
const files = rows.results ?? []
|
|
1303
|
+
|
|
1304
|
+
// No account bound means no folders. An enrolment predating Task 1831 lands
|
|
1305
|
+
// here; the missing `account=` field in the log line is what says so.
|
|
1306
|
+
let entries: DirEntry[] = []
|
|
1307
|
+
if (session.accountId) {
|
|
1308
|
+
const dir = await env.DB.prepare(
|
|
1309
|
+
'SELECT relPath, sizeBytes, modifiedAt FROM directory WHERE accountId = ?',
|
|
1310
|
+
)
|
|
1311
|
+
.bind(session.accountId)
|
|
1312
|
+
.all<DirRow>()
|
|
1313
|
+
entries = entriesUnder(dir.results ?? [], prefix)
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
log(
|
|
1317
|
+
`[data-portal] op=list owner=${q(session.ownerId)} account=${q(session.accountId)} ` +
|
|
1318
|
+
`prefix=${q(prefix)} files=${files.length} entries=${entries.length} result=ok`,
|
|
1319
|
+
)
|
|
1320
|
+
return { status: 200, payload: { ok: true, files, entries } }
|
|
1321
|
+
}
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
Then thread the prefix through the route handler:
|
|
1325
|
+
|
|
1326
|
+
```ts
|
|
1327
|
+
export async function onRequestGet(context: PagesContext): Promise<Response> {
|
|
1328
|
+
const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
|
|
1329
|
+
const prefix = new URL(context.request.url).searchParams.get('prefix') ?? ''
|
|
1330
|
+
const { status, payload } = await processFiles(
|
|
1331
|
+
sessionId,
|
|
1332
|
+
context.env,
|
|
1333
|
+
(line) => console.log(line),
|
|
1334
|
+
Date.now(),
|
|
1335
|
+
prefix,
|
|
1336
|
+
)
|
|
1337
|
+
return Response.json(payload, { status })
|
|
1338
|
+
}
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
- [ ] **Step 4: Run the new test and the existing suite**
|
|
1342
|
+
|
|
1343
|
+
```bash
|
|
1344
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run && npm run typecheck:templates
|
|
1345
|
+
```
|
|
1346
|
+
|
|
1347
|
+
Expected: all files pass; the pre-existing `file-routes.test.ts` still green (its fake `first()` returns `{ownerId}` with no `accountId`, which the widened resolver tolerates); typecheck exit 0.
|
|
1348
|
+
|
|
1349
|
+
- [ ] **Step 5: Commit**
|
|
1350
|
+
|
|
1351
|
+
```bash
|
|
1352
|
+
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts
|
|
1353
|
+
git commit -m "feat(data-portal): list the account's indexed folders beside uploads"
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
---
|
|
1357
|
+
|
|
1358
|
+
### Task 7: Download branch for indexed files
|
|
1359
|
+
|
|
1360
|
+
**Files:**
|
|
1361
|
+
- Modify: `.../template/functions/api/_lib/types.ts`
|
|
1362
|
+
- Modify: `.../template/functions/api/download.ts`
|
|
1363
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts`
|
|
1364
|
+
|
|
1365
|
+
**Interfaces:**
|
|
1366
|
+
- Consumes: `buildSignedUrl`, `SIGNED_TTL_MS` (Task 2); `resolveSession` returning `accountId` (Task 6).
|
|
1367
|
+
- Produces: `processIndexedDownload(sessionId, relPath, env, log, nowMs, fetchFn): Promise<Handler & { url?: string }>`.
|
|
1368
|
+
|
|
1369
|
+
- [ ] **Step 1: Write the failing test**
|
|
1370
|
+
|
|
1371
|
+
```ts
|
|
1372
|
+
// platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts
|
|
1373
|
+
import { describe, it, expect } from 'vitest'
|
|
1374
|
+
import { processIndexedDownload } from '../../skills/data-portal/template/functions/api/download'
|
|
1375
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
1376
|
+
|
|
1377
|
+
const ROW = { relPath: 'quotes/CR2969/quote.pdf', sizeBytes: 20, modifiedAt: '2026-07-20T11:00:00.000Z' }
|
|
1378
|
+
|
|
1379
|
+
function makeEnv(rows = [ROW], accountId = 'acc-a') {
|
|
1380
|
+
return {
|
|
1381
|
+
DB: {
|
|
1382
|
+
prepare(query: string) {
|
|
1383
|
+
let bound: unknown[] = []
|
|
1384
|
+
const stmt = {
|
|
1385
|
+
bind(...v: unknown[]) { bound = v; return stmt },
|
|
1386
|
+
async run() { return { meta: { changes: 0 } } },
|
|
1387
|
+
async all<T>() { return { results: [] as T[] } },
|
|
1388
|
+
async first<T>() {
|
|
1389
|
+
if (/FROM sessions/i.test(query)) {
|
|
1390
|
+
return (bound[0] === 'sess-a' ? { ownerId: 'alice', accountId } : null) as T | null
|
|
1391
|
+
}
|
|
1392
|
+
if (/FROM directory/i.test(query)) {
|
|
1393
|
+
return (rows.find((r) => r.relPath === bound[1]) ?? null) as T | null
|
|
1394
|
+
}
|
|
1395
|
+
return null as T | null
|
|
1396
|
+
},
|
|
1397
|
+
}
|
|
1398
|
+
return stmt
|
|
1399
|
+
},
|
|
1400
|
+
},
|
|
1401
|
+
PORTAL_FETCH_SECRET: 'secret',
|
|
1402
|
+
PORTAL_INSTALL_ORIGIN: 'https://install.example',
|
|
1403
|
+
} as unknown as PortalEnv
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const okHead = async () => ({ ok: true, status: 200 })
|
|
1407
|
+
const deadHead = async () => { throw new Error('ECONNREFUSED') }
|
|
1408
|
+
|
|
1409
|
+
describe('processIndexedDownload', () => {
|
|
1410
|
+
it('returns a signed url for an indexed file when the device answers', async () => {
|
|
1411
|
+
const r = await processIndexedDownload('sess-a', ROW.relPath, makeEnv(), () => {}, 1000, okHead as never)
|
|
1412
|
+
expect(r.status).toBe(200)
|
|
1413
|
+
const url = new URL(r.url as string)
|
|
1414
|
+
expect(url.origin).toBe('https://install.example')
|
|
1415
|
+
expect(url.searchParams.get('path')).toBe(ROW.relPath)
|
|
1416
|
+
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
1417
|
+
expect(Number(url.searchParams.get('exp'))).toBeGreaterThan(1000)
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
it('reports device-offline distinguishably when the head fails', async () => {
|
|
1421
|
+
const r = await processIndexedDownload('sess-a', ROW.relPath, makeEnv(), () => {}, 1000, deadHead as never)
|
|
1422
|
+
expect(r.status).toBe(503)
|
|
1423
|
+
expect(r.payload.error).toBe('device-offline')
|
|
1424
|
+
expect(r.url).toBeUndefined()
|
|
1425
|
+
})
|
|
1426
|
+
|
|
1427
|
+
it('404s a path that is not in the index, without contacting the device', async () => {
|
|
1428
|
+
let touched = false
|
|
1429
|
+
const spy = async () => { touched = true; return { ok: true, status: 200 } }
|
|
1430
|
+
const r = await processIndexedDownload('sess-a', 'documents/private.pdf', makeEnv(), () => {}, 1000, spy as never)
|
|
1431
|
+
expect(r.status).toBe(404)
|
|
1432
|
+
expect(touched).toBe(false)
|
|
1433
|
+
})
|
|
1434
|
+
|
|
1435
|
+
it('401s without a session', async () => {
|
|
1436
|
+
const r = await processIndexedDownload('nope', ROW.relPath, makeEnv(), () => {}, 1000, okHead as never)
|
|
1437
|
+
expect(r.status).toBe(401)
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
it('404s when the person has no account bound', async () => {
|
|
1441
|
+
const r = await processIndexedDownload('sess-a', ROW.relPath, makeEnv([ROW], ''), () => {}, 1000, okHead as never)
|
|
1442
|
+
expect(r.status).toBe(404)
|
|
1443
|
+
})
|
|
1444
|
+
})
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1448
|
+
|
|
1449
|
+
```bash
|
|
1450
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/portal-indexed-download.test.ts
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
Expected: FAIL — `processIndexedDownload` is not exported.
|
|
1454
|
+
|
|
1455
|
+
- [ ] **Step 3a: Extend `PortalEnv`**
|
|
1456
|
+
|
|
1457
|
+
In `_lib/types.ts`, add to the `PortalEnv` interface:
|
|
1458
|
+
|
|
1459
|
+
```ts
|
|
1460
|
+
export interface PortalEnv {
|
|
1461
|
+
DB: D1Database
|
|
1462
|
+
BUCKET: R2Bucket
|
|
1463
|
+
/** Shared with the install; signs download links. Never sent to the client. */
|
|
1464
|
+
PORTAL_FETCH_SECRET?: string
|
|
1465
|
+
/** The install's public origin, e.g. https://<tunnel-hostname>. */
|
|
1466
|
+
PORTAL_INSTALL_ORIGIN?: string
|
|
1467
|
+
}
|
|
1468
|
+
```
|
|
1469
|
+
|
|
1470
|
+
- [ ] **Step 3b: Add the indexed branch to `download.ts`**
|
|
1471
|
+
|
|
1472
|
+
Append to `download.ts` (leaving `processDownload` untouched, so the R2 upload path cannot regress):
|
|
1473
|
+
|
|
1474
|
+
```ts
|
|
1475
|
+
import { buildSignedUrl, SIGNED_TTL_MS } from './_lib/portal-sign.mjs'
|
|
1476
|
+
|
|
1477
|
+
type HeadFetch = (url: string, init: { method: string }) => Promise<{ ok: boolean; status: number }>
|
|
1478
|
+
|
|
1479
|
+
interface IndexedDownloadResult extends Handler {
|
|
1480
|
+
url?: string
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Hand back a short-lived signed link to a file the DEVICE holds.
|
|
1485
|
+
*
|
|
1486
|
+
* The index is checked first, so a path the device never published costs no
|
|
1487
|
+
* network call — and `documents/` is not in the index by construction, which is
|
|
1488
|
+
* why a client cannot name their way into it.
|
|
1489
|
+
*
|
|
1490
|
+
* The device is probed before the link is minted. Without that, an offline
|
|
1491
|
+
* device produces an opaque browser failure and the client has no idea why;
|
|
1492
|
+
* with it they get "device offline" and can try later. The probe-then-fetch
|
|
1493
|
+
* window is real but small, and losing it degrades to exactly the opaque
|
|
1494
|
+
* failure this avoids in the common case.
|
|
1495
|
+
*/
|
|
1496
|
+
export async function processIndexedDownload(
|
|
1497
|
+
sessionId: string,
|
|
1498
|
+
relPath: string,
|
|
1499
|
+
env: PortalEnv,
|
|
1500
|
+
log: Logger,
|
|
1501
|
+
nowMs: number,
|
|
1502
|
+
fetchFn: HeadFetch,
|
|
1503
|
+
): Promise<IndexedDownloadResult> {
|
|
1504
|
+
const session = await resolveSession(env.DB, sessionId, nowMs)
|
|
1505
|
+
if (!session) {
|
|
1506
|
+
log('[data-portal] op=fetch-through owner=none result=denied')
|
|
1507
|
+
return { status: 401, payload: { ok: false, error: 'denied' } }
|
|
1508
|
+
}
|
|
1509
|
+
if (!session.accountId) {
|
|
1510
|
+
log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=no-account`)
|
|
1511
|
+
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
1512
|
+
}
|
|
1513
|
+
const row = await env.DB.prepare(
|
|
1514
|
+
'SELECT relPath FROM directory WHERE accountId = ? AND relPath = ?',
|
|
1515
|
+
)
|
|
1516
|
+
.bind(session.accountId, relPath)
|
|
1517
|
+
.first<{ relPath: string }>()
|
|
1518
|
+
if (!row) {
|
|
1519
|
+
log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} result=absent`)
|
|
1520
|
+
return { status: 404, payload: { ok: false, error: 'not found' } }
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const secret = env.PORTAL_FETCH_SECRET
|
|
1524
|
+
const origin = env.PORTAL_INSTALL_ORIGIN
|
|
1525
|
+
if (!secret || !origin) {
|
|
1526
|
+
log(`[data-portal] op=fetch-through owner=${q(session.ownerId)} result=unconfigured`)
|
|
1527
|
+
return { status: 503, payload: { ok: false, error: 'device-offline' } }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const expiresAt = nowMs + SIGNED_TTL_MS
|
|
1531
|
+
const url = await buildSignedUrl(origin, secret, session.accountId, relPath, expiresAt)
|
|
1532
|
+
|
|
1533
|
+
const started = Date.now()
|
|
1534
|
+
try {
|
|
1535
|
+
const head = await fetchFn(url, { method: 'HEAD' })
|
|
1536
|
+
if (!head.ok) {
|
|
1537
|
+
log(
|
|
1538
|
+
`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
|
|
1539
|
+
`status=${head.status} ms=${Date.now() - started} result=device-error`,
|
|
1540
|
+
)
|
|
1541
|
+
return { status: 503, payload: { ok: false, error: 'device-offline' } }
|
|
1542
|
+
}
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
log(
|
|
1545
|
+
`[data-portal] op=fetch-through-failed owner=${q(session.ownerId)} path=${q(relPath)} ` +
|
|
1546
|
+
`err=${q(err instanceof Error ? err.message : String(err))} result=device-offline`,
|
|
1547
|
+
)
|
|
1548
|
+
return { status: 503, payload: { ok: false, error: 'device-offline' } }
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
log(
|
|
1552
|
+
`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
|
|
1553
|
+
`status=200 ms=${Date.now() - started} result=ok`,
|
|
1554
|
+
)
|
|
1555
|
+
return { status: 200, payload: { ok: true, url }, url }
|
|
1556
|
+
}
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
Extend `onRequestGet` to branch on which parameter arrived:
|
|
1560
|
+
|
|
1561
|
+
```ts
|
|
1562
|
+
export async function onRequestGet(context: PagesContext): Promise<Response> {
|
|
1563
|
+
const sessionId = readSessionCookie(context.request.headers.get('cookie')) ?? ''
|
|
1564
|
+
const params = new URL(context.request.url).searchParams
|
|
1565
|
+
const indexedPath = params.get('path')
|
|
1566
|
+
if (indexedPath) {
|
|
1567
|
+
const { status, payload } = await processIndexedDownload(
|
|
1568
|
+
sessionId,
|
|
1569
|
+
indexedPath,
|
|
1570
|
+
context.env,
|
|
1571
|
+
(line) => console.log(line),
|
|
1572
|
+
Date.now(),
|
|
1573
|
+
fetch as never,
|
|
1574
|
+
)
|
|
1575
|
+
return Response.json(payload, { status })
|
|
1576
|
+
}
|
|
1577
|
+
const key = params.get('key') ?? ''
|
|
1578
|
+
// ... existing R2 body, unchanged
|
|
1579
|
+
}
|
|
1580
|
+
```
|
|
1581
|
+
|
|
1582
|
+
- [ ] **Step 4: Run tests and typecheck**
|
|
1583
|
+
|
|
1584
|
+
```bash
|
|
1585
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run && npm run typecheck:templates
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
Expected: all green; typecheck exit 0.
|
|
1589
|
+
|
|
1590
|
+
- [ ] **Step 5: Commit**
|
|
1591
|
+
|
|
1592
|
+
```bash
|
|
1593
|
+
git add platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts
|
|
1594
|
+
git commit -m "feat(data-portal): signed-link download for device-held files"
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
---
|
|
1598
|
+
|
|
1599
|
+
### Task 8: Install-side fetch route
|
|
1600
|
+
|
|
1601
|
+
**Files:**
|
|
1602
|
+
- Create: `platform/ui/server/routes/portal-fetch.ts`
|
|
1603
|
+
- Modify: `platform/ui/server/index.ts` (import + `SUBAPP_MANIFEST` entry)
|
|
1604
|
+
- Test: `platform/ui/server/routes/__tests__/portal-fetch.test.ts`
|
|
1605
|
+
|
|
1606
|
+
**Interfaces:**
|
|
1607
|
+
- Consumes: the signed-link format from Task 2 (`accountId`, `path`, `exp`, `sig`), `resolveExposedDirs` (Task 1).
|
|
1608
|
+
- Produces: `GET /api/portal/fetch`; exported `verifySignature(secret, accountId, relPath, expiresAtMs, sig, nowMs): Promise<boolean>` for the parity test.
|
|
1609
|
+
|
|
1610
|
+
- [ ] **Step 1: Write the failing test**
|
|
1611
|
+
|
|
1612
|
+
```ts
|
|
1613
|
+
// platform/ui/server/routes/__tests__/portal-fetch.test.ts
|
|
1614
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
1615
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
|
1616
|
+
import { tmpdir } from 'node:os'
|
|
1617
|
+
import { join } from 'node:path'
|
|
1618
|
+
import { verifySignature, resolveFetchTarget } from '../portal-fetch'
|
|
1619
|
+
import { signPath } from '../../../../plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
|
|
1620
|
+
|
|
1621
|
+
const SECRET = 'shared'
|
|
1622
|
+
const ACC = 'acc-a'
|
|
1623
|
+
const NOW = 1_800_000_000_000
|
|
1624
|
+
|
|
1625
|
+
const SCHEMA = [
|
|
1626
|
+
'```allowed-top-level', 'output', 'quotes', 'documents', '```', '',
|
|
1627
|
+
'<!-- ontology-buckets:start -->',
|
|
1628
|
+
'- `quotes/` - one folder per Quotation record.',
|
|
1629
|
+
'<!-- ontology-buckets:end -->',
|
|
1630
|
+
].join('\n')
|
|
1631
|
+
|
|
1632
|
+
function accountDir() {
|
|
1633
|
+
const d = mkdtempSync(join(tmpdir(), 'portal-fetch-'))
|
|
1634
|
+
writeFileSync(join(d, 'SCHEMA.md'), SCHEMA)
|
|
1635
|
+
mkdirSync(join(d, 'quotes/CR2969'), { recursive: true })
|
|
1636
|
+
writeFileSync(join(d, 'quotes/CR2969/quote.pdf'), 'PDF')
|
|
1637
|
+
mkdirSync(join(d, 'documents'), { recursive: true })
|
|
1638
|
+
writeFileSync(join(d, 'documents/private.pdf'), 'SECRET')
|
|
1639
|
+
return d
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
describe('portal-fetch signature parity', () => {
|
|
1643
|
+
// The two ends deliberately do not share a module. This is the only place a
|
|
1644
|
+
// drift between them could hide.
|
|
1645
|
+
it('accepts a signature minted by the portal module', async () => {
|
|
1646
|
+
const exp = NOW + 300_000
|
|
1647
|
+
const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp)
|
|
1648
|
+
expect(await verifySignature(SECRET, ACC, 'quotes/CR2969/quote.pdf', exp, sig, NOW)).toBe(true)
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1651
|
+
it('rejects an expired or tampered signature', async () => {
|
|
1652
|
+
const sig = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', NOW - 1)
|
|
1653
|
+
expect(await verifySignature(SECRET, ACC, 'quotes/CR2969/quote.pdf', NOW - 1, sig, NOW)).toBe(false)
|
|
1654
|
+
const good = await signPath(SECRET, ACC, 'quotes/CR2969/quote.pdf', NOW + 300_000)
|
|
1655
|
+
expect(await verifySignature(SECRET, ACC, 'documents/private.pdf', NOW + 300_000, good, NOW)).toBe(false)
|
|
1656
|
+
})
|
|
1657
|
+
})
|
|
1658
|
+
|
|
1659
|
+
describe('resolveFetchTarget', () => {
|
|
1660
|
+
it('serves a file under an exposed dir', async () => {
|
|
1661
|
+
const d = accountDir()
|
|
1662
|
+
const r = await resolveFetchTarget(d, 'quotes/CR2969/quote.pdf')
|
|
1663
|
+
expect(r.ok).toBe(true)
|
|
1664
|
+
rmSync(d, { recursive: true, force: true })
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
// The security boundary: a VALID signature over a non-exposed path is still
|
|
1668
|
+
// refused, because the install re-derives the exposed set itself.
|
|
1669
|
+
it('refuses a non-exposed dir even though the file exists', async () => {
|
|
1670
|
+
const d = accountDir()
|
|
1671
|
+
const r = await resolveFetchTarget(d, 'documents/private.pdf')
|
|
1672
|
+
expect(r.ok).toBe(false)
|
|
1673
|
+
expect(r.reason).toBe('not-exposed')
|
|
1674
|
+
rmSync(d, { recursive: true, force: true })
|
|
1675
|
+
})
|
|
1676
|
+
|
|
1677
|
+
it('refuses traversal', async () => {
|
|
1678
|
+
const d = accountDir()
|
|
1679
|
+
for (const p of ['../../etc/passwd', 'quotes/../../x', '/etc/passwd']) {
|
|
1680
|
+
const r = await resolveFetchTarget(d, p)
|
|
1681
|
+
expect(r.ok).toBe(false)
|
|
1682
|
+
}
|
|
1683
|
+
rmSync(d, { recursive: true, force: true })
|
|
1684
|
+
})
|
|
1685
|
+
|
|
1686
|
+
it('refuses everything when the schema is missing', async () => {
|
|
1687
|
+
const d = accountDir()
|
|
1688
|
+
rmSync(join(d, 'SCHEMA.md'))
|
|
1689
|
+
const r = await resolveFetchTarget(d, 'quotes/CR2969/quote.pdf')
|
|
1690
|
+
expect(r.ok).toBe(false)
|
|
1691
|
+
expect(r.reason).toBe('not-exposed')
|
|
1692
|
+
rmSync(d, { recursive: true, force: true })
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
it('misses a path that is exposed but absent', async () => {
|
|
1696
|
+
const d = accountDir()
|
|
1697
|
+
const r = await resolveFetchTarget(d, 'quotes/CR9999/nope.pdf')
|
|
1698
|
+
expect(r.ok).toBe(false)
|
|
1699
|
+
expect(r.reason).toBe('enoent')
|
|
1700
|
+
rmSync(d, { recursive: true, force: true })
|
|
1701
|
+
})
|
|
1702
|
+
})
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
1706
|
+
|
|
1707
|
+
```bash
|
|
1708
|
+
export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"
|
|
1709
|
+
cd platform/ui && node_modules/.bin/vitest run server/routes/__tests__/portal-fetch.test.ts
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
Expected: FAIL — `../portal-fetch` does not exist.
|
|
1713
|
+
|
|
1714
|
+
- [ ] **Step 3a: Write the route**
|
|
1715
|
+
|
|
1716
|
+
```ts
|
|
1717
|
+
// platform/ui/server/routes/portal-fetch.ts
|
|
1718
|
+
// Serve one device-held file to a data portal, on a signed link.
|
|
1719
|
+
//
|
|
1720
|
+
// The portal holds an INDEX of the account's deliverable folders but not the
|
|
1721
|
+
// bytes (Task 1831). When a client clicks a file, the portal mints a link
|
|
1722
|
+
// signed with the account's shared secret and the browser lands here.
|
|
1723
|
+
//
|
|
1724
|
+
// TWO checks, and neither is redundant:
|
|
1725
|
+
// 1. the signature proves the request came from the portal;
|
|
1726
|
+
// 2. the exposed-dir set is RE-DERIVED here from the account's own SCHEMA.md,
|
|
1727
|
+
// so a validly-signed link for `documents/private.pdf` is still refused.
|
|
1728
|
+
// The second is the security boundary. If the portal is ever wrong about what
|
|
1729
|
+
// it may offer, this is what contains it.
|
|
1730
|
+
|
|
1731
|
+
import { Hono } from 'hono'
|
|
1732
|
+
import { createReadStream } from 'node:fs'
|
|
1733
|
+
import { stat, readFile } from 'node:fs/promises'
|
|
1734
|
+
import { join, resolve, sep } from 'node:path'
|
|
1735
|
+
import { Readable } from 'node:stream'
|
|
1736
|
+
import type { AppEnv } from './_env'
|
|
1737
|
+
import { resolveExposedDirs } from '../../../plugins/cloudflare/bin/schema-exposed-dirs.mjs'
|
|
1738
|
+
import { DATA_ROOT } from '../../app/lib/data-path'
|
|
1739
|
+
import { detectMimeType } from '../../app/lib/attachments'
|
|
1740
|
+
|
|
1741
|
+
const app = new Hono<AppEnv>()
|
|
1742
|
+
const TAG = '[portal-fetch]'
|
|
1743
|
+
|
|
1744
|
+
/** Same algorithm as the portal's `_lib/portal-sign.mjs`. The two ends do not
|
|
1745
|
+
* share a module — no production code in platform/ui imports from
|
|
1746
|
+
* platform/plugins — so a parity test asserts they agree. */
|
|
1747
|
+
export async function verifySignature(
|
|
1748
|
+
secret: string,
|
|
1749
|
+
accountId: string,
|
|
1750
|
+
relPath: string,
|
|
1751
|
+
expiresAtMs: number,
|
|
1752
|
+
signature: string,
|
|
1753
|
+
nowMs: number,
|
|
1754
|
+
): Promise<boolean> {
|
|
1755
|
+
if (!secret || !signature) return false
|
|
1756
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) return false
|
|
1757
|
+
const key = await crypto.subtle.importKey(
|
|
1758
|
+
'raw',
|
|
1759
|
+
new TextEncoder().encode(secret),
|
|
1760
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
1761
|
+
false,
|
|
1762
|
+
['sign'],
|
|
1763
|
+
)
|
|
1764
|
+
const mac = await crypto.subtle.sign(
|
|
1765
|
+
'HMAC',
|
|
1766
|
+
key,
|
|
1767
|
+
new TextEncoder().encode(`${accountId}\n${relPath}\n${expiresAtMs}`),
|
|
1768
|
+
)
|
|
1769
|
+
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
1770
|
+
if (expected.length !== signature.length) return false
|
|
1771
|
+
let diff = 0
|
|
1772
|
+
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
|
|
1773
|
+
return diff === 0
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
export type FetchTarget =
|
|
1777
|
+
| { ok: true; absolute: string; sizeBytes: number }
|
|
1778
|
+
| { ok: false; reason: 'not-exposed' | 'enoent' }
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Resolve an account-relative path to a servable file, refusing anything not
|
|
1782
|
+
* under a currently-exposed dir. Containment is checked on the resolved path,
|
|
1783
|
+
* so `..` cannot escape regardless of how it is spelled.
|
|
1784
|
+
*/
|
|
1785
|
+
export async function resolveFetchTarget(accountDir: string, relPath: string): Promise<FetchTarget> {
|
|
1786
|
+
let schemaText: string | null = null
|
|
1787
|
+
try {
|
|
1788
|
+
schemaText = await readFile(join(accountDir, 'SCHEMA.md'), 'utf8')
|
|
1789
|
+
} catch {
|
|
1790
|
+
schemaText = null // fail closed — see resolveExposedDirs
|
|
1791
|
+
}
|
|
1792
|
+
const { exposed } = resolveExposedDirs(schemaText)
|
|
1793
|
+
if (exposed.length === 0) return { ok: false, reason: 'not-exposed' }
|
|
1794
|
+
|
|
1795
|
+
const absolute = resolve(accountDir, relPath)
|
|
1796
|
+
const root = resolve(accountDir)
|
|
1797
|
+
if (absolute !== root && !absolute.startsWith(root + sep)) return { ok: false, reason: 'not-exposed' }
|
|
1798
|
+
|
|
1799
|
+
const rel = absolute.slice(root.length + 1)
|
|
1800
|
+
const top = rel.split(sep)[0]
|
|
1801
|
+
if (!exposed.includes(top)) return { ok: false, reason: 'not-exposed' }
|
|
1802
|
+
|
|
1803
|
+
try {
|
|
1804
|
+
const st = await stat(absolute)
|
|
1805
|
+
if (!st.isFile()) return { ok: false, reason: 'enoent' }
|
|
1806
|
+
return { ok: true, absolute, sizeBytes: st.size }
|
|
1807
|
+
} catch {
|
|
1808
|
+
return { ok: false, reason: 'enoent' }
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async function handle(c: Parameters<Parameters<typeof app.get>[1]>[0], headOnly: boolean) {
|
|
1813
|
+
const accountId = c.req.query('accountId') ?? ''
|
|
1814
|
+
const relPath = c.req.query('path') ?? ''
|
|
1815
|
+
const exp = Number(c.req.query('exp') ?? '0')
|
|
1816
|
+
const sig = c.req.query('sig') ?? ''
|
|
1817
|
+
console.log(`${TAG} op=request account=${accountId} path="${relPath}"`)
|
|
1818
|
+
|
|
1819
|
+
const secret = process.env.PORTAL_FETCH_SECRET ?? ''
|
|
1820
|
+
if (!(await verifySignature(secret, accountId, relPath, exp, sig, Date.now()))) {
|
|
1821
|
+
console.error(`${TAG} op=denied account=${accountId} reason=secret`)
|
|
1822
|
+
return c.json({ ok: false, error: 'denied' }, 401)
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const accountDir = resolve(DATA_ROOT, 'accounts', accountId)
|
|
1826
|
+
const target = await resolveFetchTarget(accountDir, relPath)
|
|
1827
|
+
if (!target.ok) {
|
|
1828
|
+
console.error(`${TAG} op=${target.reason === 'enoent' ? 'miss' : 'denied'} account=${accountId} reason=${target.reason}`)
|
|
1829
|
+
return c.json({ ok: false, error: 'not found' }, 404)
|
|
1830
|
+
}
|
|
1831
|
+
if (headOnly) {
|
|
1832
|
+
console.log(`${TAG} op=served account=${accountId} bytes=0 head=true`)
|
|
1833
|
+
return c.body(null, 200)
|
|
1834
|
+
}
|
|
1835
|
+
console.log(`${TAG} op=served account=${accountId} bytes=${target.sizeBytes}`)
|
|
1836
|
+
return new Response(Readable.toWeb(createReadStream(target.absolute)) as ReadableStream, {
|
|
1837
|
+
status: 200,
|
|
1838
|
+
headers: {
|
|
1839
|
+
'content-type': detectMimeType(target.absolute) ?? 'application/octet-stream',
|
|
1840
|
+
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(relPath.split('/').pop() ?? 'file')}`,
|
|
1841
|
+
'x-content-type-options': 'nosniff',
|
|
1842
|
+
'cache-control': 'private, no-store',
|
|
1843
|
+
},
|
|
1844
|
+
})
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
app.get('/fetch', (c) => handle(c, false))
|
|
1848
|
+
app.on('HEAD', '/fetch', (c) => handle(c, true))
|
|
1849
|
+
|
|
1850
|
+
export default app
|
|
1851
|
+
```
|
|
1852
|
+
|
|
1853
|
+
- [ ] **Step 3b: Register the subapp**
|
|
1854
|
+
|
|
1855
|
+
In `platform/ui/server/index.ts`, add the import beside the other route imports:
|
|
1856
|
+
|
|
1857
|
+
```ts
|
|
1858
|
+
import portalFetchRoutes from './routes/portal-fetch'
|
|
1859
|
+
```
|
|
1860
|
+
|
|
1861
|
+
and the manifest entry inside `SUBAPP_MANIFEST` (after the `/api/calendar` line):
|
|
1862
|
+
|
|
1863
|
+
```ts
|
|
1864
|
+
{ prefix: '/api/portal', file: 'server/routes/portal-fetch.ts', subapp: portalFetchRoutes },
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
- [ ] **Step 4: Run tests**
|
|
1868
|
+
|
|
1869
|
+
```bash
|
|
1870
|
+
cd platform/ui && node_modules/.bin/vitest run server/routes/__tests__/portal-fetch.test.ts server/__tests__/shell-route-registration.test.ts
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
Expected: PASS. If `shell-route-registration.test.ts` asserts an exhaustive manifest list, add `/api/portal` to it.
|
|
1874
|
+
|
|
1875
|
+
- [ ] **Step 5: Commit**
|
|
1876
|
+
|
|
1877
|
+
```bash
|
|
1878
|
+
git add platform/ui/server/routes/portal-fetch.ts platform/ui/server/index.ts platform/ui/server/routes/__tests__/portal-fetch.test.ts
|
|
1879
|
+
git commit -m "feat(data-portal): install route serving device files on a signed link"
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
---
|
|
1883
|
+
|
|
1884
|
+
### Task 9: Portal folder navigation
|
|
1885
|
+
|
|
1886
|
+
**Files:**
|
|
1887
|
+
- Modify: `.../data-portal/template/portal.js`
|
|
1888
|
+
- Modify: `.../data-portal/template/index.html`
|
|
1889
|
+
|
|
1890
|
+
No test: the portal front end is a static page with no harness in this repo. It is exercised by hand at deploy time, which the SKILL's done-gate already covers.
|
|
1891
|
+
|
|
1892
|
+
- [ ] **Step 1: Add the breadcrumb container to `index.html`**
|
|
1893
|
+
|
|
1894
|
+
Insert directly above the existing file-list element:
|
|
1895
|
+
|
|
1896
|
+
```html
|
|
1897
|
+
<nav id="crumbs" class="crumbs" aria-label="Folder path"></nav>
|
|
1898
|
+
<ul id="entries" class="entries"></ul>
|
|
1899
|
+
```
|
|
1900
|
+
|
|
1901
|
+
- [ ] **Step 2: Add navigation to `portal.js`**
|
|
1902
|
+
|
|
1903
|
+
```js
|
|
1904
|
+
// Folder navigation over the account's published index (Task 1831). `prefix`
|
|
1905
|
+
// is the folder currently open; '' is the root.
|
|
1906
|
+
let prefix = ''
|
|
1907
|
+
|
|
1908
|
+
function renderCrumbs() {
|
|
1909
|
+
const parts = prefix ? prefix.split('/') : []
|
|
1910
|
+
const crumbs = document.getElementById('crumbs')
|
|
1911
|
+
crumbs.replaceChildren()
|
|
1912
|
+
const mk = (label, target) => {
|
|
1913
|
+
const a = document.createElement('button')
|
|
1914
|
+
a.type = 'button'
|
|
1915
|
+
a.className = 'crumb'
|
|
1916
|
+
a.textContent = label
|
|
1917
|
+
a.addEventListener('click', () => { prefix = target; void loadEntries() })
|
|
1918
|
+
return a
|
|
1919
|
+
}
|
|
1920
|
+
crumbs.append(mk('Files', ''))
|
|
1921
|
+
parts.forEach((part, i) => {
|
|
1922
|
+
crumbs.append(document.createTextNode(' / '))
|
|
1923
|
+
crumbs.append(mk(part, parts.slice(0, i + 1).join('/')))
|
|
1924
|
+
})
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
async function loadEntries() {
|
|
1928
|
+
const res = await fetch(`/api/files?prefix=${encodeURIComponent(prefix)}`, { credentials: 'same-origin' })
|
|
1929
|
+
const data = await res.json()
|
|
1930
|
+
renderCrumbs()
|
|
1931
|
+
const list = document.getElementById('entries')
|
|
1932
|
+
list.replaceChildren()
|
|
1933
|
+
for (const entry of data.entries ?? []) {
|
|
1934
|
+
const li = document.createElement('li')
|
|
1935
|
+
const btn = document.createElement('button')
|
|
1936
|
+
btn.type = 'button'
|
|
1937
|
+
btn.textContent = entry.kind === 'directory' ? `📁 ${entry.name}` : entry.name
|
|
1938
|
+
btn.addEventListener('click', () => {
|
|
1939
|
+
if (entry.kind === 'directory') { prefix = entry.relPath; void loadEntries() }
|
|
1940
|
+
else void openIndexedFile(entry.relPath)
|
|
1941
|
+
})
|
|
1942
|
+
li.append(btn)
|
|
1943
|
+
list.append(li)
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// The portal checks the device is reachable before handing over the link, so a
|
|
1948
|
+
// down device says so plainly instead of failing as a broken download.
|
|
1949
|
+
async function openIndexedFile(relPath) {
|
|
1950
|
+
const res = await fetch(`/api/download?path=${encodeURIComponent(relPath)}`, { credentials: 'same-origin' })
|
|
1951
|
+
const data = await res.json()
|
|
1952
|
+
if (res.status === 503) {
|
|
1953
|
+
showError('That file lives on the device, which is not reachable right now. Try again shortly.')
|
|
1954
|
+
return
|
|
1955
|
+
}
|
|
1956
|
+
if (!res.ok || !data.url) {
|
|
1957
|
+
showError('That file could not be opened.')
|
|
1958
|
+
return
|
|
1959
|
+
}
|
|
1960
|
+
window.location.assign(data.url)
|
|
1961
|
+
}
|
|
1962
|
+
```
|
|
1963
|
+
|
|
1964
|
+
Call `void loadEntries()` wherever the existing upload list is first loaded, so both render on sign-in. If `showError` does not already exist in `portal.js`, reuse whatever the upload flow calls to surface a failure message rather than adding a second mechanism.
|
|
1965
|
+
|
|
1966
|
+
- [ ] **Step 3: Verify the template still typechecks**
|
|
1967
|
+
|
|
1968
|
+
```bash
|
|
1969
|
+
cd platform/plugins/cloudflare/mcp && npm run typecheck:templates
|
|
1970
|
+
```
|
|
1971
|
+
|
|
1972
|
+
Expected: exit 0 (the script covers `functions/api`, not `portal.js`; this confirms nothing else broke).
|
|
1973
|
+
|
|
1974
|
+
- [ ] **Step 4: Commit**
|
|
1975
|
+
|
|
1976
|
+
```bash
|
|
1977
|
+
git add platform/plugins/cloudflare/skills/data-portal/template/portal.js platform/plugins/cloudflare/skills/data-portal/template/index.html
|
|
1978
|
+
git commit -m "feat(data-portal): folder navigation in the portal ui"
|
|
1979
|
+
```
|
|
1980
|
+
|
|
1981
|
+
---
|
|
1982
|
+
|
|
1983
|
+
### Task 10: Assembly, heartbeat wiring, and docs
|
|
1984
|
+
|
|
1985
|
+
**Files:**
|
|
1986
|
+
- Modify: `.../data-portal/SKILL.md`
|
|
1987
|
+
- Modify: `.../data-portal/template/wrangler.toml`
|
|
1988
|
+
- Modify: `platform/plugins/cloudflare/PLUGIN.md`
|
|
1989
|
+
- Modify: `platform/plugins/scheduling/mcp/src/scripts/check-due-events.ts`
|
|
1990
|
+
- Modify: `.docs/` and `platform/plugins/docs/references/`
|
|
1991
|
+
- Test: `platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts` (extend)
|
|
1992
|
+
|
|
1993
|
+
- [ ] **Step 1: Write the failing test**
|
|
1994
|
+
|
|
1995
|
+
```ts
|
|
1996
|
+
// platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts — append
|
|
1997
|
+
it('declares the two portal-fetch bindings in wrangler.toml', () => {
|
|
1998
|
+
const toml = readFileSync(
|
|
1999
|
+
resolve(__dirname, '../../skills/data-portal/template/wrangler.toml'),
|
|
2000
|
+
'utf8',
|
|
2001
|
+
)
|
|
2002
|
+
expect(toml).toContain('PORTAL_INSTALL_ORIGIN')
|
|
2003
|
+
expect(toml).toContain('__PORTAL_INSTALL_ORIGIN__')
|
|
2004
|
+
})
|
|
2005
|
+
|
|
2006
|
+
it('SKILL.md tells the assembler to set the fetch secret and origin', () => {
|
|
2007
|
+
const skill = readFileSync(resolve(__dirname, '../../skills/data-portal/SKILL.md'), 'utf8')
|
|
2008
|
+
expect(skill).toContain('PORTAL_FETCH_SECRET')
|
|
2009
|
+
expect(skill).toContain('portal-index-push.mjs')
|
|
2010
|
+
})
|
|
2011
|
+
```
|
|
2012
|
+
|
|
2013
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
2014
|
+
|
|
2015
|
+
```bash
|
|
2016
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run __tests__/template-config.test.ts
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
Expected: FAIL on both.
|
|
2020
|
+
|
|
2021
|
+
- [ ] **Step 3a: Add the origin placeholder to `wrangler.toml`**
|
|
2022
|
+
|
|
2023
|
+
```toml
|
|
2024
|
+
[vars]
|
|
2025
|
+
PORTAL_INSTALL_ORIGIN = "__PORTAL_INSTALL_ORIGIN__"
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
`PORTAL_FETCH_SECRET` is deliberately NOT in `wrangler.toml` — it is a secret, set with `wrangler pages secret put`, never committed to the assembled tree.
|
|
2029
|
+
|
|
2030
|
+
- [ ] **Step 3b: Document assembly in `SKILL.md`**
|
|
2031
|
+
|
|
2032
|
+
Add a section after "Assemble the canonical tree":
|
|
2033
|
+
|
|
2034
|
+
````markdown
|
|
2035
|
+
## Bind the portal to the device
|
|
2036
|
+
|
|
2037
|
+
The portal shows the account's deliverable folders, which means it needs two
|
|
2038
|
+
things the template cannot know: where the install is, and a secret it shares
|
|
2039
|
+
with it.
|
|
2040
|
+
|
|
2041
|
+
Fill `__PORTAL_INSTALL_ORIGIN__` in `wrangler.toml` with the install's public
|
|
2042
|
+
origin (`https://<tunnel-hostname>`), then set the shared secret on both ends:
|
|
2043
|
+
|
|
2044
|
+
```bash
|
|
2045
|
+
SECRET=$(node -e 'console.log(crypto.randomUUID().replace(/-/g,"")+crypto.randomUUID().replace(/-/g,""))')
|
|
2046
|
+
# device side — the install reads it from its own environment
|
|
2047
|
+
echo "PORTAL_FETCH_SECRET=$SECRET" >> <accountDir>/secrets.env
|
|
2048
|
+
# portal side
|
|
2049
|
+
npx wrangler pages secret put PORTAL_FETCH_SECRET --project-name <project>
|
|
2050
|
+
```
|
|
2051
|
+
|
|
2052
|
+
The secret never travels. The portal signs one file path with a five-minute
|
|
2053
|
+
expiry and the install verifies it. The install then re-derives which folders
|
|
2054
|
+
are exposed from the account's own `SCHEMA.md`, so a signed link for a folder
|
|
2055
|
+
that is not a deliverable is refused regardless — the signature is not the
|
|
2056
|
+
security boundary, that check is.
|
|
2057
|
+
|
|
2058
|
+
Enrolment now binds each person to the account whose folders they may read:
|
|
2059
|
+
|
|
2060
|
+
```bash
|
|
2061
|
+
node platform/plugins/cloudflare/bin/portal-enrol.mjs --owner <ownerId> \
|
|
2062
|
+
--name <name> --account <accountId> | npx wrangler d1 execute <portalDbName> --remote --file=-
|
|
2063
|
+
```
|
|
2064
|
+
|
|
2065
|
+
Anyone enrolled before this existed has no account bound and sees an empty
|
|
2066
|
+
folder list. Re-run enrolment for them; the upsert makes it a re-run, not a
|
|
2067
|
+
migration.
|
|
2068
|
+
|
|
2069
|
+
## Publish the folder index
|
|
2070
|
+
|
|
2071
|
+
The device publishes file metadata — names, sizes, times, never bytes — so the
|
|
2072
|
+
tree still renders when the device is offline:
|
|
2073
|
+
|
|
2074
|
+
```bash
|
|
2075
|
+
node platform/plugins/cloudflare/bin/portal-index-push.mjs \
|
|
2076
|
+
--account-dir <accountDir> --account <accountId> --db <portalDbName> --token <cfToken>
|
|
2077
|
+
```
|
|
2078
|
+
|
|
2079
|
+
The heartbeat runs this hourly. Run it by hand after assembly so the portal has
|
|
2080
|
+
something to show on the client's first sign-in.
|
|
2081
|
+
````
|
|
2082
|
+
|
|
2083
|
+
- [ ] **Step 3c: Wire the heartbeat**
|
|
2084
|
+
|
|
2085
|
+
In `check-due-events.ts`, beside `maybeRunSentSweep`, add the same shape:
|
|
2086
|
+
|
|
2087
|
+
```ts
|
|
2088
|
+
const PORTAL_INDEX_STATE_FILE = resolve(CONFIG_DIR, "portal-index-push-state.json");
|
|
2089
|
+
const PORTAL_INDEX_INTERVAL_MS = 3_600_000;
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* Publish each portal-serving account's folder index, at most hourly.
|
|
2093
|
+
*
|
|
2094
|
+
* Spawned, not imported, for the same reason as the sent-copy sweep: this
|
|
2095
|
+
* script lives in the cloudflare plugin and reaches Cloudflare's API, which
|
|
2096
|
+
* does not belong inside the scheduling build.
|
|
2097
|
+
*
|
|
2098
|
+
* Only accounts carrying a data-portal.json have a portal, so the absence of
|
|
2099
|
+
* that file is the enrolment signal — not a config flag to keep in sync.
|
|
2100
|
+
*/
|
|
2101
|
+
function maybeRunPortalIndexPush(platformRoot: string): void {
|
|
2102
|
+
let prev: { lastRunMs?: number } | undefined;
|
|
2103
|
+
try {
|
|
2104
|
+
prev = JSON.parse(readFileSync(PORTAL_INDEX_STATE_FILE, "utf8"));
|
|
2105
|
+
} catch {
|
|
2106
|
+
prev = undefined;
|
|
2107
|
+
}
|
|
2108
|
+
const now = Date.now();
|
|
2109
|
+
if (prev?.lastRunMs && now - prev.lastRunMs < PORTAL_INDEX_INTERVAL_MS) return;
|
|
2110
|
+
|
|
2111
|
+
const script = resolve(platformRoot, "plugins/cloudflare/bin/portal-index-push.mjs");
|
|
2112
|
+
if (!existsSync(script)) {
|
|
2113
|
+
console.error(`[portal-index] skipped: ${script} not found`);
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
try {
|
|
2117
|
+
writeFileSync(PORTAL_INDEX_STATE_FILE, JSON.stringify({ lastRunMs: now }));
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
console.error(`[portal-index] state write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
spawn(process.execPath, [script, "--all-accounts"], {
|
|
2123
|
+
env: { ...process.env, PLATFORM_ROOT: platformRoot },
|
|
2124
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
2125
|
+
detached: true,
|
|
2126
|
+
}).unref();
|
|
2127
|
+
}
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
and call it in the same guarded block as the sent sweep:
|
|
2131
|
+
|
|
2132
|
+
```ts
|
|
2133
|
+
try {
|
|
2134
|
+
maybeRunPortalIndexPush(process.env.PLATFORM_ROOT!);
|
|
2135
|
+
} catch (err) {
|
|
2136
|
+
console.error(`[portal-index] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2137
|
+
}
|
|
2138
|
+
```
|
|
2139
|
+
|
|
2140
|
+
Then add `--all-accounts` handling to `portal-index-push.mjs`'s CLI block: enumerate `DATA_ROOT/accounts/*/data-portal.json`, and for each, read `portalDbName` from it and call `pushAccount`. Accounts with no `data-portal.json` are skipped silently — they have no portal.
|
|
2141
|
+
|
|
2142
|
+
- [ ] **Step 3d: Documentation**
|
|
2143
|
+
|
|
2144
|
+
- `platform/plugins/cloudflare/PLUGIN.md` — describe the index push, the two bindings, and the `[portal-index]` / `[portal-fetch]` log tags.
|
|
2145
|
+
- `.docs/` — a page describing the class-derived allowlist and why the resolver is the single decision point.
|
|
2146
|
+
- `platform/plugins/docs/references/` — the client-facing description of what the portal shows. **No task numbers in this file**: the leak gate refuses task citations anywhere under `platform/plugins`, which is Task 1830.
|
|
2147
|
+
|
|
2148
|
+
- [ ] **Step 4: Run the whole surface**
|
|
2149
|
+
|
|
2150
|
+
```bash
|
|
2151
|
+
export PATH="$HOME/.nvm/versions/node/v22.22.0/bin:$PATH"
|
|
2152
|
+
cd platform/plugins/cloudflare/mcp && ../../../node_modules/.bin/vitest run && npm run typecheck:templates
|
|
2153
|
+
cd ../../../ui && node_modules/.bin/vitest run server/routes/__tests__/portal-fetch.test.ts
|
|
2154
|
+
cd ../plugins/scheduling/mcp && ../../../node_modules/.bin/tsc --noEmit -p tsconfig.json
|
|
2155
|
+
```
|
|
2156
|
+
|
|
2157
|
+
Expected: all green; typecheck exit 0.
|
|
2158
|
+
|
|
2159
|
+
- [ ] **Step 5: Commit**
|
|
2160
|
+
|
|
2161
|
+
```bash
|
|
2162
|
+
git add platform/plugins/cloudflare platform/plugins/scheduling .docs
|
|
2163
|
+
git commit -m "feat(data-portal): assembly bindings, hourly index push, docs"
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
---
|
|
2167
|
+
|
|
2168
|
+
## Self-Review
|
|
2169
|
+
|
|
2170
|
+
**Spec coverage.** Resolver → Task 1. Signed links → Task 2. D1 schema and enrolment binding → Task 3. D1 transport → Task 4. Index push and walker → Task 5. Listing → Task 6. Download branch with offline pre-flight → Task 7. Install route with re-derived exposure → Task 8. Portal UI → Task 9. Assembly, heartbeat, docs → Task 10. Every spec section maps to a task.
|
|
2171
|
+
|
|
2172
|
+
**Type consistency.** `resolveExposedDirs` returns the same six-field object in Tasks 1, 5 and 8. `signPath`/`verifyPath` parameter order is identical in Tasks 2, 7 and 8. `DirEntry` is defined once in Task 6 and consumed by Task 9. `pushAccount` and `walkExposed` signatures match between Task 5's implementation and its tests.
|
|
2173
|
+
|
|
2174
|
+
**Known gaps, deliberate.**
|
|
2175
|
+
- Task 9 has no automated test. The portal front end has no harness in this repo and adding one is a larger change than the feature.
|
|
2176
|
+
- The heartbeat spawn in Task 10 copies a mechanism that has never been observed running on a device (Task 1828). The push is runnable standalone so it stays operable regardless, and the SKILL tells the assembler to run it by hand once.
|
|
2177
|
+
- `cf-exec.d1Query` still spawns `wrangler`. Converging it with the new client is a follow-up, to be filed at land as Task 1834 alongside Task 1812.
|