mulmoterminal 4.14.0 → 4.16.0
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/README.md +44 -1
- package/common/dirConfigSource.ts +3 -0
- package/common/sharedAppAccess.ts +571 -0
- package/common/sharedAppPreview.ts +5 -3
- package/common/sharedAppPublicFace.ts +30 -0
- package/dist/assets/{AllPackages-CF2GkZ5g-Bx8CoPCM.js → AllPackages-CF2GkZ5g-BUkwHhnA.js} +1 -1
- package/dist/assets/{BaseConfiguration-CAwZOAki-D9gdRJqD.js → BaseConfiguration-CAwZOAki-sAOejtXv.js} +1 -1
- package/dist/assets/{Element-BMI7Nj5w-KlkWc31P.js → Element-BMI7Nj5w-CtzS4V48.js} +1 -1
- package/dist/assets/{Entities-C8p_0jCn-onU5WJ7w.js → Entities-C8p_0jCn-Dw13tyTR.js} +1 -1
- package/dist/assets/{FunctionList-YWk54TCy-DyMwrJz0.js → FunctionList-YWk54TCy-CsA_edRr.js} +1 -1
- package/dist/assets/{InputJax-C5dj-ntL-CVLHxRml.js → InputJax-C5dj-ntL-DBWzFhVU.js} +1 -1
- package/dist/assets/{MathItem-YJvQL-XJ-BvjVwJXF.js → MathItem-YJvQL-XJ-DNYqBS01.js} +1 -1
- package/dist/assets/{MmlFactory-DuXYIgx8-Cm5_8EoF.js → MmlFactory-DuXYIgx8-CWLIjjvy.js} +1 -1
- package/dist/assets/{Options-oARQeeuT-ChGWvCt4.js → Options-oARQeeuT-CU8UddjX.js} +1 -1
- package/dist/assets/{OutputJax-DfUZNNCu-DaG5nzzg.js → OutputJax-DfUZNNCu-sgxDDWV-.js} +1 -1
- package/dist/assets/{PrioritizedList-6lun1Xqd-C5Db-F3s.js → PrioritizedList-6lun1Xqd-ykocRGXe.js} +1 -1
- package/dist/assets/{Styles-BPXeoqzi-Cr0wNGcQ.js → Styles-BPXeoqzi-DSzGxEMs.js} +1 -1
- package/dist/assets/{TeXAtom-LHT3KFWH-CnXfZ5YO.js → TeXAtom-LHT3KFWH-CNjc8vYz.js} +1 -1
- package/dist/assets/{abnfDiagram-VCTEODGH-DoYNjdBB-DuoOQ-OB.js → abnfDiagram-VCTEODGH-DoYNjdBB-CBWKmDCi.js} +1 -1
- package/dist/assets/{arc-CqByPKkF-Dn1DCplU.js → arc-CqByPKkF-88IlSEMD.js} +1 -1
- package/dist/assets/architecture-7GRP2DOG-CRS69XmP-CfEYhnsg.js +1 -0
- package/dist/assets/{architectureDiagram-5GKGNRK7-BhcT6qS5-D7hRerxV.js → architectureDiagram-5GKGNRK7-BhcT6qS5-BLtGEqlp.js} +1 -1
- package/dist/assets/{assistive-mml-DXJXho4o-DSJCOJrU.js → assistive-mml-DXJXho4o-DzIDEOlP.js} +1 -1
- package/dist/assets/{blockDiagram-I7D4REHJ-1Cng0Q4K-CE_lDQNR.js → blockDiagram-I7D4REHJ-1Cng0Q4K-DcIDSMg7.js} +1 -1
- package/dist/assets/{c4Diagram-7LVT6UL2-CAb9c8nc-XwP1jrs8.js → c4Diagram-7LVT6UL2-CAb9c8nc-C7a_Eswh.js} +1 -1
- package/dist/assets/channel-BjESmYnj-m72KoOEU.js +1 -0
- package/dist/assets/{chunk-2E4U76K2-CMyNbRyJ-O4iHMJzN.js → chunk-2E4U76K2-CMyNbRyJ-uuFlgwng.js} +1 -1
- package/dist/assets/{chunk-4HAMMTFA-ChwfIAnF-IqkmczzX.js → chunk-4HAMMTFA-ChwfIAnF-BV3UqcZo.js} +1 -1
- package/dist/assets/{chunk-75Z2AOVW-CbPA0Zt_-BC4_pS5x.js → chunk-75Z2AOVW-CbPA0Zt_-DP7076EE.js} +1 -1
- package/dist/assets/{chunk-CLGD4ZFX-CVw8XHcp-BD9KH9HS.js → chunk-CLGD4ZFX-CVw8XHcp-CLPfrpnT.js} +1 -1
- package/dist/assets/{chunk-DU6HZSFF-DuOx2LFX-BfylIFCt.js → chunk-DU6HZSFF-DuOx2LFX-B9i9yp2H.js} +1 -1
- package/dist/assets/{chunk-F27PBJKO-CgBMw9yN-i2GWxi_8.js → chunk-F27PBJKO-CgBMw9yN-DheADmYc.js} +1 -1
- package/dist/assets/{chunk-GMAD6QVW-JTqlimUX-DPF75NJ0.js → chunk-GMAD6QVW-JTqlimUX-Bv32TXUx.js} +1 -1
- package/dist/assets/{chunk-GVQU2GXP-D6ccB4DO-CdSlCYYP.js → chunk-GVQU2GXP-D6ccB4DO-94yHs3IY.js} +1 -1
- package/dist/assets/{chunk-IMKFNOWR-ij1DSzMb-bJpIateE.js → chunk-IMKFNOWR-ij1DSzMb-BqsuqPds.js} +1 -1
- package/dist/assets/{chunk-L3NEJ4N5-CUoAQVG2-Ckcm2qf9.js → chunk-L3NEJ4N5-CUoAQVG2-DxJSvGS_.js} +1 -1
- package/dist/assets/{chunk-OSK3NFVY-_VNbXQSu-CBZ1Oi9V.js → chunk-OSK3NFVY-_VNbXQSu-Ccnx0KHf.js} +1 -1
- package/dist/assets/{chunk-P2QGCYS3-B8lzOn3Y-C_Ar_ICY.js → chunk-P2QGCYS3-B8lzOn3Y-CaFtW38P.js} +1 -1
- package/dist/assets/{chunk-POPQ4Y6H-Bh_uOTmp-CZ0WDoDp.js → chunk-POPQ4Y6H-Bh_uOTmp-D2RHBZ5s.js} +1 -1
- package/dist/assets/{chunk-PWAF6VOD-DzjnfYSw-CbrKSYcQ.js → chunk-PWAF6VOD-DzjnfYSw-BJFV14F1.js} +1 -1
- package/dist/assets/{chunk-SHT3W25Y-B8dQLS5w-Be-9moIP.js → chunk-SHT3W25Y-B8dQLS5w-Df12R7Gh.js} +1 -1
- package/dist/assets/{chunk-SVP7TREG-B6A-T8Th-D-6Fw0Gs.js → chunk-SVP7TREG-B6A-T8Th-D6_Ichh1.js} +1 -1
- package/dist/assets/{chunk-TICWLB2K-C1kwD29l-Bn8XWgqO.js → chunk-TICWLB2K-C1kwD29l-DbGHkRkh.js} +1 -1
- package/dist/assets/{chunk-TLUHSLCS-DYzGlCmi-DuHf7zHR.js → chunk-TLUHSLCS-DYzGlCmi-CJdhEIVL.js} +2 -2
- package/dist/assets/{chunk-XXDRQBXY-vg0kfC73-C8I5TxHr.js → chunk-XXDRQBXY-vg0kfC73-aHwnHV68.js} +1 -1
- package/dist/assets/classDiagram-ZZMXUADV-C7gP-pUH-DLDPmUSl.js +1 -0
- package/dist/assets/classDiagram-v2-VYDZK3BY-CUwV5k1F-DLDPmUSl.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-NuHCtE3P-DbBSvoTS.js → cose-bilkent-JH36ORCC-NuHCtE3P-CArrWVtM.js} +1 -1
- package/dist/assets/cynefin-OW5HDTMX-C4iKivN4-BQEcGeGg.js +1 -0
- package/dist/assets/{cynefinDiagram-5FMLGOSQ-CA7sro34-g7Ohjffk.js → cynefinDiagram-5FMLGOSQ-CA7sro34-BzZQpKl2.js} +1 -1
- package/dist/assets/{dagre-GXQ25YYZ-DOztjUR_-B5EqH5BS.js → dagre-GXQ25YYZ-DOztjUR_-DOx-551u.js} +1 -1
- package/dist/assets/{diagram-S7CK7UJ4-D_nsLfRT-BvfKDOXc.js → diagram-S7CK7UJ4-D_nsLfRT-DWr9WRW6.js} +1 -1
- package/dist/assets/{diagram-UQ7AKVKN-DNN-YX9a-DKCPIpWb.js → diagram-UQ7AKVKN-DNN-YX9a-C9TXc-ah.js} +1 -1
- package/dist/assets/{diagram-VSXAHHWV--HENzo-l-CDEKcryA.js → diagram-VSXAHHWV--HENzo-l-DmNtXjHQ.js} +1 -1
- package/dist/assets/{diagram-VX7I27RA-m2m9dROj-Cg2nqYjf.js → diagram-VX7I27RA-m2m9dROj-Cq6wReC4.js} +1 -1
- package/dist/assets/{diagram-Z3DM3KII-D4qpv4-B-cB7PWSYG.js → diagram-Z3DM3KII-D4qpv4-B-DtXyJB7G.js} +1 -1
- package/dist/assets/{dist-7-TNBgyu.js → dist-BQPlJ9lU.js} +1 -1
- package/dist/assets/{dist-9C_qvTjj.js → dist-DOVOmrou.js} +1 -1
- package/dist/assets/{dist-DeiyD9Ki-CXfNTGOJ.js → dist-DeiyD9Ki-C1_gcVU2.js} +1 -1
- package/dist/assets/{dist-4YyNMfLL.js → dist-DtF92Clz.js} +1 -1
- package/dist/assets/{dist-CdTewfjF.js → dist-TlhJ1D4x.js} +1 -1
- package/dist/assets/{ebnfDiagram-PWID7BFC-CAbv3sZg-yedKuMgW.js → ebnfDiagram-PWID7BFC-CAbv3sZg-BbjG8-uo.js} +1 -1
- package/dist/assets/{erDiagram-RLTQ6QDP-a10Ryh0--DoRMdP9Q.js → erDiagram-RLTQ6QDP-a10Ryh0--C72i1ywY.js} +1 -1
- package/dist/assets/eventmodeling-NTZA5JFV-DhqtNS8R-BK1N0_ZM.js +1 -0
- package/dist/assets/flowDiagram-HODETNUW-CAW_8mwY-B3-stLUn.js +1 -0
- package/dist/assets/{ganttDiagram-EL5Y4UJY-Cb7leeOC-Dja2ctXX.js → ganttDiagram-EL5Y4UJY-Cb7leeOC-DYFiF709.js} +1 -1
- package/dist/assets/gitGraph-4MIJSDKK-S_Zs6Nxs-BRRPIfYV.js +1 -0
- package/dist/assets/{gitGraphDiagram-WWUBYQGX-BCSbUpXG-atF7efoT.js → gitGraphDiagram-WWUBYQGX-BCSbUpXG-DejyXZfK.js} +1 -1
- package/dist/assets/{html-DdQiKDqM-BoalOcqT.js → html-DdQiKDqM-CPke18TU.js} +1 -1
- package/dist/assets/index-C7OnGcwW.css +1 -0
- package/dist/assets/index-Z2OTrqqI.js +1051 -0
- package/dist/assets/info-A6RAGUB7-4sGJh1is-fp_xBD4Z.js +1 -0
- package/dist/assets/{infoDiagram-27XIBGKW-BJRaZItN-BEIeq3GV.js → infoDiagram-27XIBGKW-BJRaZItN-DnsdjhkP.js} +1 -1
- package/dist/assets/{ishikawaDiagram-5VMMS53U-DHlFe__S-DQ_AsNwg.js → ishikawaDiagram-5VMMS53U-DHlFe__S-CmJe5Tlt.js} +1 -1
- package/dist/assets/{journeyDiagram-3NMN7TZE-BOIo3JM--CnnYTo0O.js → journeyDiagram-3NMN7TZE-BOIo3JM--Y8oQ4y77.js} +1 -1
- package/dist/assets/{kanban-definition-UXKFOSKX-yrQdG683-x-RC3tkv.js → kanban-definition-UXKFOSKX-yrQdG683-CFIQzQvF.js} +1 -1
- package/dist/assets/{lengths-CvmPfBid-10gEIzPK.js → lengths-CvmPfBid-BQ2m-oBM.js} +1 -1
- package/dist/assets/{line-DUb1TjJS-DShOBLG6.js → line-DUb1TjJS-C1or5HkU.js} +1 -1
- package/dist/assets/{linear-xsuGWu5q-D-IsIc-E.js → linear-xsuGWu5q-DO1NVk1F.js} +1 -1
- package/dist/assets/{liteAdaptor-C0FQiOEx-CKOo0lvo.js → liteAdaptor-C0FQiOEx-rHhNCala.js} +1 -1
- package/dist/assets/{marp-ngiOtnIU.js → marp-246Tptqb.js} +1 -1
- package/dist/assets/{mathjax-DVEo4UYj-DPhwOynN.js → mathjax-DVEo4UYj-BKhDfB9b.js} +1 -1
- package/dist/assets/{mermaid-parser.core-fkjeiYO7-BwcekFOJ.js → mermaid-parser.core-fkjeiYO7-Du7nNQKj.js} +2 -2
- package/dist/assets/{mermaid.core-CZDx2Cyn-CeJk12OP.js → mermaid.core-CZDx2Cyn-B8TG9ViQ.js} +3 -3
- package/dist/assets/{mindmap-definition-YA3MSWOX-BpKAVyqs-CSsgL9Ev.js → mindmap-definition-YA3MSWOX-BpKAVyqs-BjKrJvG3.js} +1 -1
- package/dist/assets/{mo-CrlNnGXX-C7iwFKb3.js → mo-CrlNnGXX-DG8Vectz.js} +1 -1
- package/dist/assets/packet-AYTQ26CC-MQQyx8fI-jr1o6-sN.js +1 -0
- package/dist/assets/{pegDiagram-XKGWAZYB-C7eG_Ohl-DnQeEqS7.js → pegDiagram-XKGWAZYB-C7eG_Ohl-BhWdbn0k.js} +1 -1
- package/dist/assets/pie-WAS4IAKB-CyaL5i_d-Bu147U5x.js +1 -0
- package/dist/assets/{pieDiagram-E7YTZNPT-5f9wSfDR-DemBN8Yf.js → pieDiagram-E7YTZNPT-5f9wSfDR-Lecay4e9.js} +1 -1
- package/dist/assets/{quadrantDiagram-AXDQQJYC-mWeaOj5S-DJ33JkPA.js → quadrantDiagram-AXDQQJYC-mWeaOj5S-DS7mHc4o.js} +1 -1
- package/dist/assets/radar-RG4KPBEZ-DR-SsHDJ-CN5skCMU.js +1 -0
- package/dist/assets/railroad-74A4TZTK-C2gqsOGX-aDdh8kqg.js +1 -0
- package/dist/assets/railroad-abnf-HS5TGJTU-Dpzw6P5u-RpiXuxqp.js +1 -0
- package/dist/assets/railroad-ebnf-LZEXJU2U-J_BuIrPo-C0a1HZjw.js +1 -0
- package/dist/assets/railroad-peg-WCYAUIDC-DzMStOR--DYIIa1Hw.js +1 -0
- package/dist/assets/{railroadDiagram-O6MQD6OU-DJskhE7u-CwIUMhSD.js → railroadDiagram-O6MQD6OU-DJskhE7u-Dmh0-Us2.js} +1 -1
- package/dist/assets/{requirementDiagram-BXWQKSXE-B5E0bU4h-hoSDzLQJ.js → requirementDiagram-BXWQKSXE-B5E0bU4h-CyS_2sxk.js} +1 -1
- package/dist/assets/{sankeyDiagram-P5KCCOFB-DS3AOTai-BZeF2KIM.js → sankeyDiagram-P5KCCOFB-DS3AOTai-CSuBkdUO.js} +1 -1
- package/dist/assets/{sequenceDiagram-WJ2MYXX4-Bn2L9osv-Bs7XPvuf.js → sequenceDiagram-WJ2MYXX4-Bn2L9osv-CHxEbZ0W.js} +1 -1
- package/dist/assets/{src-CLC0ddSd-DsNPBPD5.js → src-CLC0ddSd-CjxPB3S7.js} +1 -1
- package/dist/assets/{stateDiagram-D77RDMKH-DbtoW7HL-n_W7QA2i.js → stateDiagram-D77RDMKH-DbtoW7HL-yabfmTgk.js} +1 -1
- package/dist/assets/stateDiagram-v2-MP3YSRHH-CX6RalKY-GzlXJtMT.js +1 -0
- package/dist/assets/{svg-CaHU_sxA-eLTj-UFn.js → svg-CaHU_sxA-D8CRwYZQ.js} +1 -1
- package/dist/assets/{swimlanes-42K2YHIH-90h8w_3B-PrSiGyln.js → swimlanes-42K2YHIH-90h8w_3B-lAgM4Z9o.js} +1 -1
- package/dist/assets/swimlanesDiagram-VR7AAH4N-49XH_TMr-4AicWyJt.js +8 -0
- package/dist/assets/{tex-B1cONMvb-CwimKS8s.js → tex-B1cONMvb-DydoaQyY.js} +1 -1
- package/dist/assets/{timeline-definition-24CTP7MA-Bx3KcICO-BeSkr_pz.js → timeline-definition-24CTP7MA-Bx3KcICO-BH12S7gs.js} +1 -1
- package/dist/assets/treeView-Q6P3EWNA-gFs854Sn-aY6owIcz.js +1 -0
- package/dist/assets/treemap-WGGIJYW6-DGh_szUA-YLwoYd-l.js +1 -0
- package/dist/assets/{vennDiagram-4TSXK5OY-DSexNe_H-Cif5NoRB.js → vennDiagram-4TSXK5OY-DSexNe_H-B0-oluSn.js} +1 -1
- package/dist/assets/wardley-WFR3VGLG-BVLYL3gB-Bmrp_tbe.js +1 -0
- package/dist/assets/{wardleyDiagram-VM6X3IG4-DesYYyZW-L-RyhMHN.js → wardleyDiagram-VM6X3IG4-DesYYyZW-CIvLV4o_.js} +1 -1
- package/dist/assets/{xychartDiagram-S5SC5T6Z-dIt6pHWV-Ct1J2Ggu.js → xychartDiagram-S5SC5T6Z-dIt6pHWV-C1D4f_2b.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +7 -7
- package/server/backends/collections.ts +10 -11
- package/server/backends/deckList.ts +142 -0
- package/server/backends/mulmoscript.ts +120 -1
- package/server/backends/sharedApp/access.ts +47 -0
- package/server/backends/sharedApp/context.ts +1 -1
- package/server/backends/sharedApp/preview.ts +2 -1
- package/server/backends/sharedApp/publish.ts +8 -5
- package/server/backends/sharedAppPreviewRoutes.ts +56 -18
- package/server/backends/stagedSkills.ts +71 -0
- package/server/backends/storiesRoot.ts +29 -0
- package/server/backends/storiesRootSet.ts +49 -0
- package/server/backends/workspaceSetup.ts +5 -12
- package/server/config/config-routes.ts +8 -1
- package/server/config/config-schema.ts +18 -0
- package/server/config/dir-config.ts +6 -1
- package/server/config/worktree-dir-config.ts +3 -1
- package/server/index.ts +5 -1
- package/server/infra/canonical-path.ts +15 -0
- package/server/infra/collection-tool.ts +12 -10
- package/server/infra/project-root.ts +15 -0
- package/server/infra/shared-app-tool.ts +34 -6
- package/server/routes/dir-routes.ts +27 -2
- package/server/skills/mulmoterminal-config/SKILL.md +30 -2
- package/server/skills/mulmoterminal-dirs/SKILL.md +3 -2
- package/server/skills/mulmoterminal-shared-app/SKILL.md +67 -6
- package/server/skills/mulmoterminal-shared-app/templates/append-feed.md +1 -1
- package/dist/assets/architecture-7GRP2DOG-CRS69XmP-CAHsIjH1.js +0 -1
- package/dist/assets/channel-BjESmYnj-CFiXT9oS.js +0 -1
- package/dist/assets/classDiagram-ZZMXUADV-C7gP-pUH-w1B1j6lG.js +0 -1
- package/dist/assets/classDiagram-v2-VYDZK3BY-CUwV5k1F-w1B1j6lG.js +0 -1
- package/dist/assets/cynefin-OW5HDTMX-C4iKivN4-DCP-fZBd.js +0 -1
- package/dist/assets/eventmodeling-NTZA5JFV-DhqtNS8R-BqMQQV9r.js +0 -1
- package/dist/assets/flowDiagram-HODETNUW-CAW_8mwY-BbCF3Xlq.js +0 -1
- package/dist/assets/gitGraph-4MIJSDKK-S_Zs6Nxs-ZWRSFUk-.js +0 -1
- package/dist/assets/index-B-BwWalM.css +0 -1
- package/dist/assets/index-DJqafXyu.js +0 -1051
- package/dist/assets/info-A6RAGUB7-4sGJh1is-B6ciYeff.js +0 -1
- package/dist/assets/packet-AYTQ26CC-MQQyx8fI-C1uVptHd.js +0 -1
- package/dist/assets/pie-WAS4IAKB-CyaL5i_d-CvuBD4G5.js +0 -1
- package/dist/assets/radar-RG4KPBEZ-DR-SsHDJ-CXZSNBN1.js +0 -1
- package/dist/assets/railroad-74A4TZTK-C2gqsOGX-BLUc9I7K.js +0 -1
- package/dist/assets/railroad-abnf-HS5TGJTU-Dpzw6P5u-CDyLVmSE.js +0 -1
- package/dist/assets/railroad-ebnf-LZEXJU2U-J_BuIrPo-BBzTDDwY.js +0 -1
- package/dist/assets/railroad-peg-WCYAUIDC-DzMStOR--Cljx_bWU.js +0 -1
- package/dist/assets/stateDiagram-v2-MP3YSRHH-CX6RalKY-Dq9S-AUp.js +0 -1
- package/dist/assets/swimlanesDiagram-VR7AAH4N-49XH_TMr-y3IgDYmY.js +0 -8
- package/dist/assets/treeView-Q6P3EWNA-gFs854Sn-CnQVHjtu.js +0 -1
- package/dist/assets/treemap-WGGIJYW6-DGh_szUA-DdG0VnPJ.js +0 -1
- package/dist/assets/wardley-WFR3VGLG-BVLYL3gB-uwMZq6Py.js +0 -1
package/README.md
CHANGED
|
@@ -101,7 +101,8 @@ the same two behind **[GraphAI](https://github.com/receptron/graphai)**. [More
|
|
|
101
101
|
command-output explanations — so a wall of parallel agents stays legible.
|
|
102
102
|
- **Make it yours.** Per-directory **themes, colors, and name badges** (`prod` in red,
|
|
103
103
|
`staging` in amber), a configurable header (buttons + info chips), custom attention sounds,
|
|
104
|
-
and Run / Skill menus to launch a project's scripts
|
|
104
|
+
and Run / Skill / Mulmo menus to launch a project's scripts, `.claude/skills` and decks right
|
|
105
|
+
inside a cell.
|
|
105
106
|
|
|
106
107
|

|
|
107
108
|
|
|
@@ -369,6 +370,7 @@ The launcher detects it and prints the exact, OS-appropriate removal command; ru
|
|
|
369
370
|
- [Running](#running)
|
|
370
371
|
- [Scripts (Run menu)](#scripts-run-menu)
|
|
371
372
|
- [Skills (Skill menu)](#skills-skill-menu)
|
|
373
|
+
- [Decks (Mulmo menu)](#decks-mulmo-menu)
|
|
372
374
|
- [Files view (browse & edit)](#files-view-browse--edit)
|
|
373
375
|
- [Git worktrees & pull requests](#git-worktrees--pull-requests)
|
|
374
376
|
- [Cost & token usage](#cost--token-usage)
|
|
@@ -1050,6 +1052,47 @@ button. Skills are discovered read-only; the menu never creates or edits them.
|
|
|
1050
1052
|
|
|
1051
1053
|
---
|
|
1052
1054
|
|
|
1055
|
+
## Decks (Mulmo menu)
|
|
1056
|
+
|
|
1057
|
+
Beside **⚡ Skill ▾** is **⊞ Mulmo ▾** — the decks (mulmoScript presentations) this project offers,
|
|
1058
|
+
one click from the **Canvas**. It appears **only when there are decks to show** (none, no button),
|
|
1059
|
+
like the two menus next to it.
|
|
1060
|
+
|
|
1061
|
+
Picking one shows it in the Canvas beside that cell, enlarging the cell first if it was tiled.
|
|
1062
|
+
Nothing is typed into the session and the agent is not asked: this is a viewer, so it costs no
|
|
1063
|
+
tokens and works while the agent is busy.
|
|
1064
|
+
|
|
1065
|
+
**Two sources, both named — the menu never searches your disk:**
|
|
1066
|
+
|
|
1067
|
+
1. **`artifacts/stories/` under the workspace** — where the plugin keeps the decks an agent makes.
|
|
1068
|
+
Always offered, nothing to configure.
|
|
1069
|
+
2. **Decks you list yourself**, for one kept inside a repository:
|
|
1070
|
+
|
|
1071
|
+
```jsonc
|
|
1072
|
+
// <dir>/.mulmoterminal.json
|
|
1073
|
+
{ "decks": ["decks/launch.json", "docs/talks/retro.json"] }
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
Paths are relative to that file and must stay **inside its directory** — `../elsewhere.json` and
|
|
1077
|
+
absolute paths are dropped, because a config file travels with a clone. Each deck is named by its
|
|
1078
|
+
own `title`, or by its file name when it has none.
|
|
1079
|
+
|
|
1080
|
+
**Why you list them rather than the menu finding them:** a search does find them — along with
|
|
1081
|
+
everything else on disk that happens to parse as a deck. Measured on a real workspace: **250 files
|
|
1082
|
+
matched, 33 were the user's own decks and 217 were a checked-out repository's test fixtures and
|
|
1083
|
+
samples.** A menu is a short list of things you chose.
|
|
1084
|
+
|
|
1085
|
+
**Where the decks have to live:** in any directory MulmoTerminal knows about — the workspace it
|
|
1086
|
+
was started in, plus the directories in your launcher's saved list (up to 64 in total, and one that
|
|
1087
|
+
is no longer on disk is skipped). Those are read **once at startup**, so a repository you open for
|
|
1088
|
+
the first time needs a restart before its decks can be shown. Any deck you can see in the file tree is also reachable there — right-click a row and choose
|
|
1089
|
+
**Open in the Canvas** — with no configuration at all.
|
|
1090
|
+
|
|
1091
|
+
If a deck cannot be opened (it was deleted, or the workspace moved since startup), the cell says
|
|
1092
|
+
why rather than doing nothing.
|
|
1093
|
+
|
|
1094
|
+
---
|
|
1095
|
+
|
|
1053
1096
|
## Files view (browse & edit)
|
|
1054
1097
|
|
|
1055
1098
|
A terminal header can carry a **📁 Files** button — add it as a [header button](#header-buttons)
|
|
@@ -30,6 +30,7 @@ export const DIR_CONFIG_KEYS = [
|
|
|
30
30
|
"buttons",
|
|
31
31
|
"chips",
|
|
32
32
|
"skills",
|
|
33
|
+
"decks",
|
|
33
34
|
"provider",
|
|
34
35
|
"model",
|
|
35
36
|
"addDirs",
|
|
@@ -70,6 +71,7 @@ export interface DirConfigExtras {
|
|
|
70
71
|
provider: string | null;
|
|
71
72
|
model: string | null;
|
|
72
73
|
skills: string[] | null;
|
|
74
|
+
decks: string[] | null;
|
|
73
75
|
addDirs: string[] | null;
|
|
74
76
|
// Tri-state on purpose: `false` is a setting this file made, and the preview has to show it as
|
|
75
77
|
// one. Carried here because a boolean cannot be read back off the per-cell config the way a
|
|
@@ -91,6 +93,7 @@ export const EMPTY_DIR_CONFIG_EXTRAS: DirConfigExtras = {
|
|
|
91
93
|
provider: null,
|
|
92
94
|
model: null,
|
|
93
95
|
skills: null,
|
|
96
|
+
decks: null,
|
|
94
97
|
addDirs: null,
|
|
95
98
|
appendSystemPrompt: null,
|
|
96
99
|
buttonLabels: [],
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// WHO CAN SEE WHAT, per collection of a shared app — read off the two documents the Firestore
|
|
2
|
+
// rules actually read, and nothing else.
|
|
3
|
+
//
|
|
4
|
+
// The question this answers is the author's, and it is asked about strangers: "the person who has
|
|
5
|
+
// not signed in, and the person who signed in but was never invited — what of mine can they
|
|
6
|
+
// reach?" That is not answerable from `app.json` by reading it, because the answer is spread over
|
|
7
|
+
// `public.enabled`, `public.read`, `public.submit[cid].auth`, `participantRead`, `peerVisibility`
|
|
8
|
+
// and the roster, and any two of them can disagree.
|
|
9
|
+
//
|
|
10
|
+
// So this is a TRANSCRIPTION of the rules, not a second opinion about them. Every branch below
|
|
11
|
+
// names the predicate in `../../mulmoserver/firestore.rules` it mirrors, and the inputs are the
|
|
12
|
+
// projected documents (`apps/{aid}` and its `public` block) rather than the authored manifest —
|
|
13
|
+
// the same pair `app(aid)` hands every rule. A summary computed from the manifest would be a
|
|
14
|
+
// third reading of the declaration, and the one that is wrong is always the one nobody deploys.
|
|
15
|
+
//
|
|
16
|
+
// It is a REPORT. Nothing here authorizes anything: the rules do, on the server, and an app whose
|
|
17
|
+
// rules say otherwise is right and this is wrong. That is the point of stating it — a difference
|
|
18
|
+
// between this panel and production is a bug worth finding, and it can only be found if the panel
|
|
19
|
+
// commits to an answer.
|
|
20
|
+
import { byCodeUnit } from "./byCodeUnit.js";
|
|
21
|
+
import { isRecord } from "./isRecord.js";
|
|
22
|
+
import { publicFaceOf, type PublicFace } from "./sharedAppPublicFace.js";
|
|
23
|
+
|
|
24
|
+
/** The four people an author is deciding about. Two of them are the ones this panel exists for.
|
|
25
|
+
*
|
|
26
|
+
* `visitor` is the person on the public page with no Google account. Firebase gives them an
|
|
27
|
+
* ANONYMOUS session there, so `authed()` is true for them and `verified()` is not — which is what
|
|
28
|
+
* makes them different from `stranger` rather than simply weaker, and why a `uidField` binding
|
|
29
|
+
* reaches them while an `emailField` one cannot.
|
|
30
|
+
*
|
|
31
|
+
* `stranger` is any Google account in the world that was never invited. The aid and the cid are
|
|
32
|
+
* both readable from `config/public`, so "they would have to know the ids" is not a barrier and
|
|
33
|
+
* is deliberately not modelled as one.
|
|
34
|
+
*
|
|
35
|
+
* `participant` holds the literal `participant` role on the roster; `writer` holds `owner` or
|
|
36
|
+
* `editor` on this collection. `viewer` and `assignee` are neither — they read like a writer and
|
|
37
|
+
* write like nobody, which the census beside the table reports rather than the table. */
|
|
38
|
+
export type AccessSubject = "visitor" | "stranger" | "participant" | "writer";
|
|
39
|
+
|
|
40
|
+
export const ACCESS_SUBJECTS: readonly AccessSubject[] = ["visitor", "stranger", "participant", "writer"];
|
|
41
|
+
|
|
42
|
+
/** How much of the collection this subject may READ. `own` is `ownRow` — the rows the subject
|
|
43
|
+
* themselves submitted, reached through the declared identity binding and no other. */
|
|
44
|
+
export type ReadAccess = "none" | "own" | "all";
|
|
45
|
+
|
|
46
|
+
export interface SubjectAccess {
|
|
47
|
+
read: ReadAccess;
|
|
48
|
+
/** May bring a NEW record into this collection. */
|
|
49
|
+
create: boolean;
|
|
50
|
+
/** May change or withdraw the records that are theirs (`selfUpdate` / `selfTransitions` /
|
|
51
|
+
* `selfDelete`), which is a different permission from `editAll` and never implies it. */
|
|
52
|
+
editOwn: boolean;
|
|
53
|
+
/** May change or delete ANY record here. */
|
|
54
|
+
editAll: boolean;
|
|
55
|
+
/** `mirrorRepair` — this collection is the PUBLIC PROJECTION of records nobody outside may read
|
|
56
|
+
* (`mirrorOf`), and the rules let ANYONE write its `state` field back to the truth. Not even
|
|
57
|
+
* `authed()`: a stale grid repairing itself is the whole point of the rule.
|
|
58
|
+
*
|
|
59
|
+
* A flag of its own rather than folded into `editAll`, because it is neither nothing nor a
|
|
60
|
+
* general write — one field, to one value, that cannot be a lie. Its own flag is what stops the
|
|
61
|
+
* table saying "Nothing" about a collection every visitor may write to. */
|
|
62
|
+
repairMirror: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How many roster addresses hold each kind of role ON THIS COLLECTION.
|
|
66
|
+
*
|
|
67
|
+
* Beside the table because the table's `participant` and `writer` columns describe a permission
|
|
68
|
+
* that may belong to nobody: an app can declare `audience: "participant"` and have no
|
|
69
|
+
* participants, and the column would then read as an exposure that does not exist. */
|
|
70
|
+
export interface RoleCensus {
|
|
71
|
+
writers: number;
|
|
72
|
+
/** `viewer` and `assignee` — they read everything here and the table does not have a column for
|
|
73
|
+
* them, so they are counted where they cannot be missed. */
|
|
74
|
+
readers: number;
|
|
75
|
+
participants: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CollectionAccess {
|
|
79
|
+
cid: string;
|
|
80
|
+
/** Does this collection take submissions at all — `subOpen` in the rules. A collection with no
|
|
81
|
+
* `public.submit` entry has no self-service path in or out for anybody, whatever the switch
|
|
82
|
+
* says, and that is worth saying plainly rather than leaving as four empty cells. */
|
|
83
|
+
takesSubmissions: boolean;
|
|
84
|
+
/** The auth stage `public.submit[cid].auth` declares, or `none`. */
|
|
85
|
+
authStage: "none" | "anonymous" | "verifiedEmail";
|
|
86
|
+
census: RoleCensus;
|
|
87
|
+
/** Conditions this summary cannot answer, in the author's words — a submission window, a
|
|
88
|
+
* session gate, a staged reveal. Each one can only NARROW what the table says. */
|
|
89
|
+
caveats: string[];
|
|
90
|
+
access: Record<AccessSubject, SubjectAccess>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SharedAppAccess {
|
|
94
|
+
publicFace: PublicFace;
|
|
95
|
+
collections: CollectionAccess[];
|
|
96
|
+
/** Roster addresses the rules will never match, because they are not lower case — see
|
|
97
|
+
* `unmatchable`. App-wide rather than per collection, because the roster is.
|
|
98
|
+
*
|
|
99
|
+
* Reported rather than silently dropped from the census: an author whose `Owner / editor` count
|
|
100
|
+
* reads `(0)` is owed the reason, and this one is invisible in a file that reads correctly to a
|
|
101
|
+
* human. */
|
|
102
|
+
unmatchableRoster: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const asRecord = (value: unknown): Record<string, unknown> | undefined => (isRecord(value) ? value : undefined);
|
|
106
|
+
const asStrings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []);
|
|
107
|
+
const flagOn = (block: Record<string, unknown>, key: string): boolean => block[key] === true;
|
|
108
|
+
|
|
109
|
+
/** The role this address resolves to for this cid — `role(a, cid)` in the rules, including its
|
|
110
|
+
* `'*'` fallback and its `null` for a member scoped elsewhere. */
|
|
111
|
+
function roleFor(roles: Record<string, unknown>, cid: string): string | null {
|
|
112
|
+
const scoped = roles[cid];
|
|
113
|
+
if (typeof scoped === "string") return scoped;
|
|
114
|
+
const fallback = roles["*"];
|
|
115
|
+
return typeof fallback === "string" ? fallback : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Roster keys the rules can never match, because of their case.
|
|
119
|
+
*
|
|
120
|
+
* `email() in a.members` is an exact string comparison and rules have no `lower()`; Firebase puts
|
|
121
|
+
* a lower-cased address in the token. So `Foo@Example.com` on the roster grants that person
|
|
122
|
+
* nothing at all — the same defect `rosterCaseProblems` reports at publish time, and this route
|
|
123
|
+
* deliberately answers even for a declaration a publish would refuse. */
|
|
124
|
+
const unmatchable = (address: string): boolean => address !== address.toLowerCase();
|
|
125
|
+
|
|
126
|
+
function censusOf(members: Record<string, unknown>, cid: string): RoleCensus {
|
|
127
|
+
const census: RoleCensus = { writers: 0, readers: 0, participants: 0 };
|
|
128
|
+
for (const [address, roles] of Object.entries(members)) {
|
|
129
|
+
// NOT COUNTED, rather than counted with a warning beside them: the count is what the row says
|
|
130
|
+
// about who holds this permission, and a key the rules cannot match holds none of it. Counting
|
|
131
|
+
// it printed `Owner / editor (1)` over an app whose owner is locked out of their own roster.
|
|
132
|
+
if (unmatchable(address)) continue;
|
|
133
|
+
if (!isRecord(roles)) continue;
|
|
134
|
+
const role = roleFor(roles, cid);
|
|
135
|
+
if (role === "owner" || role === "editor") census.writers += 1;
|
|
136
|
+
else if (role === "viewer" || role === "assignee") census.readers += 1;
|
|
137
|
+
else if (role === "participant") census.participants += 1;
|
|
138
|
+
}
|
|
139
|
+
return census;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What the subject brings to `request.auth`. `listed` is `listedIn(a)`; `role` is what `role(a,
|
|
143
|
+
* cid)` would answer for them. */
|
|
144
|
+
interface Principal {
|
|
145
|
+
verified: boolean;
|
|
146
|
+
listed: boolean;
|
|
147
|
+
role: "participant" | "writer" | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const PRINCIPALS: Record<AccessSubject, Principal> = {
|
|
151
|
+
// Anonymous session: authed, never verified. See the note on `AccessSubject`.
|
|
152
|
+
visitor: { verified: false, listed: false, role: null },
|
|
153
|
+
stranger: { verified: true, listed: false, role: null },
|
|
154
|
+
participant: { verified: true, listed: true, role: "participant" },
|
|
155
|
+
writer: { verified: true, listed: true, role: "writer" },
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The two principals `caveatsOf` asks about — the same pair the panel colours. */
|
|
159
|
+
const OUTSIDER_PRINCIPALS: readonly Principal[] = [PRINCIPALS.visitor, PRINCIPALS.stranger];
|
|
160
|
+
|
|
161
|
+
/** One collection's declaration, read once so the four subjects cannot be answered from four
|
|
162
|
+
* different readings of it. */
|
|
163
|
+
interface Declared {
|
|
164
|
+
/** `col(a, cid)` — the collection's rule configuration on the app document. */
|
|
165
|
+
c: Record<string, unknown>;
|
|
166
|
+
/** `sub(a, cid)`, and `undefined` where `subOpen` is false. */
|
|
167
|
+
s: Record<string, unknown> | undefined;
|
|
168
|
+
publicOn: boolean;
|
|
169
|
+
publicRead: boolean;
|
|
170
|
+
partRead: boolean;
|
|
171
|
+
/** The clock `inWindow` is judged against — threaded in rather than read here so the summary
|
|
172
|
+
* stays a pure function of its inputs and a spec can pin a closed window without waiting. */
|
|
173
|
+
now: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `ownRow` — can THIS subject be bound to a row here at all?
|
|
177
|
+
*
|
|
178
|
+
* The bindings are the rules': the two `auth.uid` id strategies and `uidField` need only
|
|
179
|
+
* `authed()`, so the anonymous visitor reaches them; `emailField` compares `email()` and so needs
|
|
180
|
+
* `verified()`. Every one of them requires `subOpen` first, which is why a collection with no
|
|
181
|
+
* submit declaration gives nobody an own row however they signed in. */
|
|
182
|
+
function ownRowReachable(declared: Declared, who: Principal): boolean {
|
|
183
|
+
const { s } = declared;
|
|
184
|
+
if (s === undefined) return false;
|
|
185
|
+
const uidBound = s.idFrom === "auth.uid" || s.idFrom === "auth.uid+field" || typeof s.uidField === "string";
|
|
186
|
+
const emailBound = typeof s.emailField === "string";
|
|
187
|
+
return uidBound || (who.verified && emailBound);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `authOk(s)` for this subject. */
|
|
191
|
+
function authOk(stage: CollectionAccess["authStage"], who: Principal): boolean {
|
|
192
|
+
if (stage === "verifiedEmail") return who.verified;
|
|
193
|
+
// Both `none` and `anonymous` are satisfied by the anonymous session every public page holds.
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `inWindow(s, aid)`, evaluated against a clock.
|
|
198
|
+
*
|
|
199
|
+
* Evaluated rather than merely mentioned, because a CLOSED window is how the sample apps seal a
|
|
200
|
+
* board: `apps/roles` declares `window.until` in the year 2000 on all three of its collections, so
|
|
201
|
+
* a summary that only said "there is a window" reported an open door on an app nobody can post to.
|
|
202
|
+
* The rules compare `request.time`, so this is the same answer they give — at the moment it is
|
|
203
|
+
* asked, which is what makes the panel worth re-opening rather than a fact about the file.
|
|
204
|
+
*
|
|
205
|
+
* `perRecord` is the half that cannot be answered from here: `fromField` / `untilField` put the
|
|
206
|
+
* bound on ANOTHER record. It is reported as its own state so the caveat can say so instead of
|
|
207
|
+
* the table quietly picking one of the two answers. */
|
|
208
|
+
function windowState(submit: Record<string, unknown>, now: number): "none" | "open" | "closed" | "early" | "perRecord" {
|
|
209
|
+
const window = asRecord(submit.window);
|
|
210
|
+
if (window === undefined) return "none";
|
|
211
|
+
if (typeof window.fromMs === "number" && now < window.fromMs) return "early";
|
|
212
|
+
if (typeof window.untilMs === "number" && now >= window.untilMs) return "closed";
|
|
213
|
+
if (window.fromField !== undefined || window.untilField !== undefined) return "perRecord";
|
|
214
|
+
return "open";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Only the two states that are DECIDED and shut. `none` is a collection with no window at all,
|
|
218
|
+
* and `perRecord` is a bound this summary cannot read — refusing on either would report a closed
|
|
219
|
+
* door on most of the apps there are. */
|
|
220
|
+
function windowOpen(submit: Record<string, unknown>, now: number): boolean {
|
|
221
|
+
const state = windowState(submit, now);
|
|
222
|
+
return state !== "closed" && state !== "early";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The fields `validate` makes mandatory — `required` outright, and the `field` of each
|
|
226
|
+
* `keyFields` entry, which the rules index into. */
|
|
227
|
+
function validatedFields(submit: Record<string, unknown>): string[] {
|
|
228
|
+
const validate = asRecord(submit.validate);
|
|
229
|
+
if (validate === undefined) return [];
|
|
230
|
+
const keyed = Array.isArray(validate.keyFields) ? validate.keyFields : [];
|
|
231
|
+
return [...asStrings(validate.required), ...keyed.flatMap((entry) => (isRecord(entry) && typeof entry.field === "string" ? [entry.field] : []))];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Every field the rules REQUIRE a create to carry.
|
|
235
|
+
*
|
|
236
|
+
* It matters because `submitCreate` also asks `hasOnly(s.createFields)`: a field the rules demand
|
|
237
|
+
* and the list does not allow makes the two conjuncts contradict each other, and the collection
|
|
238
|
+
* accepts nothing at all from anybody. `{ emailField: "email", createFields: ["title"] }` is the
|
|
239
|
+
* shape — sending `email` fails `hasOnly`, omitting it fails the identity check — and this route
|
|
240
|
+
* answers for declarations a publish would refuse, so the contradiction reaches the panel. */
|
|
241
|
+
function requiredCreateFields(declared: Declared, stage: CollectionAccess["authStage"]): string[] {
|
|
242
|
+
const { c, s } = declared;
|
|
243
|
+
if (s === undefined) return [];
|
|
244
|
+
const need: string[] = [...validatedFields(s)];
|
|
245
|
+
// Only on the `verifiedEmail` stage: that is the branch of `authOk` that reads it.
|
|
246
|
+
if (stage === "verifiedEmail" && typeof s.emailField === "string") need.push(s.emailField);
|
|
247
|
+
// `uidOk` compares `.get(uidField, null)` against the uid, so an absent field simply loses.
|
|
248
|
+
if (typeof s.uidField === "string") need.push(s.uidField);
|
|
249
|
+
if (typeof s.idField === "string" && (s.idFrom === "auth.uid+field" || s.idFrom === "field" || s.idFrom === "slug")) need.push(s.idField);
|
|
250
|
+
if (typeof s.stampField === "string") need.push(s.stampField);
|
|
251
|
+
// The status field is demanded by TWO different rules, and the second one has no `initialStatus`
|
|
252
|
+
// in it: `initialOk` refuses a create whose status is null whenever `transitions` and
|
|
253
|
+
// `statusField` are both declared, so the submitter must be able to send one either way. Which
|
|
254
|
+
// VALUE they send is not decidable from the declaration — the `transitions` caveat carries that.
|
|
255
|
+
if (typeof c.statusField === "string" && (s.initialStatus !== undefined || c.transitions !== undefined)) need.push(c.statusField);
|
|
256
|
+
const gate = asRecord(s.gateOn);
|
|
257
|
+
if (gate !== undefined && typeof gate.match === "string") need.push(gate.match);
|
|
258
|
+
// `refIn` builds the parent's path out of this field of the submission.
|
|
259
|
+
const refIn = asRecord(c.refIn);
|
|
260
|
+
if (refIn !== undefined && typeof refIn.ref === "string") need.push(refIn.ref);
|
|
261
|
+
return need;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The two ways a status DECLARATION refuses every create by itself — both fail closed in the
|
|
265
|
+
* rules, and both are shapes a half-finished manifest really has.
|
|
266
|
+
*
|
|
267
|
+
* `submitCreate` requires `statusField in c` before it will compare `initialStatus`, so a submit
|
|
268
|
+
* block naming an initial status over a collection that declares no status field denies every
|
|
269
|
+
* submission. And `initialOk` looks the create's status up under `transitions.initial`, defaulting
|
|
270
|
+
* to the empty list — so an initial status the map does not list denies them too.
|
|
271
|
+
*
|
|
272
|
+
* Modelled rather than caveated because the answer is decided by the declaration alone: nothing
|
|
273
|
+
* about the record or the caller can rescue it, which is exactly when the table can speak. */
|
|
274
|
+
/** `initialOk(c)`, the half that binds EVERYBODY.
|
|
275
|
+
*
|
|
276
|
+
* It sits above the branch group in `createWith`, so it is asked of a writer's create exactly as
|
|
277
|
+
* it is asked of a visitor's. Only one shape of it is decidable from the declaration alone: with
|
|
278
|
+
* `transitions` and `statusField` both declared, the create's status is looked up under
|
|
279
|
+
* `transitions.initial` — and a map listing nothing there can be satisfied by no value at all,
|
|
280
|
+
* from anyone. Which value a writer WOULD send is not knowable here, which is why the rest of
|
|
281
|
+
* `initialOk` stays in the `transitions` caveat.
|
|
282
|
+
*
|
|
283
|
+
* Deliberately not folded into `initialStatusOk`: that one also carries the `initialStatus`
|
|
284
|
+
* comparison, which lives inside `submitCreate` and binds the public path only. Folding them
|
|
285
|
+
* would apply a public-path condition to a writer, which is the mirror image of this bug. */
|
|
286
|
+
function initialTransitionPossible(c: Record<string, unknown>): boolean {
|
|
287
|
+
const transitions = asRecord(c.transitions);
|
|
288
|
+
if (transitions === undefined || typeof c.statusField !== "string") return true;
|
|
289
|
+
return asStrings(transitions.initial).length > 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function initialStatusOk(declared: Declared): boolean {
|
|
293
|
+
const { c, s } = declared;
|
|
294
|
+
const initial = s?.initialStatus;
|
|
295
|
+
const transitions = asRecord(c.transitions);
|
|
296
|
+
if (initial === undefined) {
|
|
297
|
+
// `initialOk` only binds when BOTH are declared. When they are, the submitter has to supply a
|
|
298
|
+
// status the map lists under `initial` — so a map that lists none there (an empty list, or no
|
|
299
|
+
// `initial` key at all) can be satisfied by no value whatsoever.
|
|
300
|
+
return initialTransitionPossible(c);
|
|
301
|
+
}
|
|
302
|
+
// A non-string `initialStatus` can never equal the record's status field, which the rules compare
|
|
303
|
+
// without coercing — so it refuses every create for the same reason and by the same rule.
|
|
304
|
+
if (typeof initial !== "string" || typeof c.statusField !== "string") return false;
|
|
305
|
+
if (transitions === undefined) return true;
|
|
306
|
+
return asStrings(transitions.initial).includes(initial);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** `submitCreate` minus the parts that depend on the record and the clock.
|
|
310
|
+
*
|
|
311
|
+
* The first conjunct is the one #1926 was about and the one this whole panel is for: a
|
|
312
|
+
* `public.submit` declaration is not a statement that the app is open, so the gate is the SWITCH
|
|
313
|
+
* or the ROSTER, never the declaration's existence. */
|
|
314
|
+
function canCreate(declared: Declared, stage: CollectionAccess["authStage"], who: Principal, ignoreWindow = false): boolean {
|
|
315
|
+
const { s } = declared;
|
|
316
|
+
if (s === undefined) return false;
|
|
317
|
+
if (!(declared.publicOn || who.listed)) return false;
|
|
318
|
+
// Only the two states that are DECIDED and shut. `none` is a collection with no window at all,
|
|
319
|
+
// and `perRecord` is a bound this summary cannot read — refusing on either would report a closed
|
|
320
|
+
// door on most of the apps there are.
|
|
321
|
+
if (!ignoreWindow && !windowOpen(s, declared.now)) return false;
|
|
322
|
+
if (!Array.isArray(s.createFields)) return false;
|
|
323
|
+
const allowed = asStrings(s.createFields);
|
|
324
|
+
if (!requiredCreateFields(declared, stage).every((field) => allowed.includes(field))) return false;
|
|
325
|
+
if (!authOk(stage, who)) return false;
|
|
326
|
+
if (!initialStatusOk(declared)) return false;
|
|
327
|
+
// `audience: "participant"` is matched against the ROLE, so it shuts out viewers and editors as
|
|
328
|
+
// firmly as it shuts out strangers.
|
|
329
|
+
return s.audience !== "participant" || who.role === "participant";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** `selfWriteOk` and `selfDelete`, kept APART — the rules gate them differently and the difference
|
|
333
|
+
* shows up exactly when a window closes.
|
|
334
|
+
*
|
|
335
|
+
* Both are keyed by the CURRENT STATUS, so a collection with no `statusField` can say neither and
|
|
336
|
+
* the rules fail closed on it. That prerequisite is the declaration's, not a state machine's: a
|
|
337
|
+
* single-status collection satisfies it.
|
|
338
|
+
*
|
|
339
|
+
* The update half sits inside `updateWith`, which carries `inWindow`; the delete half is reached
|
|
340
|
+
* through `deleteWith`, which does not. So after a window closes a submitter may still WITHDRAW
|
|
341
|
+
* their row and may no longer EDIT it. */
|
|
342
|
+
function selfUpdateDeclared(declared: Declared): boolean {
|
|
343
|
+
const { c, s } = declared;
|
|
344
|
+
if (s === undefined || typeof c.statusField !== "string" || flagOn(s, "finalize")) return false;
|
|
345
|
+
if (!windowOpen(s, declared.now)) return false;
|
|
346
|
+
return editsSomeField(s) || movesSomeStatus(c, s);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** A `selfUpdate` entry that actually names a field.
|
|
350
|
+
*
|
|
351
|
+
* `selfWriteOk` asks `changed().hasOnly(selfUpdate[current])`, so an empty list passes only for a
|
|
352
|
+
* write that changes nothing — which is not an edit, and reporting it as one puts `Edit own only`
|
|
353
|
+
* on a row whose owner can change nothing at all. */
|
|
354
|
+
function editsSomeField(submit: Record<string, unknown>): boolean {
|
|
355
|
+
return Object.values(asRecord(submit.selfUpdate) ?? {}).some((fields) => asStrings(fields).length > 0);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A `selfTransitions` entry with a target the state machine will actually accept.
|
|
359
|
+
*
|
|
360
|
+
* Two ways it can name none: an empty target list, and a target the collection's own `transitions`
|
|
361
|
+
* forbid — `updateWith` asks `transitionOk(c)` before it ever reaches `selfWriteOk`, so a move the
|
|
362
|
+
* machine does not allow is refused however the submit block declares it. A target equal to the
|
|
363
|
+
* status it moves from passes `transitionOk` and changes nothing, so it is not a move either. */
|
|
364
|
+
function movesSomeStatus(c: Record<string, unknown>, submit: Record<string, unknown>): boolean {
|
|
365
|
+
const machine = asRecord(c.transitions);
|
|
366
|
+
return Object.entries(asRecord(submit.selfTransitions) ?? {}).some(([from, targets]) =>
|
|
367
|
+
asStrings(targets).some((to) => to !== from && (machine === undefined || asStrings(machine[from]).includes(to))),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function selfDeleteDeclared(declared: Declared): boolean {
|
|
372
|
+
const { c, s } = declared;
|
|
373
|
+
if (s === undefined || typeof c.statusField !== "string") return false;
|
|
374
|
+
// MINUS the sealed states. `deleteWith` asks `!sealedNow(c)` before it reaches `selfDelete`, so a
|
|
375
|
+
// declaration whose every withdrawable status is also sealed grants no withdrawal at all — and
|
|
376
|
+
// `selfDelete: ["draft"]` beside `sealed: ["draft"]` is a shape a manifest really has.
|
|
377
|
+
const sealed = asStrings(c.sealed);
|
|
378
|
+
return asStrings(s.selfDelete).some((status) => !sealed.includes(status));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Whether this subject can hold a row here AT ALL — the binding reaching them is necessary and
|
|
382
|
+
* not sufficient.
|
|
383
|
+
*
|
|
384
|
+
* `emailField` reaches every verified account in the world, so `ownRowReachable` alone would
|
|
385
|
+
* report "own rows" for a stranger who has no way to make one, in the one panel whose job is to
|
|
386
|
+
* say that strangers reach nothing. So the row has to have been creatable: by them, or — for
|
|
387
|
+
* someone on the roster — by the desk on their behalf.
|
|
388
|
+
*
|
|
389
|
+
* THE WINDOW IS DELIBERATELY IGNORED HERE. `ownRow` in the rules asks for a submit binding and the
|
|
390
|
+
* caller's identity and nothing else, so a visitor who submitted while the window was open goes on
|
|
391
|
+
* reading that row after it closes. Asking `canCreate` in full would erase their row from this
|
|
392
|
+
* table at the exact moment the panel is most likely to be consulted.
|
|
393
|
+
*
|
|
394
|
+
* What it still closes over is a stranger who submitted while the SWITCH was on and the app was
|
|
395
|
+
* closed afterwards. Nothing in the working tree records that it happened; `caveatsOf` says so in
|
|
396
|
+
* words instead. */
|
|
397
|
+
function holdsRow(declared: Declared, stage: CollectionAccess["authStage"], who: Principal): boolean {
|
|
398
|
+
return ownRowReachable(declared, who) && (who.listed || canCreate(declared, stage, who, true));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** `readWith`, in its own order: a role first, then the public switch, then the two roster-wide
|
|
402
|
+
* openings, and `ownRow` last as the narrowest answer that is still an answer. */
|
|
403
|
+
function readAccessFor(declared: Declared, who: Principal, isWriter: boolean, own: boolean): ReadAccess {
|
|
404
|
+
if (isWriter || declared.publicRead) return "all";
|
|
405
|
+
// `revealGated` sits with the other two roster-wide openings, and it is CONDITIONAL: the row
|
|
406
|
+
// opens once its parent says so. There is no "some rows" state and this table would rather
|
|
407
|
+
// overstate an insider's reach than report `Nothing` about a row the rules hand them — the
|
|
408
|
+
// caveat below carries the condition. Roster-only, so no outsider row moves.
|
|
409
|
+
const rosterWide = declared.partRead || declared.c.peerVisibility === "public" || flagOn(declared.c, "revealGated");
|
|
410
|
+
if (who.listed && rosterWide) return "all";
|
|
411
|
+
return own ? "own" : "none";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function accessFor(declared: Declared, stage: CollectionAccess["authStage"], who: Principal): SubjectAccess {
|
|
415
|
+
const { c } = declared;
|
|
416
|
+
const immutable = flagOn(c, "immutable");
|
|
417
|
+
const isWriter = who.role === "writer";
|
|
418
|
+
const own = holdsRow(declared, stage, who);
|
|
419
|
+
|
|
420
|
+
const read = readAccessFor(declared, who, isWriter, own);
|
|
421
|
+
|
|
422
|
+
// `createWith`: a writer creates unless the collection is `submitOnly`, and either way the
|
|
423
|
+
// public submission path is open to them on the same terms as everybody else.
|
|
424
|
+
const create = (isWriter && !flagOn(c, "submitOnly") && initialTransitionPossible(c)) || canCreate(declared, stage, who);
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
read,
|
|
428
|
+
create,
|
|
429
|
+
editOwn: own && !immutable && (selfUpdateDeclared(declared) || selfDeleteDeclared(declared)),
|
|
430
|
+
editAll: isWriter && !immutable,
|
|
431
|
+
// Everybody's, and unconditionally: `mirrorRepair` is the FIRST branch of `updateWith` and asks
|
|
432
|
+
// nothing about the caller.
|
|
433
|
+
repairMirror: typeof c.mirrorOf === "string",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The window's own sentence, in the four states `windowState` distinguishes. Split out of
|
|
438
|
+
* `caveatsOf` because it is the only branch there with more than one outcome. */
|
|
439
|
+
function windowCaveats(declared: Declared): string[] {
|
|
440
|
+
if (declared.s === undefined) return [];
|
|
441
|
+
const state = windowState(declared.s, declared.now);
|
|
442
|
+
if (state === "closed") {
|
|
443
|
+
return ["The submission window has CLOSED. Nobody reaches this collection through the public path now, whatever the rows above say about who could."];
|
|
444
|
+
}
|
|
445
|
+
if (state === "early") return ["The submission window has not opened yet."];
|
|
446
|
+
if (state === "open") return ["Submissions are only taken inside a declared window, and it is open right now."];
|
|
447
|
+
if (state === "perRecord") return ["Each record carries its own window bound, on another record — this summary cannot say whether any one of them is open."];
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** What the table cannot answer, said in the author's own declaration's terms.
|
|
452
|
+
*
|
|
453
|
+
* Every one of these NARROWS the table — none of them opens anything — so a reader who ignores
|
|
454
|
+
* the list is left with a summary that is too generous rather than too tight, which is the safe
|
|
455
|
+
* direction for the one question this panel is asked. */
|
|
456
|
+
function caveatsOf(declared: Declared, stage: CollectionAccess["authStage"]): string[] {
|
|
457
|
+
const { c, s } = declared;
|
|
458
|
+
const caveats: string[] = [];
|
|
459
|
+
// The gap `ownRow` deliberately does not model — see its note. Reported only where it is
|
|
460
|
+
// possible: a binding that reaches an outsider, and a door that is now shut to them.
|
|
461
|
+
//
|
|
462
|
+
// "Closed to them" has to mean closed BY THE SWITCH, which is the only thing that moves. A
|
|
463
|
+
// collection scoped `audience: "participant"` refuses an outsider whatever `public.enabled`
|
|
464
|
+
// says, so it never had the open period this sentence describes — and printing the caveat
|
|
465
|
+
// there put a warning on `apps/ai-blogs`, whose strangers have never been able to submit.
|
|
466
|
+
const strandable = OUTSIDER_PRINCIPALS.some(
|
|
467
|
+
(who) => ownRowReachable(declared, who) && !canCreate(declared, stage, who) && canCreate({ ...declared, publicOn: true }, stage, who, true),
|
|
468
|
+
);
|
|
469
|
+
if (strandable) {
|
|
470
|
+
// ONLY what is actually declared. `selfDelete` is what makes a withdrawal possible, and a
|
|
471
|
+
// caveat promising one where the declaration names none is the panel inventing a permission.
|
|
472
|
+
const andWithdraw = selfDeleteDeclared(declared) ? ", and may still withdraw it" : "";
|
|
473
|
+
caveats.push(
|
|
474
|
+
`Anyone who submitted while this collection was open still reads their own row${andWithdraw} — the rules bind a row to its submitter without asking the switch or the window.`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
caveats.push(...windowCaveats(declared));
|
|
478
|
+
// `gateOn` — the projected key, and the one the rules read. `gate` is not produced by anything.
|
|
479
|
+
if (s !== undefined && s.gateOn !== undefined)
|
|
480
|
+
caveats.push("A session gate has to be open, on the question the host is currently showing, before a submission is taken.");
|
|
481
|
+
if (s !== undefined && s.idFrom === "field")
|
|
482
|
+
caveats.push("The record id is a field, so the first submission for a value takes it and later ones are refused.");
|
|
483
|
+
// THE THREE THAT BIND A WRITER, and the reason they are here rather than in the table: each one
|
|
484
|
+
// is decided per RECORD, by the status that record is in or by another record entirely, and the
|
|
485
|
+
// `Owner / editor` row is one cell for the whole collection. Without them the row reads
|
|
486
|
+
// "Anything" about a collection where the owner cannot delete a closed topic.
|
|
487
|
+
if ("transitions" in c && typeof c.statusField === "string") {
|
|
488
|
+
caveats.push("A record's status may only move along the declared `transitions` — for everyone, the owner included.");
|
|
489
|
+
}
|
|
490
|
+
if (asStrings(c.sealed).length > 0) {
|
|
491
|
+
// DELETE only: a sealed record can still have its fields corrected. It is `deleteWith` that
|
|
492
|
+
// asks `sealedNow`, and saying "cannot be changed" here would be the panel being stricter than
|
|
493
|
+
// the rules, which is its own kind of wrong.
|
|
494
|
+
caveats.push("A record in a sealed state cannot be DELETED by anyone, the owner included — though its fields can still be corrected.");
|
|
495
|
+
}
|
|
496
|
+
if (isRecord(c.refIn)) {
|
|
497
|
+
caveats.push(
|
|
498
|
+
"A new record is refused unless the record it points at is in the state `refIn` requires, and that reference is frozen afterwards — the owner is bound too.",
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
if (flagOn(c, "revealGated")) {
|
|
502
|
+
// The condition the `All rows` above cannot carry: it is per record, and only after the parent
|
|
503
|
+
// says so.
|
|
504
|
+
caveats.push("Everyone on the roster reads a row ONCE ITS PARENT REVEALS IT, and not before — the roster rows above are the state after the reveal.");
|
|
505
|
+
}
|
|
506
|
+
if (typeof c.assigneeField === "string") caveats.push("An assignee reads everything here and writes only the rows assigned to them.");
|
|
507
|
+
if (typeof c.mirrorOf === "string") caveats.push("Anyone may repair this collection's `state` field to match the record it mirrors.");
|
|
508
|
+
// The SUBMISSION side of the same pair (`mirrorClaimed` / `mirrorReleased`), which binds every
|
|
509
|
+
// create and every delete here — the writer branches included. Without it a create that looks
|
|
510
|
+
// allowed in the table is refused for a reason nothing on this panel names.
|
|
511
|
+
if (typeof s?.mirror === "string") {
|
|
512
|
+
caveats.push(`A record here cannot be created or deleted on its own: the same write has to move the slot it claims in \`${s.mirror}\`.`);
|
|
513
|
+
}
|
|
514
|
+
return caveats;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The summary, from the two documents `app(aid)` resolves to.
|
|
518
|
+
*
|
|
519
|
+
* `cids` is the collections the app PUBLISHES, passed in rather than inferred from the keys
|
|
520
|
+
* below: a collection that declares no rule configuration, no public read and no submit is
|
|
521
|
+
* exactly the one whose absence from this table would be read as "it is not published". */
|
|
522
|
+
export function sharedAppAccessOf(
|
|
523
|
+
app: Record<string, unknown>,
|
|
524
|
+
publicBlock: Record<string, unknown> | undefined,
|
|
525
|
+
cids: readonly string[],
|
|
526
|
+
now: number = Date.now(),
|
|
527
|
+
): SharedAppAccess {
|
|
528
|
+
const publicOn = publicBlock?.enabled === true;
|
|
529
|
+
const readList = asStrings(publicBlock?.read);
|
|
530
|
+
const submit = asRecord(publicBlock?.submit) ?? {};
|
|
531
|
+
const configured = asRecord(app.collections) ?? {};
|
|
532
|
+
const participantRead = asStrings(app.participantRead);
|
|
533
|
+
const members = asRecord(app.members) ?? {};
|
|
534
|
+
|
|
535
|
+
const all = [...new Set([...cids, ...Object.keys(configured), ...Object.keys(submit), ...readList, ...participantRead])].sort(byCodeUnit);
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
publicFace: publicFaceOf(publicBlock),
|
|
539
|
+
unmatchableRoster: Object.keys(members).filter(unmatchable).sort(byCodeUnit),
|
|
540
|
+
collections: all.map((cid): CollectionAccess => {
|
|
541
|
+
const s = asRecord(submit[cid]);
|
|
542
|
+
const declared: Declared = {
|
|
543
|
+
c: asRecord(configured[cid]) ?? {},
|
|
544
|
+
s,
|
|
545
|
+
publicOn,
|
|
546
|
+
publicRead: publicOn && readList.includes(cid),
|
|
547
|
+
partRead: participantRead.includes(cid),
|
|
548
|
+
now,
|
|
549
|
+
};
|
|
550
|
+
const stage = s?.auth === "anonymous" || s?.auth === "verifiedEmail" ? s.auth : "none";
|
|
551
|
+
return {
|
|
552
|
+
cid,
|
|
553
|
+
takesSubmissions: s !== undefined,
|
|
554
|
+
authStage: stage,
|
|
555
|
+
census: censusOf(members, cid),
|
|
556
|
+
caveats: caveatsOf(declared, stage),
|
|
557
|
+
access: {
|
|
558
|
+
visitor: accessFor(declared, stage, PRINCIPALS.visitor),
|
|
559
|
+
stranger: accessFor(declared, stage, PRINCIPALS.stranger),
|
|
560
|
+
participant: accessFor(declared, stage, PRINCIPALS.participant),
|
|
561
|
+
writer: accessFor(declared, stage, PRINCIPALS.writer),
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The wire shape of `GET /api/shared-app/access`, in the three states the pane distinguishes:
|
|
569
|
+
* not a shared app at all, a manifest that could not be read, and the summary. */
|
|
570
|
+
export type SharedAppAccessResponse =
|
|
571
|
+
{ declared: false } | { declared: true; ok: false; problems: string[] } | { declared: true; ok: true; access: SharedAppAccess };
|