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.
Files changed (165) hide show
  1. package/README.md +44 -1
  2. package/common/dirConfigSource.ts +3 -0
  3. package/common/sharedAppAccess.ts +571 -0
  4. package/common/sharedAppPreview.ts +5 -3
  5. package/common/sharedAppPublicFace.ts +30 -0
  6. package/dist/assets/{AllPackages-CF2GkZ5g-Bx8CoPCM.js → AllPackages-CF2GkZ5g-BUkwHhnA.js} +1 -1
  7. package/dist/assets/{BaseConfiguration-CAwZOAki-D9gdRJqD.js → BaseConfiguration-CAwZOAki-sAOejtXv.js} +1 -1
  8. package/dist/assets/{Element-BMI7Nj5w-KlkWc31P.js → Element-BMI7Nj5w-CtzS4V48.js} +1 -1
  9. package/dist/assets/{Entities-C8p_0jCn-onU5WJ7w.js → Entities-C8p_0jCn-Dw13tyTR.js} +1 -1
  10. package/dist/assets/{FunctionList-YWk54TCy-DyMwrJz0.js → FunctionList-YWk54TCy-CsA_edRr.js} +1 -1
  11. package/dist/assets/{InputJax-C5dj-ntL-CVLHxRml.js → InputJax-C5dj-ntL-DBWzFhVU.js} +1 -1
  12. package/dist/assets/{MathItem-YJvQL-XJ-BvjVwJXF.js → MathItem-YJvQL-XJ-DNYqBS01.js} +1 -1
  13. package/dist/assets/{MmlFactory-DuXYIgx8-Cm5_8EoF.js → MmlFactory-DuXYIgx8-CWLIjjvy.js} +1 -1
  14. package/dist/assets/{Options-oARQeeuT-ChGWvCt4.js → Options-oARQeeuT-CU8UddjX.js} +1 -1
  15. package/dist/assets/{OutputJax-DfUZNNCu-DaG5nzzg.js → OutputJax-DfUZNNCu-sgxDDWV-.js} +1 -1
  16. package/dist/assets/{PrioritizedList-6lun1Xqd-C5Db-F3s.js → PrioritizedList-6lun1Xqd-ykocRGXe.js} +1 -1
  17. package/dist/assets/{Styles-BPXeoqzi-Cr0wNGcQ.js → Styles-BPXeoqzi-DSzGxEMs.js} +1 -1
  18. package/dist/assets/{TeXAtom-LHT3KFWH-CnXfZ5YO.js → TeXAtom-LHT3KFWH-CNjc8vYz.js} +1 -1
  19. package/dist/assets/{abnfDiagram-VCTEODGH-DoYNjdBB-DuoOQ-OB.js → abnfDiagram-VCTEODGH-DoYNjdBB-CBWKmDCi.js} +1 -1
  20. package/dist/assets/{arc-CqByPKkF-Dn1DCplU.js → arc-CqByPKkF-88IlSEMD.js} +1 -1
  21. package/dist/assets/architecture-7GRP2DOG-CRS69XmP-CfEYhnsg.js +1 -0
  22. package/dist/assets/{architectureDiagram-5GKGNRK7-BhcT6qS5-D7hRerxV.js → architectureDiagram-5GKGNRK7-BhcT6qS5-BLtGEqlp.js} +1 -1
  23. package/dist/assets/{assistive-mml-DXJXho4o-DSJCOJrU.js → assistive-mml-DXJXho4o-DzIDEOlP.js} +1 -1
  24. package/dist/assets/{blockDiagram-I7D4REHJ-1Cng0Q4K-CE_lDQNR.js → blockDiagram-I7D4REHJ-1Cng0Q4K-DcIDSMg7.js} +1 -1
  25. package/dist/assets/{c4Diagram-7LVT6UL2-CAb9c8nc-XwP1jrs8.js → c4Diagram-7LVT6UL2-CAb9c8nc-C7a_Eswh.js} +1 -1
  26. package/dist/assets/channel-BjESmYnj-m72KoOEU.js +1 -0
  27. package/dist/assets/{chunk-2E4U76K2-CMyNbRyJ-O4iHMJzN.js → chunk-2E4U76K2-CMyNbRyJ-uuFlgwng.js} +1 -1
  28. package/dist/assets/{chunk-4HAMMTFA-ChwfIAnF-IqkmczzX.js → chunk-4HAMMTFA-ChwfIAnF-BV3UqcZo.js} +1 -1
  29. package/dist/assets/{chunk-75Z2AOVW-CbPA0Zt_-BC4_pS5x.js → chunk-75Z2AOVW-CbPA0Zt_-DP7076EE.js} +1 -1
  30. package/dist/assets/{chunk-CLGD4ZFX-CVw8XHcp-BD9KH9HS.js → chunk-CLGD4ZFX-CVw8XHcp-CLPfrpnT.js} +1 -1
  31. package/dist/assets/{chunk-DU6HZSFF-DuOx2LFX-BfylIFCt.js → chunk-DU6HZSFF-DuOx2LFX-B9i9yp2H.js} +1 -1
  32. package/dist/assets/{chunk-F27PBJKO-CgBMw9yN-i2GWxi_8.js → chunk-F27PBJKO-CgBMw9yN-DheADmYc.js} +1 -1
  33. package/dist/assets/{chunk-GMAD6QVW-JTqlimUX-DPF75NJ0.js → chunk-GMAD6QVW-JTqlimUX-Bv32TXUx.js} +1 -1
  34. package/dist/assets/{chunk-GVQU2GXP-D6ccB4DO-CdSlCYYP.js → chunk-GVQU2GXP-D6ccB4DO-94yHs3IY.js} +1 -1
  35. package/dist/assets/{chunk-IMKFNOWR-ij1DSzMb-bJpIateE.js → chunk-IMKFNOWR-ij1DSzMb-BqsuqPds.js} +1 -1
  36. package/dist/assets/{chunk-L3NEJ4N5-CUoAQVG2-Ckcm2qf9.js → chunk-L3NEJ4N5-CUoAQVG2-DxJSvGS_.js} +1 -1
  37. package/dist/assets/{chunk-OSK3NFVY-_VNbXQSu-CBZ1Oi9V.js → chunk-OSK3NFVY-_VNbXQSu-Ccnx0KHf.js} +1 -1
  38. package/dist/assets/{chunk-P2QGCYS3-B8lzOn3Y-C_Ar_ICY.js → chunk-P2QGCYS3-B8lzOn3Y-CaFtW38P.js} +1 -1
  39. package/dist/assets/{chunk-POPQ4Y6H-Bh_uOTmp-CZ0WDoDp.js → chunk-POPQ4Y6H-Bh_uOTmp-D2RHBZ5s.js} +1 -1
  40. package/dist/assets/{chunk-PWAF6VOD-DzjnfYSw-CbrKSYcQ.js → chunk-PWAF6VOD-DzjnfYSw-BJFV14F1.js} +1 -1
  41. package/dist/assets/{chunk-SHT3W25Y-B8dQLS5w-Be-9moIP.js → chunk-SHT3W25Y-B8dQLS5w-Df12R7Gh.js} +1 -1
  42. package/dist/assets/{chunk-SVP7TREG-B6A-T8Th-D-6Fw0Gs.js → chunk-SVP7TREG-B6A-T8Th-D6_Ichh1.js} +1 -1
  43. package/dist/assets/{chunk-TICWLB2K-C1kwD29l-Bn8XWgqO.js → chunk-TICWLB2K-C1kwD29l-DbGHkRkh.js} +1 -1
  44. package/dist/assets/{chunk-TLUHSLCS-DYzGlCmi-DuHf7zHR.js → chunk-TLUHSLCS-DYzGlCmi-CJdhEIVL.js} +2 -2
  45. package/dist/assets/{chunk-XXDRQBXY-vg0kfC73-C8I5TxHr.js → chunk-XXDRQBXY-vg0kfC73-aHwnHV68.js} +1 -1
  46. package/dist/assets/classDiagram-ZZMXUADV-C7gP-pUH-DLDPmUSl.js +1 -0
  47. package/dist/assets/classDiagram-v2-VYDZK3BY-CUwV5k1F-DLDPmUSl.js +1 -0
  48. package/dist/assets/{cose-bilkent-JH36ORCC-NuHCtE3P-DbBSvoTS.js → cose-bilkent-JH36ORCC-NuHCtE3P-CArrWVtM.js} +1 -1
  49. package/dist/assets/cynefin-OW5HDTMX-C4iKivN4-BQEcGeGg.js +1 -0
  50. package/dist/assets/{cynefinDiagram-5FMLGOSQ-CA7sro34-g7Ohjffk.js → cynefinDiagram-5FMLGOSQ-CA7sro34-BzZQpKl2.js} +1 -1
  51. package/dist/assets/{dagre-GXQ25YYZ-DOztjUR_-B5EqH5BS.js → dagre-GXQ25YYZ-DOztjUR_-DOx-551u.js} +1 -1
  52. package/dist/assets/{diagram-S7CK7UJ4-D_nsLfRT-BvfKDOXc.js → diagram-S7CK7UJ4-D_nsLfRT-DWr9WRW6.js} +1 -1
  53. package/dist/assets/{diagram-UQ7AKVKN-DNN-YX9a-DKCPIpWb.js → diagram-UQ7AKVKN-DNN-YX9a-C9TXc-ah.js} +1 -1
  54. package/dist/assets/{diagram-VSXAHHWV--HENzo-l-CDEKcryA.js → diagram-VSXAHHWV--HENzo-l-DmNtXjHQ.js} +1 -1
  55. package/dist/assets/{diagram-VX7I27RA-m2m9dROj-Cg2nqYjf.js → diagram-VX7I27RA-m2m9dROj-Cq6wReC4.js} +1 -1
  56. package/dist/assets/{diagram-Z3DM3KII-D4qpv4-B-cB7PWSYG.js → diagram-Z3DM3KII-D4qpv4-B-DtXyJB7G.js} +1 -1
  57. package/dist/assets/{dist-7-TNBgyu.js → dist-BQPlJ9lU.js} +1 -1
  58. package/dist/assets/{dist-9C_qvTjj.js → dist-DOVOmrou.js} +1 -1
  59. package/dist/assets/{dist-DeiyD9Ki-CXfNTGOJ.js → dist-DeiyD9Ki-C1_gcVU2.js} +1 -1
  60. package/dist/assets/{dist-4YyNMfLL.js → dist-DtF92Clz.js} +1 -1
  61. package/dist/assets/{dist-CdTewfjF.js → dist-TlhJ1D4x.js} +1 -1
  62. package/dist/assets/{ebnfDiagram-PWID7BFC-CAbv3sZg-yedKuMgW.js → ebnfDiagram-PWID7BFC-CAbv3sZg-BbjG8-uo.js} +1 -1
  63. package/dist/assets/{erDiagram-RLTQ6QDP-a10Ryh0--DoRMdP9Q.js → erDiagram-RLTQ6QDP-a10Ryh0--C72i1ywY.js} +1 -1
  64. package/dist/assets/eventmodeling-NTZA5JFV-DhqtNS8R-BK1N0_ZM.js +1 -0
  65. package/dist/assets/flowDiagram-HODETNUW-CAW_8mwY-B3-stLUn.js +1 -0
  66. package/dist/assets/{ganttDiagram-EL5Y4UJY-Cb7leeOC-Dja2ctXX.js → ganttDiagram-EL5Y4UJY-Cb7leeOC-DYFiF709.js} +1 -1
  67. package/dist/assets/gitGraph-4MIJSDKK-S_Zs6Nxs-BRRPIfYV.js +1 -0
  68. package/dist/assets/{gitGraphDiagram-WWUBYQGX-BCSbUpXG-atF7efoT.js → gitGraphDiagram-WWUBYQGX-BCSbUpXG-DejyXZfK.js} +1 -1
  69. package/dist/assets/{html-DdQiKDqM-BoalOcqT.js → html-DdQiKDqM-CPke18TU.js} +1 -1
  70. package/dist/assets/index-C7OnGcwW.css +1 -0
  71. package/dist/assets/index-Z2OTrqqI.js +1051 -0
  72. package/dist/assets/info-A6RAGUB7-4sGJh1is-fp_xBD4Z.js +1 -0
  73. package/dist/assets/{infoDiagram-27XIBGKW-BJRaZItN-BEIeq3GV.js → infoDiagram-27XIBGKW-BJRaZItN-DnsdjhkP.js} +1 -1
  74. package/dist/assets/{ishikawaDiagram-5VMMS53U-DHlFe__S-DQ_AsNwg.js → ishikawaDiagram-5VMMS53U-DHlFe__S-CmJe5Tlt.js} +1 -1
  75. package/dist/assets/{journeyDiagram-3NMN7TZE-BOIo3JM--CnnYTo0O.js → journeyDiagram-3NMN7TZE-BOIo3JM--Y8oQ4y77.js} +1 -1
  76. package/dist/assets/{kanban-definition-UXKFOSKX-yrQdG683-x-RC3tkv.js → kanban-definition-UXKFOSKX-yrQdG683-CFIQzQvF.js} +1 -1
  77. package/dist/assets/{lengths-CvmPfBid-10gEIzPK.js → lengths-CvmPfBid-BQ2m-oBM.js} +1 -1
  78. package/dist/assets/{line-DUb1TjJS-DShOBLG6.js → line-DUb1TjJS-C1or5HkU.js} +1 -1
  79. package/dist/assets/{linear-xsuGWu5q-D-IsIc-E.js → linear-xsuGWu5q-DO1NVk1F.js} +1 -1
  80. package/dist/assets/{liteAdaptor-C0FQiOEx-CKOo0lvo.js → liteAdaptor-C0FQiOEx-rHhNCala.js} +1 -1
  81. package/dist/assets/{marp-ngiOtnIU.js → marp-246Tptqb.js} +1 -1
  82. package/dist/assets/{mathjax-DVEo4UYj-DPhwOynN.js → mathjax-DVEo4UYj-BKhDfB9b.js} +1 -1
  83. package/dist/assets/{mermaid-parser.core-fkjeiYO7-BwcekFOJ.js → mermaid-parser.core-fkjeiYO7-Du7nNQKj.js} +2 -2
  84. package/dist/assets/{mermaid.core-CZDx2Cyn-CeJk12OP.js → mermaid.core-CZDx2Cyn-B8TG9ViQ.js} +3 -3
  85. package/dist/assets/{mindmap-definition-YA3MSWOX-BpKAVyqs-CSsgL9Ev.js → mindmap-definition-YA3MSWOX-BpKAVyqs-BjKrJvG3.js} +1 -1
  86. package/dist/assets/{mo-CrlNnGXX-C7iwFKb3.js → mo-CrlNnGXX-DG8Vectz.js} +1 -1
  87. package/dist/assets/packet-AYTQ26CC-MQQyx8fI-jr1o6-sN.js +1 -0
  88. package/dist/assets/{pegDiagram-XKGWAZYB-C7eG_Ohl-DnQeEqS7.js → pegDiagram-XKGWAZYB-C7eG_Ohl-BhWdbn0k.js} +1 -1
  89. package/dist/assets/pie-WAS4IAKB-CyaL5i_d-Bu147U5x.js +1 -0
  90. package/dist/assets/{pieDiagram-E7YTZNPT-5f9wSfDR-DemBN8Yf.js → pieDiagram-E7YTZNPT-5f9wSfDR-Lecay4e9.js} +1 -1
  91. package/dist/assets/{quadrantDiagram-AXDQQJYC-mWeaOj5S-DJ33JkPA.js → quadrantDiagram-AXDQQJYC-mWeaOj5S-DS7mHc4o.js} +1 -1
  92. package/dist/assets/radar-RG4KPBEZ-DR-SsHDJ-CN5skCMU.js +1 -0
  93. package/dist/assets/railroad-74A4TZTK-C2gqsOGX-aDdh8kqg.js +1 -0
  94. package/dist/assets/railroad-abnf-HS5TGJTU-Dpzw6P5u-RpiXuxqp.js +1 -0
  95. package/dist/assets/railroad-ebnf-LZEXJU2U-J_BuIrPo-C0a1HZjw.js +1 -0
  96. package/dist/assets/railroad-peg-WCYAUIDC-DzMStOR--DYIIa1Hw.js +1 -0
  97. package/dist/assets/{railroadDiagram-O6MQD6OU-DJskhE7u-CwIUMhSD.js → railroadDiagram-O6MQD6OU-DJskhE7u-Dmh0-Us2.js} +1 -1
  98. package/dist/assets/{requirementDiagram-BXWQKSXE-B5E0bU4h-hoSDzLQJ.js → requirementDiagram-BXWQKSXE-B5E0bU4h-CyS_2sxk.js} +1 -1
  99. package/dist/assets/{sankeyDiagram-P5KCCOFB-DS3AOTai-BZeF2KIM.js → sankeyDiagram-P5KCCOFB-DS3AOTai-CSuBkdUO.js} +1 -1
  100. package/dist/assets/{sequenceDiagram-WJ2MYXX4-Bn2L9osv-Bs7XPvuf.js → sequenceDiagram-WJ2MYXX4-Bn2L9osv-CHxEbZ0W.js} +1 -1
  101. package/dist/assets/{src-CLC0ddSd-DsNPBPD5.js → src-CLC0ddSd-CjxPB3S7.js} +1 -1
  102. package/dist/assets/{stateDiagram-D77RDMKH-DbtoW7HL-n_W7QA2i.js → stateDiagram-D77RDMKH-DbtoW7HL-yabfmTgk.js} +1 -1
  103. package/dist/assets/stateDiagram-v2-MP3YSRHH-CX6RalKY-GzlXJtMT.js +1 -0
  104. package/dist/assets/{svg-CaHU_sxA-eLTj-UFn.js → svg-CaHU_sxA-D8CRwYZQ.js} +1 -1
  105. package/dist/assets/{swimlanes-42K2YHIH-90h8w_3B-PrSiGyln.js → swimlanes-42K2YHIH-90h8w_3B-lAgM4Z9o.js} +1 -1
  106. package/dist/assets/swimlanesDiagram-VR7AAH4N-49XH_TMr-4AicWyJt.js +8 -0
  107. package/dist/assets/{tex-B1cONMvb-CwimKS8s.js → tex-B1cONMvb-DydoaQyY.js} +1 -1
  108. package/dist/assets/{timeline-definition-24CTP7MA-Bx3KcICO-BeSkr_pz.js → timeline-definition-24CTP7MA-Bx3KcICO-BH12S7gs.js} +1 -1
  109. package/dist/assets/treeView-Q6P3EWNA-gFs854Sn-aY6owIcz.js +1 -0
  110. package/dist/assets/treemap-WGGIJYW6-DGh_szUA-YLwoYd-l.js +1 -0
  111. package/dist/assets/{vennDiagram-4TSXK5OY-DSexNe_H-Cif5NoRB.js → vennDiagram-4TSXK5OY-DSexNe_H-B0-oluSn.js} +1 -1
  112. package/dist/assets/wardley-WFR3VGLG-BVLYL3gB-Bmrp_tbe.js +1 -0
  113. package/dist/assets/{wardleyDiagram-VM6X3IG4-DesYYyZW-L-RyhMHN.js → wardleyDiagram-VM6X3IG4-DesYYyZW-CIvLV4o_.js} +1 -1
  114. package/dist/assets/{xychartDiagram-S5SC5T6Z-dIt6pHWV-Ct1J2Ggu.js → xychartDiagram-S5SC5T6Z-dIt6pHWV-C1D4f_2b.js} +1 -1
  115. package/dist/index.html +2 -2
  116. package/package.json +7 -7
  117. package/server/backends/collections.ts +10 -11
  118. package/server/backends/deckList.ts +142 -0
  119. package/server/backends/mulmoscript.ts +120 -1
  120. package/server/backends/sharedApp/access.ts +47 -0
  121. package/server/backends/sharedApp/context.ts +1 -1
  122. package/server/backends/sharedApp/preview.ts +2 -1
  123. package/server/backends/sharedApp/publish.ts +8 -5
  124. package/server/backends/sharedAppPreviewRoutes.ts +56 -18
  125. package/server/backends/stagedSkills.ts +71 -0
  126. package/server/backends/storiesRoot.ts +29 -0
  127. package/server/backends/storiesRootSet.ts +49 -0
  128. package/server/backends/workspaceSetup.ts +5 -12
  129. package/server/config/config-routes.ts +8 -1
  130. package/server/config/config-schema.ts +18 -0
  131. package/server/config/dir-config.ts +6 -1
  132. package/server/config/worktree-dir-config.ts +3 -1
  133. package/server/index.ts +5 -1
  134. package/server/infra/canonical-path.ts +15 -0
  135. package/server/infra/collection-tool.ts +12 -10
  136. package/server/infra/project-root.ts +15 -0
  137. package/server/infra/shared-app-tool.ts +34 -6
  138. package/server/routes/dir-routes.ts +27 -2
  139. package/server/skills/mulmoterminal-config/SKILL.md +30 -2
  140. package/server/skills/mulmoterminal-dirs/SKILL.md +3 -2
  141. package/server/skills/mulmoterminal-shared-app/SKILL.md +67 -6
  142. package/server/skills/mulmoterminal-shared-app/templates/append-feed.md +1 -1
  143. package/dist/assets/architecture-7GRP2DOG-CRS69XmP-CAHsIjH1.js +0 -1
  144. package/dist/assets/channel-BjESmYnj-CFiXT9oS.js +0 -1
  145. package/dist/assets/classDiagram-ZZMXUADV-C7gP-pUH-w1B1j6lG.js +0 -1
  146. package/dist/assets/classDiagram-v2-VYDZK3BY-CUwV5k1F-w1B1j6lG.js +0 -1
  147. package/dist/assets/cynefin-OW5HDTMX-C4iKivN4-DCP-fZBd.js +0 -1
  148. package/dist/assets/eventmodeling-NTZA5JFV-DhqtNS8R-BqMQQV9r.js +0 -1
  149. package/dist/assets/flowDiagram-HODETNUW-CAW_8mwY-BbCF3Xlq.js +0 -1
  150. package/dist/assets/gitGraph-4MIJSDKK-S_Zs6Nxs-ZWRSFUk-.js +0 -1
  151. package/dist/assets/index-B-BwWalM.css +0 -1
  152. package/dist/assets/index-DJqafXyu.js +0 -1051
  153. package/dist/assets/info-A6RAGUB7-4sGJh1is-B6ciYeff.js +0 -1
  154. package/dist/assets/packet-AYTQ26CC-MQQyx8fI-C1uVptHd.js +0 -1
  155. package/dist/assets/pie-WAS4IAKB-CyaL5i_d-CvuBD4G5.js +0 -1
  156. package/dist/assets/radar-RG4KPBEZ-DR-SsHDJ-CXZSNBN1.js +0 -1
  157. package/dist/assets/railroad-74A4TZTK-C2gqsOGX-BLUc9I7K.js +0 -1
  158. package/dist/assets/railroad-abnf-HS5TGJTU-Dpzw6P5u-CDyLVmSE.js +0 -1
  159. package/dist/assets/railroad-ebnf-LZEXJU2U-J_BuIrPo-BBzTDDwY.js +0 -1
  160. package/dist/assets/railroad-peg-WCYAUIDC-DzMStOR--Cljx_bWU.js +0 -1
  161. package/dist/assets/stateDiagram-v2-MP3YSRHH-CX6RalKY-Dq9S-AUp.js +0 -1
  162. package/dist/assets/swimlanesDiagram-VR7AAH4N-49XH_TMr-y3IgDYmY.js +0 -8
  163. package/dist/assets/treeView-Q6P3EWNA-gFs854Sn-CnQVHjtu.js +0 -1
  164. package/dist/assets/treemap-WGGIJYW6-DGh_szUA-DdG0VnPJ.js +0 -1
  165. 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 and `.claude/skills` right inside a cell.
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
  ![MulmoTerminal's grid view — four live Claude sessions running side by side, each in its own color-coded project](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/grid-2x2-live.png)
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 };