@rubytech/create-maxy-code 0.1.488 → 0.1.490
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +748 -0
- package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +1716 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +121 -0
- package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +103 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js +51 -0
- package/payload/platform/lib/graph-mcp/dist/__tests__/schema-cypher-parser.test.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts +2 -2
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js +20 -4
- package/payload/platform/lib/graph-mcp/dist/schema-cypher-parser.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/__tests__/schema-cypher-parser.test.ts +55 -0
- package/payload/platform/lib/graph-mcp/src/schema-cypher-parser.ts +19 -4
- package/payload/platform/lib/graph-style/dist/index.d.ts +24 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +25 -1
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/additional-base-labels.test.ts +22 -0
- package/payload/platform/lib/graph-style/src/index.ts +27 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js +15 -0
- package/payload/platform/lib/storage-broker/dist/__tests__/audit.test.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts +34 -1
- package/payload/platform/lib/storage-broker/dist/audit.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/audit.js +28 -4
- package/payload/platform/lib/storage-broker/dist/audit.js.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +13 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
- package/payload/platform/lib/storage-broker/dist/cf-exec.js +22 -0
- package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
- package/payload/platform/lib/storage-broker/src/__tests__/audit.test.ts +21 -0
- package/payload/platform/lib/storage-broker/src/audit.ts +67 -3
- package/payload/platform/lib/storage-broker/src/cf-exec.ts +48 -0
- package/payload/platform/neo4j/schema.cypher +105 -0
- package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +34 -5
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +8 -2
- package/payload/platform/plugins/cloudflare/PLUGIN.md +22 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +161 -41
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-delete-withdraw.test.ts +95 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +75 -13
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +216 -16
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +38 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-moved-download.test.ts +83 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-schema-shape.test.ts +48 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-sign.test.ts +62 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-target.test.ts +103 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-multipart.test.ts +211 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-upload-target.test.ts +168 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-uploads-listing.test.ts +138 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +179 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +14 -13
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +87 -13
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +87 -9
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/portal-sign.mjs +74 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/target.ts +91 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +23 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +45 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +72 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +80 -13
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +358 -16
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +12 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +45 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +258 -21
- package/payload/platform/plugins/docs/references/calendar-booking.md +5 -1
- package/payload/platform/plugins/docs/references/internals.md +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js +61 -31
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/schema-cypher-drift.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/index.js +2 -2
- package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js +70 -5
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/booking-reconcile.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js +31 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/getUserTimezone.test.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +31 -4
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +18 -7
- package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts +15 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.d.ts.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js +24 -11
- package/payload/platform/plugins/scheduling/mcp/dist/lib/neo4j.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +5 -122
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +84 -5
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
- package/payload/platform/scripts/__tests__/quarantine-restore.test.sh +143 -0
- package/payload/platform/scripts/quarantine-restore.mjs +175 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +24 -7
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +52 -74
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +11 -8
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +32 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +32 -2
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts +6 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.js +6 -0
- package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
- package/payload/platform/templates/account-schema/SCHEMA.md +7 -4
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/passive/IDENTITY.md +6 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +6 -0
- package/payload/platform/templates/specialists/agents/citation-auditor.md +6 -0
- package/payload/platform/templates/specialists/agents/coding-assistant.md +6 -0
- package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +6 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +6 -0
- package/payload/platform/templates/specialists/agents/data-manager.md +6 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +6 -0
- package/payload/platform/templates/specialists/agents/librarian.md +6 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -0
- package/payload/platform/templates/specialists/agents/project-manager.md +6 -0
- package/payload/platform/templates/specialists/agents/public-session-reviewer.md +6 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +7 -1
- package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +6 -0
- package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +6 -0
- package/payload/server/{chunk-REWXLAPC.js → chunk-KZFE3MOY.js} +52 -4
- package/payload/server/public/activity.html +5 -6
- package/payload/server/public/assets/AdminLoginScreens-DgVFsHtd.js +1 -0
- package/payload/server/public/assets/AdminShell-BwNqwb4k.js +2 -0
- package/payload/server/public/assets/activity-2DL2DwLr.js +1 -0
- package/payload/server/public/assets/admin-COe0dT2U.js +1 -0
- package/payload/server/public/assets/{arc-D-QzyndF.js → arc-CFMJyMm-.js} +1 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-Cypt5qHL.js +1 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-W7f0Us4S.js → architectureDiagram-Q4EWVU46-CiSSgdL8.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CfPS4FXg.js → blockDiagram-DXYQGD6D-_sXtIW5c.js} +1 -1
- package/payload/server/public/assets/{bot-DnuyRsLS.js → bot-CpBuDQlF.js} +1 -1
- package/payload/server/public/assets/{browser-q4EFqKvR.js → browser-CSApuZrv.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Dq60cLVI.js → c4Diagram-AHTNJAMY-Bh7OAMXs.js} +1 -1
- package/payload/server/public/assets/{calendar-C-q9kWmA.js → calendar-DlvwZnS7.js} +1 -1
- package/payload/server/public/assets/channel-BMyXE4zc.js +1 -0
- package/payload/server/public/assets/chat-DWvYv9jq.js +1 -0
- package/payload/server/public/assets/chevron-down-Dnuuw6Q2.js +1 -0
- package/payload/server/public/assets/chevron-left-CC4zmWOr.js +1 -0
- package/payload/server/public/assets/chevron-right-CG_lHTx_.js +1 -0
- package/payload/server/public/assets/{chunk-2KRD3SAO-CXDjuDN0.js → chunk-2KRD3SAO-D4fktAwW.js} +1 -1
- package/payload/server/public/assets/{chunk-336JU56O-926ox0tj.js → chunk-336JU56O-CcQoKH2G.js} +2 -2
- package/payload/server/public/assets/chunk-426QAEUC-Xu6JKP4d.js +1 -0
- package/payload/server/public/assets/{chunk-4BX2VUAB-C2nrPCfN.js → chunk-4BX2VUAB-C13Wz3zS.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-CuW3FOR6.js → chunk-4TB4RGXK-BL48D7gN.js} +1 -1
- package/payload/server/public/assets/{chunk-55IACEB6-B4_-In90.js → chunk-55IACEB6-TmOKdy8a.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-DnYGr9CP.js → chunk-5FUZZQ4R-DZ_jSF6N.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-BYCOi9bf.js → chunk-5PVQY5BW-De4G1vfV.js} +1 -1
- package/payload/server/public/assets/{chunk-67CJDMHE-CBxsC4JP.js → chunk-67CJDMHE-Cif9Uu5C.js} +1 -1
- package/payload/server/public/assets/{chunk-7N4EOEYR-CAACaqFr.js → chunk-7N4EOEYR-WjTHCXnd.js} +1 -1
- package/payload/server/public/assets/{chunk-AA7GKIK3-BoamZdS6.js → chunk-AA7GKIK3-CwCfdQmb.js} +1 -1
- package/payload/server/public/assets/{chunk-BSJP7CBP-CHk82eFy.js → chunk-BSJP7CBP-9A67yXwT.js} +1 -1
- package/payload/server/public/assets/{chunk-CIAEETIT-DdeW9zxl.js → chunk-CIAEETIT-DPMiWSBi.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DJqSRv2w.js → chunk-EDXVE4YY-C2f_Qvx-.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-BRi4BwM5.js → chunk-ENJZ2VHE-B-Q9EbSQ.js} +1 -1
- package/payload/server/public/assets/{chunk-FMBD7UC4-OJp2CtUE.js → chunk-FMBD7UC4-Bmt_ZRlV.js} +1 -1
- package/payload/server/public/assets/{chunk-FOC6F5B3-BUAQ6spG.js → chunk-FOC6F5B3-DfGJGxpX.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-DeRggD-Q.js → chunk-ICPOFSXX-Wyym1Q8K.js} +2 -2
- package/payload/server/public/assets/{chunk-K5T4RW27-EHiXZ8Py.js → chunk-K5T4RW27-DMpoaqw2.js} +1 -1
- package/payload/server/public/assets/{chunk-KGLVRYIC-tOajPcpn.js → chunk-KGLVRYIC-DYUFgri2.js} +1 -1
- package/payload/server/public/assets/{chunk-LIHQZDEY-uwO6oHIU.js → chunk-LIHQZDEY-x3LmeKmx.js} +1 -1
- package/payload/server/public/assets/{chunk-ORNJ4GCN-Dpa1_BCA.js → chunk-ORNJ4GCN-BeuNAWkW.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-ChdDqaQL.js → chunk-OYMX7WX6-BUWVAfQA.js} +1 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-7_kS3RBy.js +1 -0
- package/payload/server/public/assets/{chunk-U2HBQHQK-5JgWosPv.js → chunk-U2HBQHQK-DKS5adQo.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-BGJqysB2.js → chunk-X2U36JSP-CT_UM4IN.js} +1 -1
- package/payload/server/public/assets/{chunk-XPW4576I-GERE2ekx.js → chunk-XPW4576I-ByFnN4JO.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BskqX1V5.js → chunk-YZCP3GAM-DCetC20J.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-DG1iY-L9.js → chunk-ZZ45TVLE-Bh1QIv8N.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DJqbNcuC.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-CGglqfQA.js +1 -0
- package/payload/server/public/assets/clone-BiTMTzp_.js +1 -0
- package/payload/server/public/assets/{cose-bilkent-S5V4N54A-C8Rl4ykh.js → cose-bilkent-S5V4N54A-DgLEaApT.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-C0YrJPF3.js → dagre-KV5264BT-BfkUPrEV.js} +1 -1
- package/payload/server/public/assets/{dagre-DTlv-FFy.js → dagre-unhMIyVo.js} +1 -1
- package/payload/server/public/assets/data-CJbC_cVy.js +1 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-Dfk5oepI.js → diagram-5BDNPKRD-BjjwcTD9.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-QRj55DiB.js → diagram-G4DWMVQ6-BbgWtVSS.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-BAPKsohU.js → diagram-MMDJMWI5-DyAnkWGy.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-C3iZXPkC.js → diagram-TYMM5635-SAMMoLxq.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-DIbI7g_5.js → erDiagram-SMLLAGMA-Cvo-5-fD.js} +1 -1
- package/payload/server/public/assets/{flatten-DlAJeN0L.js → flatten-BXzaHgtL.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-l3eJJr8R.js → flowDiagram-DWJPFMVM-y3o2Ycld.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-aaEzHxIq.js → ganttDiagram-T4ZO3ILL-BEm_CQaK.js} +1 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-C_-XjxLd.js +1 -0
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-Di-1eg_m.js → gitGraphDiagram-UUTBAWPF-CqmOxS7F.js} +1 -1
- package/payload/server/public/assets/{graph-CNgt7ygi.js → graph-kaxS9a_7.js} +2 -2
- package/payload/server/public/assets/graph-labels-GgejRtgV.js +1 -0
- package/payload/server/public/assets/{graphlib-D5awC2yp.js → graphlib-_HfbQcBf.js} +1 -1
- package/payload/server/public/assets/info-OMHHGYJF-BH8yDCfL.js +1 -0
- package/payload/server/public/assets/infoDiagram-42DDH7IO-DyDXyJ6U.js +2 -0
- package/payload/server/public/assets/{isEmpty-Dh1dF-tM.js → isEmpty-uB1dEu_Z.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-CLOd_cNf.js → ishikawaDiagram-UXIWVN3A-C_d0vcZC.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-cGyfW6Tg.js → journeyDiagram-VCZTEJTY-DsjRBqlW.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CRTKWPlh.js → kanban-definition-6JOO6SKY-BwtK6LS-.js} +1 -1
- package/payload/server/public/assets/{line-B8Brm0TD.js → line-szBCFOLJ.js} +1 -1
- package/payload/server/public/assets/{linear-BHnSKXBe.js → linear-X3O7lwK3.js} +1 -1
- package/payload/server/public/assets/{maximize-2-k3Fu5hdP.js → maximize-2-CPq3nwZA.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-BtNpdQct.js → mermaid-parser.core-BavvMAhU.js} +2 -2
- package/payload/server/public/assets/{mermaid.core-BY87anQZ.js → mermaid.core-Br2F9Hdm.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-rUSv9qN1.js → mindmap-definition-QFDTVHPH-4qlwXoJ-.js} +1 -1
- package/payload/server/public/assets/operator-CldXXilj.js +1 -0
- package/payload/server/public/assets/{ordinal-Bnl5GL-x.js → ordinal-CZY9laxg.js} +1 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-Bajcns4r.js +1 -0
- package/payload/server/public/assets/{page-Bvx0A2jB.js → page-BE6DkJP9.js} +1 -1
- package/payload/server/public/assets/{page-DhwPG5Zf.js → page-BIwdWXnv.js} +3 -3
- package/payload/server/public/assets/{pdf-render-CrpAi4Ba.js → pdf-render-H884L3EI.js} +1 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-D7hjM5w1.js +1 -0
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-Cjn9IP-C.js → pieDiagram-DEJITSTG-BbXryIg0.js} +1 -1
- package/payload/server/public/assets/{public-BJ5Oo2Fw.js → public-BKQEQGPE.js} +1 -1
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-BkSHkSsn.js → quadrantDiagram-34T5L4WZ-BHLjcvh3.js} +1 -1
- package/payload/server/public/assets/radar-PYXPWWZC-B46d5x7a.js +1 -0
- package/payload/server/public/assets/{reduce-Bp5oNw1n.js → reduce-BGQzAenn.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-hLwgyB-F.js → requirementDiagram-MS252O5E-BXaVO0lk.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-D-ov5jM4.js → rotate-ccw-DieZzl10.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-D6Dl1RIa.js → sankeyDiagram-XADWPNL6-CmN1s17Q.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-j0QBqkbL.js → sequenceDiagram-FGHM5R23-DC08e5NY.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-TvEBNsuV.js → stateDiagram-FHFEXIEX-DnoQMuOW.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-CrstH34p.js +1 -0
- package/payload/server/public/assets/{tasks-DHMxTZVD.js → tasks-DYyCGz8W.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-EmV5lz6o.js → time-entry-format-CC_9jFuR.js} +1 -1
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-BfivOP-j.js → timeline-definition-GMOUNBTQ-eTn3uRaH.js} +1 -1
- package/payload/server/public/assets/treeView-SZITEDCU-3XQh5xQ1.js +1 -0
- package/payload/server/public/assets/treemap-W4RFUUIX-DwR-_l5z.js +1 -0
- package/payload/server/public/assets/{triangle-alert-CivyPfAL.js → triangle-alert-7mQfCQWP.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-hbnPS-r9.js → useCopyFeedback-os7N0yCx.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-Bgfqig1V.css +1 -0
- package/payload/server/public/assets/{useVoiceRecorder-QN9_GqB_.js → useVoiceRecorder-ZFwt5i6d.js} +2 -2
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-Bvfw9aFJ.js → vennDiagram-DHZGUBPP-DNx6o_ZQ.js} +1 -1
- package/payload/server/public/assets/wardley-RL74JXVD-DZp9oCGv.js +1 -0
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-C9h04G6b.js → wardleyDiagram-NUSXRM2D-BZOchqF3.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-BMvi2m2q.js → xychartDiagram-5P7HB3ND-ZBEGH6ZI.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +14 -14
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +10 -11
- package/payload/server/public/index.html +16 -17
- package/payload/server/public/operator.html +15 -16
- package/payload/server/public/public.html +14 -14
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +1836 -998
- package/payload/server/{src-JJ6PHO5I.js → src-UYDFMWF4.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-Dic_fPt6.js +0 -1
- package/payload/server/public/assets/AdminShell-JN7rZ8aG.js +0 -2
- package/payload/server/public/assets/Checkbox-CB74kINA.js +0 -1
- package/payload/server/public/assets/activity-DbfpHg3i.js +0 -1
- package/payload/server/public/assets/admin-CThbXaYH.js +0 -1
- package/payload/server/public/assets/architecture-YZFGNWBL-CGvD4r9F.js +0 -1
- package/payload/server/public/assets/channel-Bpg-ADC0.js +0 -1
- package/payload/server/public/assets/chat-DcefIV9q.js +0 -1
- package/payload/server/public/assets/chevron-down-Blma_CKl.js +0 -1
- package/payload/server/public/assets/chevron-left-xeYDxFiC.js +0 -1
- package/payload/server/public/assets/chevron-right-BonbtjqJ.js +0 -1
- package/payload/server/public/assets/chunk-426QAEUC-1poG7Nd3.js +0 -1
- package/payload/server/public/assets/chunk-QZHKN3VN-GN1tIO8J.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMGVq8Jv.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DfddT8UV.js +0 -1
- package/payload/server/public/assets/clone-Cc9vFIEk.js +0 -1
- package/payload/server/public/assets/data-D5dp6nj6.js +0 -1
- package/payload/server/public/assets/gitGraph-7Q5UKJZL-DtjXE0UI.js +0 -1
- package/payload/server/public/assets/graph-labels-DA48HrRR.js +0 -1
- package/payload/server/public/assets/info-OMHHGYJF-CmArhGYf.js +0 -1
- package/payload/server/public/assets/infoDiagram-42DDH7IO-utWz7oZy.js +0 -2
- package/payload/server/public/assets/operator-8NVpNLeD.js +0 -1
- package/payload/server/public/assets/packet-4T2RLAQJ-BTfK0Zw4.js +0 -1
- package/payload/server/public/assets/pie-ZZUOXDRM-BvtPeMQY.js +0 -1
- package/payload/server/public/assets/radar-PYXPWWZC-D2UXvQgn.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Ur4eM6rb.js +0 -1
- package/payload/server/public/assets/treeView-SZITEDCU-0dE7FvM2.js +0 -1
- package/payload/server/public/assets/treemap-W4RFUUIX-BrwlXpr5.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-kwhG-aJj.css +0 -1
- package/payload/server/public/assets/wardley-RL74JXVD-CO9HdfJ-.js +0 -1
- /package/payload/server/public/assets/{_baseFor-BBkmqPEk.js → _baseFor-CMWvz0uA.js} +0 -0
- /package/payload/server/public/assets/{array-Bjtb2C_5.js → array-Cny_NT6T.js} +0 -0
- /package/payload/server/public/assets/{cytoscape.esm-B1dQ664k.js → cytoscape.esm-De0CCmB2.js} +0 -0
- /package/payload/server/public/assets/{defaultLocale-iwYA7ne8.js → defaultLocale-BahRht8U.js} +0 -0
- /package/payload/server/public/assets/{dist-CLmrZbY7.js → dist-C9D_xz81.js} +0 -0
- /package/payload/server/public/assets/{init-CxnBAgcf.js → init-DoIvjoJ_.js} +0 -0
- /package/payload/server/public/assets/{katex-DojHoLIr.js → katex-Bfn1OZEl.js} +0 -0
- /package/payload/server/public/assets/{path-JZiojYzQ.js → path-CTezU8L5.js} +0 -0
- /package/payload/server/public/assets/{preload-helper-CT1Z6Pdu.js → preload-helper-D_sPnjeL.js} +0 -0
- /package/payload/server/public/assets/{rough.esm-D5NinLFK.js → rough.esm-BJo4pvXu.js} +0 -0
- /package/payload/server/public/assets/{src-DQ0MSD6q.js → src-BTKk8KAG.js} +0 -0
- /package/payload/server/public/assets/{useSubAccountSwitcher-DotTrvX3.js → useSubAccountSwitcher-Q941cZYV.js} +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, unlinkSync, utimesSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { walkExposed, pushAccount } from '../../bin/portal-index-push.mjs'
|
|
@@ -12,6 +12,10 @@ function file(rel: string, body = 'x') {
|
|
|
12
12
|
writeFileSync(abs, body)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function emptyDir(rel: string) {
|
|
16
|
+
mkdirSync(join(dir, rel), { recursive: true })
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
const SCHEMA = [
|
|
16
20
|
'```allowed-top-level',
|
|
17
21
|
'output',
|
|
@@ -45,12 +49,13 @@ function fakeClient() {
|
|
|
45
49
|
calls,
|
|
46
50
|
async query(sql: string, params: unknown[] = []) {
|
|
47
51
|
calls.push({ sql, params })
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
// The open paren is load-bearing: `INSERT INTO directory_state` would
|
|
53
|
+
// otherwise register as a row insert and inflate every count.
|
|
54
|
+
if (/INSERT INTO directory \(/i.test(sql)) {
|
|
55
|
+
rows.add(String(params[1]))
|
|
50
56
|
return []
|
|
51
57
|
}
|
|
52
|
-
if (/
|
|
53
|
-
rows.add(String(params[1]))
|
|
58
|
+
if (/DELETE FROM directory\b/i.test(sql)) {
|
|
54
59
|
return []
|
|
55
60
|
}
|
|
56
61
|
if (/SELECT COUNT/i.test(sql)) return [{ n: rows.size }]
|
|
@@ -71,8 +76,11 @@ const push = (client: unknown, overrides: Record<string, unknown> = {}) =>
|
|
|
71
76
|
})
|
|
72
77
|
|
|
73
78
|
describe('walkExposed', () => {
|
|
74
|
-
it('returns files
|
|
75
|
-
const paths = (await walkExposed(dir, ['output', 'quotes']))
|
|
79
|
+
it('returns files from exposed dirs, at any depth', async () => {
|
|
80
|
+
const paths = (await walkExposed(dir, ['output', 'quotes']))
|
|
81
|
+
.filter((r) => !r.isDir)
|
|
82
|
+
.map((r) => r.relPath)
|
|
83
|
+
.sort()
|
|
76
84
|
expect(paths).toEqual(['output/report.pdf', 'quotes/CR2969/quote.pdf', 'quotes/CR2974/quote.pdf'])
|
|
77
85
|
})
|
|
78
86
|
|
|
@@ -89,29 +97,93 @@ describe('walkExposed', () => {
|
|
|
89
97
|
})
|
|
90
98
|
|
|
91
99
|
it('carries size and an iso modified time', async () => {
|
|
92
|
-
const row = (await walkExposed(dir, ['output']))
|
|
100
|
+
const row = (await walkExposed(dir, ['output'])).find((r) => !r.isDir)!
|
|
93
101
|
expect(row.sizeBytes).toBe(1)
|
|
94
102
|
expect(row.modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
95
103
|
})
|
|
104
|
+
|
|
105
|
+
// An exposed folder holding no files at any depth is the case the client
|
|
106
|
+
// could not see at all: nothing derived a folder name from a file prefix.
|
|
107
|
+
it('emits exactly one row for an exposed dir holding no files, marked isDir', async () => {
|
|
108
|
+
emptyDir('jobs')
|
|
109
|
+
const rows = await walkExposed(dir, ['jobs'])
|
|
110
|
+
expect(rows.length).toBe(1)
|
|
111
|
+
expect(rows[0].relPath).toBe('jobs')
|
|
112
|
+
expect(rows[0].isDir).toBe(true)
|
|
113
|
+
expect(rows[0].sizeBytes).toBe(0)
|
|
114
|
+
expect(rows[0].modifiedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('emits the dir row and the file row for a dir holding a nested file', async () => {
|
|
118
|
+
const rows = await walkExposed(dir, ['quotes'])
|
|
119
|
+
const byPath = new Map(rows.map((r) => [r.relPath, r]))
|
|
120
|
+
expect(byPath.get('quotes')?.isDir).toBe(true)
|
|
121
|
+
expect(byPath.get('quotes/CR2969')?.isDir).toBe(true)
|
|
122
|
+
expect(byPath.get('quotes/CR2969/quote.pdf')?.isDir).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// The dir row's time is the directory's own, not the walk's: a folder whose
|
|
126
|
+
// contents changed is what the operator reads, and `new Date()` here would
|
|
127
|
+
// make every row look freshly touched every cycle.
|
|
128
|
+
it('takes the dir mtime rather than the time of the walk', async () => {
|
|
129
|
+
emptyDir('jobs')
|
|
130
|
+
const past = new Date('2020-03-04T05:06:07.000Z')
|
|
131
|
+
utimesSync(join(dir, 'jobs'), past, past)
|
|
132
|
+
const rows = await walkExposed(dir, ['jobs'])
|
|
133
|
+
expect(rows[0].modifiedAt).toBe('2020-03-04T05:06:07.000Z')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('emits no row for a dot-prefixed dir at any level', async () => {
|
|
137
|
+
const paths = (await walkExposed(dir, ['output'])).map((r) => r.relPath)
|
|
138
|
+
expect(paths).not.toContain('output/.uploads-tmp')
|
|
139
|
+
})
|
|
96
140
|
})
|
|
97
141
|
|
|
98
142
|
describe('pushAccount', () => {
|
|
99
|
-
|
|
143
|
+
// 7 rows: the 3 files plus output/, quotes/, quotes/CR2969/, quotes/CR2974/.
|
|
144
|
+
it('stages the current walk, then flips the pointer to it', async () => {
|
|
145
|
+
// Task 1910 replaced delete-then-insert with stage-flip-sweep. The first
|
|
146
|
+
// statement now READS the pointer rather than emptying the table, which is
|
|
147
|
+
// what removes the window where a client saw nothing.
|
|
100
148
|
const c = fakeClient()
|
|
101
149
|
const r = await push(c)
|
|
102
150
|
expect(r.exposed).toEqual(['output', 'quotes'])
|
|
103
|
-
expect(r.rows).toBe(
|
|
104
|
-
expect(/
|
|
151
|
+
expect(r.rows).toBe(7)
|
|
152
|
+
expect(/SELECT currentGeneration/i.test(c.calls[0].sql)).toBe(true)
|
|
105
153
|
expect(c.calls[0].params).toEqual(['acc-1'])
|
|
106
|
-
expect(c.calls.filter((x) => /INSERT INTO directory/i.test(x.sql)).length).toBe(
|
|
154
|
+
expect(c.calls.filter((x) => /INSERT INTO directory \(/i.test(x.sql)).length).toBe(7)
|
|
107
155
|
})
|
|
108
156
|
|
|
157
|
+
// The folder itself survives its last file: emptied is not the same as gone,
|
|
158
|
+
// and the client still needs somewhere to put the next one.
|
|
109
159
|
it('converges on a delete: a removed file leaves no stale row', async () => {
|
|
110
160
|
const c = fakeClient()
|
|
111
161
|
await push(c)
|
|
112
162
|
unlinkSync(join(dir, 'quotes/CR2974/quote.pdf'))
|
|
163
|
+
// The fake accumulates calls across pushes; only the second one is the
|
|
164
|
+
// converged state.
|
|
165
|
+
const before = c.calls.length
|
|
113
166
|
const second = await push(c)
|
|
114
|
-
expect(second.rows).toBe(
|
|
167
|
+
expect(second.rows).toBe(6)
|
|
168
|
+
const inserted = c.calls
|
|
169
|
+
.slice(before)
|
|
170
|
+
.filter((x) => /INSERT INTO directory/i.test(x.sql))
|
|
171
|
+
.map((x) => String(x.params[1]))
|
|
172
|
+
expect(inserted).toContain('quotes/CR2974')
|
|
173
|
+
expect(inserted).not.toContain('quotes/CR2974/quote.pdf')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('marks each inserted row as a directory or not', async () => {
|
|
177
|
+
const c = fakeClient()
|
|
178
|
+
await push(c)
|
|
179
|
+
const byPath = new Map(
|
|
180
|
+
c.calls
|
|
181
|
+
.filter((x) => /INSERT INTO directory/i.test(x.sql))
|
|
182
|
+
.map((x) => [String(x.params[1]), x.params[5]]),
|
|
183
|
+
)
|
|
184
|
+
expect(byPath.get('quotes')).toBe(1)
|
|
185
|
+
expect(byPath.get('quotes/CR2969')).toBe(1)
|
|
186
|
+
expect(byPath.get('quotes/CR2969/quote.pdf')).toBe(0)
|
|
115
187
|
})
|
|
116
188
|
|
|
117
189
|
it('binds every value as a param rather than interpolating it', async () => {
|
|
@@ -133,13 +205,14 @@ describe('pushAccount', () => {
|
|
|
133
205
|
expect(c.calls.length).toBe(0)
|
|
134
206
|
})
|
|
135
207
|
|
|
136
|
-
it('logs resolve,
|
|
208
|
+
it('logs resolve, stage, flip and verify lines', async () => {
|
|
137
209
|
const lines: string[] = []
|
|
138
210
|
const c = fakeClient()
|
|
139
211
|
await push(c, { log: (l: string) => lines.push(l) })
|
|
140
212
|
expect(lines.some((l) => l.includes('op=resolve') && l.includes('exposed=output,quotes'))).toBe(true)
|
|
141
|
-
expect(lines.some((l) => l.includes('op=
|
|
142
|
-
expect(lines.some((l) => l.includes('op=
|
|
213
|
+
expect(lines.some((l) => l.includes('op=stage') && l.includes('inserted=7'))).toBe(true)
|
|
214
|
+
expect(lines.some((l) => l.includes('op=flip') && l.includes('generation=1'))).toBe(true)
|
|
215
|
+
expect(lines.some((l) => l.includes('op=verify') && l.includes('rowsAfter=7') && l.includes('expected=7'))).toBe(true)
|
|
143
216
|
})
|
|
144
217
|
|
|
145
218
|
it('reports an exposed-set change only when it actually changes', async () => {
|
|
@@ -197,3 +270,130 @@ describe('pushAccount', () => {
|
|
|
197
270
|
expect(lines.some((l) => /op=resolve .*extraFolders=0/.test(l))).toBe(true)
|
|
198
271
|
})
|
|
199
272
|
})
|
|
273
|
+
|
|
274
|
+
// The atomic replace (Task 1910), superseding backlog Task 1842.
|
|
275
|
+
//
|
|
276
|
+
// A generation-aware client: rows carry a generation, and a reader sees only
|
|
277
|
+
// the generation the pointer names. That is what makes "never a half-built
|
|
278
|
+
// tree" a measurable property rather than an assertion.
|
|
279
|
+
function generationClient() {
|
|
280
|
+
const calls: { sql: string; params: unknown[] }[] = []
|
|
281
|
+
/** @type {{accountId: string, relPath: string, generation: number}[]} */
|
|
282
|
+
const rows: { accountId: string; relPath: string; generation: number }[] = []
|
|
283
|
+
let pointer: number | null = null
|
|
284
|
+
return {
|
|
285
|
+
calls,
|
|
286
|
+
rows,
|
|
287
|
+
/** What a client listing right now would see. */
|
|
288
|
+
visible() {
|
|
289
|
+
return pointer === null ? [] : rows.filter((r) => r.generation === pointer)
|
|
290
|
+
},
|
|
291
|
+
pointerValue() {
|
|
292
|
+
return pointer
|
|
293
|
+
},
|
|
294
|
+
async query(sql: string, params: unknown[] = []) {
|
|
295
|
+
calls.push({ sql, params })
|
|
296
|
+
if (/SELECT\s+currentGeneration/i.test(sql)) {
|
|
297
|
+
return pointer === null ? [] : [{ currentGeneration: pointer }]
|
|
298
|
+
}
|
|
299
|
+
if (/INSERT INTO directory_state|UPDATE directory_state/i.test(sql)) {
|
|
300
|
+
pointer = Number(params[1] ?? params[0])
|
|
301
|
+
return []
|
|
302
|
+
}
|
|
303
|
+
if (/INSERT INTO directory\b/i.test(sql)) {
|
|
304
|
+
rows.push({
|
|
305
|
+
accountId: String(params[0]),
|
|
306
|
+
relPath: String(params[1]),
|
|
307
|
+
generation: Number(params[6]),
|
|
308
|
+
})
|
|
309
|
+
return []
|
|
310
|
+
}
|
|
311
|
+
if (/DELETE FROM directory\b/i.test(sql)) {
|
|
312
|
+
const keep = Number(params[1])
|
|
313
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
314
|
+
if (rows[i].generation !== keep) rows.splice(i, 1)
|
|
315
|
+
}
|
|
316
|
+
return []
|
|
317
|
+
}
|
|
318
|
+
if (/SELECT COUNT/i.test(sql)) {
|
|
319
|
+
return [{ n: rows.filter((r) => r.generation === pointer).length }]
|
|
320
|
+
}
|
|
321
|
+
return []
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
describe('the index replace is atomic', () => {
|
|
327
|
+
it('never issues a delete before the pointer flip', async () => {
|
|
328
|
+
// The delete-then-insert window this replaces was Task 1842: a client
|
|
329
|
+
// listing mid-push saw an empty or half-built tree. At 60s cadence that
|
|
330
|
+
// window would be hit sixty times more often than it was hourly.
|
|
331
|
+
const c = generationClient()
|
|
332
|
+
await push(c)
|
|
333
|
+
const flipAt = c.calls.findIndex((x) => /INSERT INTO directory_state/i.test(x.sql))
|
|
334
|
+
const deleteAt = c.calls.findIndex((x) => /^DELETE FROM directory\b/i.test(x.sql.trim()))
|
|
335
|
+
expect(flipAt).toBeGreaterThanOrEqual(0)
|
|
336
|
+
expect(deleteAt).toBeGreaterThan(flipAt)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('writes every row before the flip', async () => {
|
|
340
|
+
const c = generationClient()
|
|
341
|
+
await push(c)
|
|
342
|
+
const flipAt = c.calls.findIndex((x) => /INSERT INTO directory_state/i.test(x.sql))
|
|
343
|
+
const lastInsert = c.calls.map((x) => /INSERT INTO directory\b/i.test(x.sql)).lastIndexOf(true)
|
|
344
|
+
expect(lastInsert).toBeLessThan(flipAt)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('a reader mid-push sees the old tree, never a partial one', async () => {
|
|
348
|
+
// First publish establishes generation 1.
|
|
349
|
+
const c = generationClient()
|
|
350
|
+
await push(c)
|
|
351
|
+
const first = c.visible().map((r) => r.relPath).sort()
|
|
352
|
+
expect(first.length).toBeGreaterThan(0)
|
|
353
|
+
|
|
354
|
+
// A second publish stages generation 2. Until the flip, `visible()` must
|
|
355
|
+
// still be exactly the first tree — not a mixture, not an empty set.
|
|
356
|
+
const seen: string[][] = []
|
|
357
|
+
const watched = {
|
|
358
|
+
...c,
|
|
359
|
+
async query(sql: string, params: unknown[] = []) {
|
|
360
|
+
const out = await c.query(sql, params)
|
|
361
|
+
seen.push(c.visible().map((r) => r.relPath).sort())
|
|
362
|
+
return out
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
await push(watched)
|
|
366
|
+
|
|
367
|
+
const flipIdx = c.calls.findIndex(
|
|
368
|
+
(x, i) => i > first.length && /INSERT INTO directory_state/i.test(x.sql),
|
|
369
|
+
)
|
|
370
|
+
for (let i = 0; i < flipIdx; i++) {
|
|
371
|
+
expect(seen[i]).toEqual(first)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('advances the generation on each publish', async () => {
|
|
376
|
+
const c = generationClient()
|
|
377
|
+
await push(c)
|
|
378
|
+
expect(c.pointerValue()).toBe(1)
|
|
379
|
+
await push(c)
|
|
380
|
+
expect(c.pointerValue()).toBe(2)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('deletes superseded generations after the flip', async () => {
|
|
384
|
+
const c = generationClient()
|
|
385
|
+
await push(c)
|
|
386
|
+
await push(c)
|
|
387
|
+
expect(c.rows.every((r) => r.generation === 2)).toBe(true)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('writes nothing at all when the exposed set is empty', async () => {
|
|
391
|
+
// Fail closed must not delete the account rows: that state would be
|
|
392
|
+
// indistinguishable from a successful publish of an account with no
|
|
393
|
+
// deliverables.
|
|
394
|
+
rmSync(join(dir, 'SCHEMA.md'))
|
|
395
|
+
const c = generationClient()
|
|
396
|
+
await push(c)
|
|
397
|
+
expect(c.calls.filter((x) => /INSERT|DELETE|directory_state/i.test(x.sql))).toEqual([])
|
|
398
|
+
})
|
|
399
|
+
})
|
|
@@ -10,14 +10,17 @@ function makeEnv(
|
|
|
10
10
|
secret?: string
|
|
11
11
|
origin?: string
|
|
12
12
|
paths?: string[]
|
|
13
|
+
dirPaths?: string[]
|
|
13
14
|
folders?: string
|
|
15
|
+
onQuery?: (query: string) => void
|
|
14
16
|
} = {},
|
|
15
17
|
) {
|
|
16
|
-
const { accountId = 'acc-a', paths = [PATH] } = opts
|
|
18
|
+
const { accountId = 'acc-a', paths = [PATH], dirPaths = [], onQuery = () => {} } = opts
|
|
17
19
|
const folders = 'folders' in opts ? opts.folders : ''
|
|
18
20
|
return {
|
|
19
21
|
DB: {
|
|
20
22
|
prepare(query: string) {
|
|
23
|
+
onQuery(query)
|
|
21
24
|
let bound: unknown[] = []
|
|
22
25
|
const stmt = {
|
|
23
26
|
bind(...v: unknown[]) {
|
|
@@ -37,7 +40,9 @@ function makeEnv(
|
|
|
37
40
|
: null) as T | null
|
|
38
41
|
}
|
|
39
42
|
if (/FROM directory/i.test(query)) {
|
|
40
|
-
|
|
43
|
+
const rel = bound[1] as string
|
|
44
|
+
if (dirPaths.includes(rel)) return { relPath: rel, isDir: 1 } as T
|
|
45
|
+
return (paths.includes(rel) ? { relPath: rel, isDir: 0 } : null) as T | null
|
|
41
46
|
}
|
|
42
47
|
return null as T | null
|
|
43
48
|
},
|
|
@@ -100,6 +105,37 @@ describe('processIndexedDownload', () => {
|
|
|
100
105
|
expect(touched).toBe(false)
|
|
101
106
|
})
|
|
102
107
|
|
|
108
|
+
// Folders are in the index now, so a client can name one here. A folder has
|
|
109
|
+
// no bytes and must never mint a signed link. result=is-directory separates
|
|
110
|
+
// it from result=not-granted and result=absent, the only readings before.
|
|
111
|
+
it('refuses a directory rather than signing a link for it', async () => {
|
|
112
|
+
let touched = false
|
|
113
|
+
const spy = async () => {
|
|
114
|
+
touched = true
|
|
115
|
+
return { ok: true, status: 200 }
|
|
116
|
+
}
|
|
117
|
+
const lines: string[] = []
|
|
118
|
+
const r = await processIndexedDownload(
|
|
119
|
+
'sess-a',
|
|
120
|
+
'quotes/CR2969',
|
|
121
|
+
makeEnv({ paths: [PATH], dirPaths: ['quotes/CR2969'] }),
|
|
122
|
+
(l) => lines.push(l),
|
|
123
|
+
1000,
|
|
124
|
+
spy as never,
|
|
125
|
+
)
|
|
126
|
+
expect(r.status).toBe(404)
|
|
127
|
+
expect(r.url).toBeUndefined()
|
|
128
|
+
expect(touched).toBe(false)
|
|
129
|
+
expect(lines.some((l) => l.includes('result=is-directory'))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('reads isDir in the directory select', async () => {
|
|
133
|
+
const queries: string[] = []
|
|
134
|
+
const env = makeEnv({ onQuery: (q) => queries.push(q) })
|
|
135
|
+
await processIndexedDownload('sess-a', PATH, env, () => {}, 1000, okHead as never)
|
|
136
|
+
expect(queries.some((q) => /FROM directory/i.test(q) && /isDir/.test(q))).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
|
|
103
139
|
it('401s without a session', async () => {
|
|
104
140
|
const r = await processIndexedDownload('nope', PATH, makeEnv(), () => {}, 1000, okHead as never)
|
|
105
141
|
expect(r.status).toBe(401)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { processDownload } from '../../skills/data-portal/template/functions/api/download'
|
|
3
|
+
import type { PortalEnv } from '../../skills/data-portal/template/functions/api/_lib/types'
|
|
4
|
+
|
|
5
|
+
const ACC = '098a18a3-1741-447f-9778-65470645d57d'
|
|
6
|
+
|
|
7
|
+
function harness(row: { devicePath: string } | null, opts: { configured?: boolean } = {}) {
|
|
8
|
+
const configured = opts.configured ?? true
|
|
9
|
+
const lines: string[] = []
|
|
10
|
+
const env = {
|
|
11
|
+
BUCKET: {
|
|
12
|
+
async get(key: string) {
|
|
13
|
+
// Only an in-transit object exists. A moved row's bytes are gone from
|
|
14
|
+
// R2 entirely, which is what makes reading them here the wrong move.
|
|
15
|
+
return row && !row.devicePath ? { body: null, size: 4, key } : null
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
DB: {
|
|
19
|
+
prepare(sql: string) {
|
|
20
|
+
return {
|
|
21
|
+
bind() {
|
|
22
|
+
return {
|
|
23
|
+
async first() {
|
|
24
|
+
if (sql.includes('FROM sessions')) {
|
|
25
|
+
return { ownerId: 'alice', accountId: ACC, folders: '' }
|
|
26
|
+
}
|
|
27
|
+
if (sql.includes('devicePath')) return row
|
|
28
|
+
return null
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
PORTAL_FETCH_SECRET: configured ? 'test-secret-value' : undefined,
|
|
36
|
+
PORTAL_INSTALL_ORIGIN: configured ? 'https://install.example' : undefined,
|
|
37
|
+
} as unknown as PortalEnv
|
|
38
|
+
return { env, lines, log: (l: string) => lines.push(l) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('downloading an upload that has moved to the device', () => {
|
|
42
|
+
it('streams R2 bytes while the row is still in transit', async () => {
|
|
43
|
+
const h = harness({ devicePath: '' })
|
|
44
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
45
|
+
expect(res.status).toBe(200)
|
|
46
|
+
expect(res.body).not.toBeNull()
|
|
47
|
+
expect(res.url).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('mints an own-upload link once the row has moved', async () => {
|
|
51
|
+
// The object is gone from R2. Without this branch the client's own upload
|
|
52
|
+
// would 404 the moment the device collected it.
|
|
53
|
+
const h = harness({ devicePath: 'uploads/alice/a.pdf' })
|
|
54
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
55
|
+
expect(res.status).toBe(200)
|
|
56
|
+
expect(res.url).toContain('/api/portal/fetch')
|
|
57
|
+
expect(new URL(res.url!).searchParams.get('owner')).toBe('alice')
|
|
58
|
+
expect(new URL(res.url!).searchParams.get('path')).toBe('uploads/alice/a.pdf')
|
|
59
|
+
expect(h.lines.some((l) => l.includes('op=download') && l.includes('result=moved'))).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('refuses another person key before anything is minted', async () => {
|
|
63
|
+
const h = harness({ devicePath: 'uploads/bob/a.pdf' })
|
|
64
|
+
const res = await processDownload('s', 'bob/a.pdf', h.env, h.log, 1000)
|
|
65
|
+
expect(res.status).toBe(403)
|
|
66
|
+
expect(res.url).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports device-offline rather than a server error when unconfigured', async () => {
|
|
70
|
+
// Same reasoning as the indexed path: from the client's side the file
|
|
71
|
+
// genuinely is unreachable, and the operator's signal is the log line.
|
|
72
|
+
const h = harness({ devicePath: 'uploads/alice/a.pdf' }, { configured: false })
|
|
73
|
+
const res = await processDownload('s', 'alice/a.pdf', h.env, h.log, 1000)
|
|
74
|
+
expect(res.status).toBe(503)
|
|
75
|
+
expect(h.lines.some((l) => l.includes('result=unconfigured'))).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reports absent for a key with no manifest row at all', async () => {
|
|
79
|
+
const h = harness(null)
|
|
80
|
+
const res = await processDownload('s', 'alice/gone.pdf', h.env, h.log, 1000)
|
|
81
|
+
expect(res.status).toBe(404)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
// The schema is applied by the skill, not by any code this suite can call, so
|
|
6
|
+
// its shape is asserted textually. A missing column here is not a test failure
|
|
7
|
+
// in some other file — it is an upload naming a column D1 does not have.
|
|
8
|
+
const SQL = readFileSync(
|
|
9
|
+
join(__dirname, '../../skills/data-portal/schema.sql'),
|
|
10
|
+
'utf8',
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
describe('portal schema shape', () => {
|
|
14
|
+
it('carries the manifest lifecycle columns', () => {
|
|
15
|
+
expect(SQL).toMatch(/targetPath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
16
|
+
expect(SQL).toMatch(/devicePath\s+TEXT\s+NOT NULL\s+DEFAULT\s+''/)
|
|
17
|
+
expect(SQL).toMatch(/deleted\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('carries the directory generation and its pointer table', () => {
|
|
21
|
+
expect(SQL).toMatch(/generation\s+INTEGER\s+NOT NULL\s+DEFAULT\s+0/)
|
|
22
|
+
expect(SQL).toMatch(/CREATE TABLE IF NOT EXISTS directory_state/)
|
|
23
|
+
expect(SQL).toMatch(/currentGeneration\s+INTEGER\s+NOT NULL/)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('keeps objectKey UNIQUE, which the upsert conflict target still needs', () => {
|
|
27
|
+
// objectKey is now ownerId/targetPath/filename. The upsert still conflicts
|
|
28
|
+
// on it, so this pins that the constraint was not dropped by accident while
|
|
29
|
+
// the key shape changed underneath it.
|
|
30
|
+
expect(SQL).toMatch(/objectKey\s+TEXT\s+UNIQUE\s+NOT NULL/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('scopes the directory uniqueness by generation', () => {
|
|
34
|
+
// Two generations hold the same relPath at once between the write and the
|
|
35
|
+
// flip. UNIQUE (accountId, relPath) would reject the second write and the
|
|
36
|
+
// push could never stage a new generation at all.
|
|
37
|
+
expect(SQL).toMatch(/UNIQUE\s*\(\s*accountId,\s*relPath,\s*generation\s*\)/)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('documents the hand-run ALTER for every added column', () => {
|
|
41
|
+
// IF NOT EXISTS adds no column to an existing table, so a portal deployed
|
|
42
|
+
// before this needs each one by hand. An undocumented column is an operator
|
|
43
|
+
// reading "no such column" with nothing telling them what to run.
|
|
44
|
+
for (const col of ['targetPath', 'devicePath', 'deleted', 'generation']) {
|
|
45
|
+
expect(SQL).toMatch(new RegExp(`ALTER TABLE \\w+ ADD COLUMN ${col}\\b`))
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
signPath,
|
|
5
5
|
verifyPath,
|
|
6
6
|
buildSignedUrl,
|
|
7
|
+
signOwnUpload,
|
|
8
|
+
verifyOwnUpload,
|
|
9
|
+
buildOwnUploadUrl,
|
|
7
10
|
} from '../../skills/data-portal/template/functions/api/_lib/portal-sign.mjs'
|
|
8
11
|
|
|
9
12
|
const SECRET = 'test-secret-value'
|
|
@@ -66,3 +69,62 @@ describe('portal-sign', () => {
|
|
|
66
69
|
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
67
70
|
})
|
|
68
71
|
})
|
|
72
|
+
|
|
73
|
+
// The second link type (Task 1910). An untargeted upload moves to
|
|
74
|
+
// `uploads/<ownerId>/`, which the exposed set never contains, so the ordinary
|
|
75
|
+
// signature cannot reach it and the device has no way to know whose folder is
|
|
76
|
+
// being asked for. Binding the owner INSIDE the HMAC is what makes that safe.
|
|
77
|
+
describe('own-upload signatures', () => {
|
|
78
|
+
const OWNER = 'alice'
|
|
79
|
+
const PATH = 'uploads/alice/invoice.pdf'
|
|
80
|
+
|
|
81
|
+
it('verifies a link minted for the same owner', async () => {
|
|
82
|
+
const exp = NOW + 1000
|
|
83
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
84
|
+
expect(await verifyOwnUpload(SECRET, ACC, OWNER, PATH, exp, sig, NOW)).toBe(true)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('refuses the same path claimed under a different owner', async () => {
|
|
88
|
+
// The whole point: bob must not be able to present alice's link, and a
|
|
89
|
+
// portal must not be able to mint one into her folder.
|
|
90
|
+
const exp = NOW + 1000
|
|
91
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
92
|
+
expect(await verifyOwnUpload(SECRET, ACC, 'bob', PATH, exp, sig, NOW)).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('is a DIFFERENT message from the exposed-folder signature', async () => {
|
|
96
|
+
// If the two shapes collided, an ordinary signed link would admit an
|
|
97
|
+
// uploads path and the owner binding would be decorative.
|
|
98
|
+
const exp = NOW + 1000
|
|
99
|
+
const own = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
100
|
+
const plain = await signPath(SECRET, ACC, PATH, exp)
|
|
101
|
+
expect(own).not.toBe(plain)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not verify under the ordinary scheme', async () => {
|
|
105
|
+
const exp = NOW + 1000
|
|
106
|
+
const own = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
107
|
+
expect(await verifyPath(SECRET, ACC, PATH, exp, own, NOW)).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('refuses an expired link', async () => {
|
|
111
|
+
const exp = NOW - 1
|
|
112
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
113
|
+
expect(await verifyOwnUpload(SECRET, ACC, OWNER, PATH, exp, sig, NOW)).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses a wrong secret', async () => {
|
|
117
|
+
const exp = NOW + 1000
|
|
118
|
+
const sig = await signOwnUpload(SECRET, ACC, OWNER, PATH, exp)
|
|
119
|
+
expect(await verifyOwnUpload('other-secret', ACC, OWNER, PATH, exp, sig, NOW)).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('carries the owner as a query parameter so the device can name it', async () => {
|
|
123
|
+
const raw = await buildOwnUploadUrl('https://install.example', SECRET, ACC, OWNER, PATH, NOW + 1000)
|
|
124
|
+
const url = new URL(raw)
|
|
125
|
+
expect(url.pathname).toBe('/api/portal/fetch')
|
|
126
|
+
expect(url.searchParams.get('owner')).toBe(OWNER)
|
|
127
|
+
expect(url.searchParams.get('path')).toBe(PATH)
|
|
128
|
+
expect(url.searchParams.get('sig')).toMatch(/^[0-9a-f]{64}$/)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
checkTarget,
|
|
4
|
+
isSafeTargetSegment,
|
|
5
|
+
uploadKey,
|
|
6
|
+
} from '../../skills/data-portal/template/functions/api/_lib/target'
|
|
7
|
+
|
|
8
|
+
/** A D1 fake whose `first()` answers from a set of published folder rows. It
|
|
9
|
+
* models the real query's constraint rather than its happy path: a row is a
|
|
10
|
+
* folder only when isDir = 1, so a fake that ignored the filter would let a
|
|
11
|
+
* file path pass as an upload target. */
|
|
12
|
+
function fakeDb(folders: string[]) {
|
|
13
|
+
return {
|
|
14
|
+
prepare(sql: string) {
|
|
15
|
+
return {
|
|
16
|
+
bind(_accountId: string, relPath: string) {
|
|
17
|
+
return {
|
|
18
|
+
async first() {
|
|
19
|
+
if (!sql.includes('isDir = 1')) throw new Error('query must filter isDir')
|
|
20
|
+
return folders.includes(relPath) ? { relPath } : null
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
} as never
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Any query at all is a failure for the cases that must refuse before the
|
|
30
|
+
* index is read. Refusing early is not an optimisation here: it is what keeps
|
|
31
|
+
* an out-of-grant probe from costing a database round trip. */
|
|
32
|
+
const noQueryDb = {
|
|
33
|
+
prepare() {
|
|
34
|
+
throw new Error('must not query')
|
|
35
|
+
},
|
|
36
|
+
} as never
|
|
37
|
+
|
|
38
|
+
describe('isSafeTargetSegment', () => {
|
|
39
|
+
it('rejects traversal, separators, empties and NUL', () => {
|
|
40
|
+
for (const bad of ['', '.', '..', 'a/b', 'a\\b', 'a\0b']) {
|
|
41
|
+
expect(isSafeTargetSegment(bad)).toBe(false)
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('accepts an ordinary folder name', () => {
|
|
46
|
+
expect(isSafeTargetSegment('jobs')).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('uploadKey', () => {
|
|
51
|
+
it('collapses to owner/filename when untargeted', () => {
|
|
52
|
+
expect(uploadKey('alice', '', 'a.pdf')).toBe('alice/a.pdf')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('carries the target so two folders never collide', () => {
|
|
56
|
+
expect(uploadKey('alice', 'jobs', 'a.pdf')).toBe('alice/jobs/a.pdf')
|
|
57
|
+
expect(uploadKey('alice', 'jobs/2026', 'a.pdf')).toBe('alice/jobs/2026/a.pdf')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('checkTarget', () => {
|
|
62
|
+
it('admits the empty target without touching the index', async () => {
|
|
63
|
+
expect(await checkTarget(noQueryDb, 'acct', [], '')).toEqual({ ok: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('admits a granted folder that the index publishes', async () => {
|
|
67
|
+
expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'jobs')).toEqual({ ok: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('admits a nested path under a granted top-level folder', async () => {
|
|
71
|
+
expect(await checkTarget(fakeDb(['jobs/2026']), 'acct', ['jobs'], 'jobs/2026')).toEqual({
|
|
72
|
+
ok: true,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('refuses a folder outside the grant before reading the index', async () => {
|
|
77
|
+
expect(await checkTarget(noQueryDb, 'acct', ['output'], 'jobs')).toEqual({
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: 'grant',
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('refuses a folder the index does not publish', async () => {
|
|
84
|
+
expect(await checkTarget(fakeDb(['jobs']), 'acct', [], 'output')).toEqual({
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: 'index',
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('refuses a traversal segment before any lookup', async () => {
|
|
91
|
+
expect(await checkTarget(noQueryDb, 'acct', [], 'jobs/../secrets')).toEqual({
|
|
92
|
+
ok: false,
|
|
93
|
+
reason: 'segment',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('refuses a trailing separator, which is an empty segment', async () => {
|
|
98
|
+
expect(await checkTarget(noQueryDb, 'acct', [], 'jobs/')).toEqual({
|
|
99
|
+
ok: false,
|
|
100
|
+
reason: 'segment',
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|