agent-whiteboard 1.0.4 → 1.2.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 (202) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +61 -5
  3. package/dist/client/assets/abnfDiagram-VCTEODGH-Dj8sd_5g.js +1 -0
  4. package/dist/client/assets/{arc-4691b008.js → arc-Aq92EeUK.js} +1 -1
  5. package/dist/client/assets/architectureDiagram-5GKGNRK7-B1QyVL0F.js +36 -0
  6. package/dist/client/assets/blockDiagram-I7D4REHJ-CmU2SSFe.js +129 -0
  7. package/dist/client/assets/{bootstrap.min-21652813.js → bootstrap.min-UtnB-TxT.js} +1 -2
  8. package/dist/client/assets/c4Diagram-7LVT6UL2-DWmhp77c.js +38 -0
  9. package/dist/client/assets/channel-C9ni1Via.js +1 -0
  10. package/dist/client/assets/{chunk-QZHKN3VN-0f554985.js → chunk-2Q5K7J3B-CQLo8Sbv.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-23d48b24.js → chunk-5VM5RSS4-fJTWmTTt.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-1104c326.js → chunk-F27PBJKO-DqzEf5gi.js} +1 -1
  13. package/dist/client/assets/chunk-IMKFNOWR-CMamLTz3.js +231 -0
  14. package/dist/client/assets/{chunk-4BX2VUAB-3c93511d.js → chunk-JWPE2WC7-tyYPyD43.js} +1 -1
  15. package/dist/client/assets/chunk-POPQ4Y6H-XdY4rO_V.js +1 -0
  16. package/dist/client/assets/chunk-SVP7TREG-COvqzqaO.js +88 -0
  17. package/dist/client/assets/chunk-TICWLB2K-BsJSFuPC.js +206 -0
  18. package/dist/client/assets/{chunk-55IACEB6-0243aefd.js → chunk-XXDRQBXY-HfVrYBJb.js} +1 -1
  19. package/dist/client/assets/classDiagram-ZZMXUADV-DktNEiGC.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-VYDZK3BY-DktNEiGC.js +1 -0
  21. package/dist/client/assets/cose-bilkent-JH36ORCC-UXGdwSjS.js +1 -0
  22. package/dist/client/assets/cynefin-OW5HDTMX-PWlPYnpn.js +178 -0
  23. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-CYE5H1wR.js +62 -0
  24. package/dist/client/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  25. package/dist/client/assets/dagre-GXQ25YYZ-DaknBOKq.js +4 -0
  26. package/dist/client/assets/diagram-S7CK7UJ4-Dz7rZHxF.js +30 -0
  27. package/dist/client/assets/diagram-UQ7AKVKN-DzCi6tiS.js +41 -0
  28. package/dist/client/assets/{diagram-KO2AKTUF-81f42eeb.js → diagram-VSXAHHWV-TlUzhXgv.js} +3 -3
  29. package/dist/client/assets/diagram-VX7I27RA-Ds7jdzK7.js +24 -0
  30. package/dist/client/assets/diagram-Z3DM3KII-C--JD7cf.js +24 -0
  31. package/dist/client/assets/ebnfDiagram-PWID7BFC-Dw5OM-dL.js +1 -0
  32. package/dist/client/assets/embed-DcNeILcO.js +211 -0
  33. package/dist/client/assets/erDiagram-RLTQ6QDP-DXalBHjA.js +99 -0
  34. package/dist/client/assets/flowDiagram-HODETNUW-DcI3UqyE.js +168 -0
  35. package/dist/client/assets/ganttDiagram-EL5Y4UJY-DyrkIo6c.js +292 -0
  36. package/dist/client/assets/gitGraphDiagram-WWUBYQGX-fJKfGRcg.js +106 -0
  37. package/dist/client/assets/index-CIhkXT_z.js +19 -0
  38. package/dist/client/assets/index-DEiH2AE7.css +1 -0
  39. package/dist/client/assets/infoDiagram-27XIBGKW-Cijiqswb.js +2 -0
  40. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-50b43d03.js → ishikawaDiagram-5VMMS53U-C1WqWU2l.js} +2 -2
  41. package/dist/client/assets/{journeyDiagram-JHISSGLW-dce41bdf.js → journeyDiagram-3NMN7TZE-7j05mbLR.js} +4 -4
  42. package/dist/client/assets/{kanban-definition-UN3LZRKU-92401a86.js → kanban-definition-UXKFOSKX-D5mGDfs_.js} +1 -1
  43. package/dist/client/assets/{katex-4c9ea27f.js → katex-BTI-AXqd.js} +3 -3
  44. package/dist/client/assets/{katex-0cba7652.js → katex-C5jXJg4s.js} +3 -3
  45. package/dist/client/assets/layout-KEin06-H.js +1 -0
  46. package/dist/client/assets/linear-D1EL1Azg.js +1 -0
  47. package/dist/client/assets/mermaid.core-CLvSDmrA.js +310 -0
  48. package/dist/client/assets/{mindmap-definition-RKZ34NQL-66fe4544.js → mindmap-definition-YA3MSWOX-BsCqKd_d.js} +12 -12
  49. package/dist/client/assets/{ordinal-db88ae6f.js → ordinal-BeghXfj9.js} +1 -1
  50. package/dist/client/assets/pegDiagram-XKGWAZYB-Du5lDU3W.js +1 -0
  51. package/dist/client/assets/pieDiagram-E7YTZNPT-hjv5Ao6B.js +39 -0
  52. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-f64a7ed7.js → quadrantDiagram-AXDQQJYC-DNon8LWK.js} +4 -4
  53. package/dist/client/assets/railroadDiagram-O6MQD6OU-yNZz2zxo.js +1 -0
  54. package/dist/client/assets/requirementDiagram-BXWQKSXE-CZiiEUHW.js +84 -0
  55. package/dist/client/assets/sankeyDiagram-P5KCCOFB-D-u8ZQUV.js +40 -0
  56. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-BxjTf3q9.js +162 -0
  57. package/dist/client/assets/sizeCapture-INFHLROL-Dt4Wg8C5.js +1 -0
  58. package/dist/client/assets/stateDiagram-D77RDMKH-B8BM6dln.js +1 -0
  59. package/dist/client/assets/stateDiagram-v2-MP3YSRHH-MJauX84T.js +1 -0
  60. package/dist/client/assets/step-BEQzeS8V.js +1 -0
  61. package/dist/client/assets/swimlanes-42K2YHIH-eGWrsWAA.js +1 -0
  62. package/dist/client/assets/swimlanesDiagram-VR7AAH4N-BYFu8tyL.js +8 -0
  63. package/dist/client/assets/{time-043b3464.js → time-BaoZouO5.js} +1 -1
  64. package/dist/client/assets/{timeline-definition-PNZ67QCA-b0c81243.js → timeline-definition-24CTP7MA-CAM-3Qj9.js} +10 -10
  65. package/dist/client/assets/vennDiagram-4TSXK5OY-CuFEwwD0.js +34 -0
  66. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-8d4d7626.js → wardleyDiagram-VM6X3IG4-DI5yCVh0.js} +3 -3
  67. package/dist/client/assets/xychartDiagram-S5SC5T6Z-BI0Bn-OQ.js +7 -0
  68. package/dist/client/index.html +2 -3
  69. package/dist/server/app.js +28 -396
  70. package/dist/server/channel.js +5 -3
  71. package/dist/server/export-html.js +30 -60
  72. package/dist/server/export-zip.js +83 -0
  73. package/dist/server/import-zip.js +196 -0
  74. package/dist/server/index.js +2 -1
  75. package/dist/server/mcp.js +28 -13
  76. package/dist/server/port.js +18 -0
  77. package/dist/server/quiz-submission.js +116 -0
  78. package/dist/server/quiz.js +110 -0
  79. package/dist/server/render-core.js +8 -7
  80. package/dist/server/routes/export.js +95 -0
  81. package/dist/server/routes/import.js +24 -0
  82. package/dist/server/routes/quiz.js +12 -0
  83. package/dist/server/routes/render.js +184 -0
  84. package/dist/server/routes/slideshow.js +52 -0
  85. package/dist/server/routes/snapshots.js +119 -0
  86. package/dist/server/snapshot-reader.js +58 -20
  87. package/dist/server/snapshot-writer.js +18 -16
  88. package/dist/server/validate.js +14 -0
  89. package/dist/server/viewport-cache.js +97 -25
  90. package/package.json +13 -7
  91. package/dist/client/assets/architecture-7EHR7CIX-1221aa61.js +0 -1
  92. package/dist/client/assets/architectureDiagram-3BPJPVTR-1eef4d3c.js +0 -36
  93. package/dist/client/assets/blockDiagram-GPEHLZMM-313fb06c.js +0 -132
  94. package/dist/client/assets/c4Diagram-AAUBKEIU-92b023ad.js +0 -10
  95. package/dist/client/assets/channel-5ee4a8a7.js +0 -1
  96. package/dist/client/assets/chunk-2J33WTMH-fb021fdc.js +0 -1
  97. package/dist/client/assets/chunk-727SXJPM-8e0e0228.js +0 -206
  98. package/dist/client/assets/chunk-AQP2D5EJ-2ccc13c2.js +0 -231
  99. package/dist/client/assets/classDiagram-4FO5ZUOK-42ffecb6.js +0 -1
  100. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-42ffecb6.js +0 -1
  101. package/dist/client/assets/cose-bilkent-S5V4N54A-45ec2e52.js +0 -1
  102. package/dist/client/assets/cytoscape.esm-1491e259.js +0 -331
  103. package/dist/client/assets/dagre-BM42HDAG-ed748885.js +0 -4
  104. package/dist/client/assets/diagram-2AECGRRQ-ccc0ddec.js +0 -43
  105. package/dist/client/assets/diagram-5GNKFQAL-342efdea.js +0 -10
  106. package/dist/client/assets/diagram-LMA3HP47-39e66739.js +0 -24
  107. package/dist/client/assets/diagram-OG6HWLK6-b3e61903.js +0 -24
  108. package/dist/client/assets/embed-2b4adf34.js +0 -201
  109. package/dist/client/assets/erDiagram-TEJ5UH35-281f5cc7.js +0 -85
  110. package/dist/client/assets/eventmodeling-FCH6USID-6508120e.js +0 -1
  111. package/dist/client/assets/flowDiagram-I6XJVG4X-c3a4269e.js +0 -162
  112. package/dist/client/assets/ganttDiagram-6RSMTGT7-013b07ec.js +0 -292
  113. package/dist/client/assets/gitGraph-WXDBUCRP-02827359.js +0 -1
  114. package/dist/client/assets/gitGraphDiagram-PVQCEYII-de184acb.js +0 -106
  115. package/dist/client/assets/graph-f3dfaf53.js +0 -1
  116. package/dist/client/assets/index-d674a6a4.css +0 -1
  117. package/dist/client/assets/index-ffc5af4b.js +0 -5
  118. package/dist/client/assets/info-J43DQDTF-81e32294.js +0 -1
  119. package/dist/client/assets/infoDiagram-5YYISTIA-196101ee.js +0 -2
  120. package/dist/client/assets/layout-3a4eff80.js +0 -1
  121. package/dist/client/assets/linear-fed00341.js +0 -1
  122. package/dist/client/assets/mermaid-parser.core-52f9cd92.js +0 -173
  123. package/dist/client/assets/mermaid.core-7dfc221a.js +0 -306
  124. package/dist/client/assets/packet-YPE3B663-278a272a.js +0 -1
  125. package/dist/client/assets/pie-LRSECV5Y-2e55d9ae.js +0 -1
  126. package/dist/client/assets/pieDiagram-4H26LBE5-65b1bcbd.js +0 -30
  127. package/dist/client/assets/radar-GUYGQ44K-608e459f.js +0 -1
  128. package/dist/client/assets/requirementDiagram-4Y6WPE33-90ebf889.js +0 -84
  129. package/dist/client/assets/sankeyDiagram-5OEKKPKP-2d1a83b3.js +0 -40
  130. package/dist/client/assets/sequenceDiagram-3UESZ5HK-aece3633.js +0 -162
  131. package/dist/client/assets/stateDiagram-AJRCARHV-9bd5b5f4.js +0 -1
  132. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-7c16c251.js +0 -1
  133. package/dist/client/assets/step-171ad54a.js +0 -1
  134. package/dist/client/assets/treeView-BLDUP644-e54b9ddb.js +0 -1
  135. package/dist/client/assets/treemap-LRROVOQU-6a81775b.js +0 -1
  136. package/dist/client/assets/vennDiagram-CIIHVFJN-8eaf0464.js +0 -34
  137. package/dist/client/assets/wardley-L42UT6IY-7abd921f.js +0 -1
  138. package/dist/client/assets/xychartDiagram-2RQKCTM6-450f333c.js +0 -7
  139. /package/dist/client/assets/{KaTeX_AMS-Regular-0cdd387c.woff2 → KaTeX_AMS-Regular-BQhdFMY1.woff2} +0 -0
  140. /package/dist/client/assets/{KaTeX_AMS-Regular-30da91e8.woff → KaTeX_AMS-Regular-DMm9YOAa.woff} +0 -0
  141. /package/dist/client/assets/{KaTeX_AMS-Regular-68534840.ttf → KaTeX_AMS-Regular-DRggAlZN.ttf} +0 -0
  142. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-07d8e303.ttf → KaTeX_Caligraphic-Bold-ATXxdsX0.ttf} +0 -0
  143. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-1ae6bd74.woff → KaTeX_Caligraphic-Bold-BEiXGLvX.woff} +0 -0
  144. /package/dist/client/assets/{KaTeX_Caligraphic-Bold-de7701e4.woff2 → KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2} +0 -0
  145. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-3398dd02.woff → KaTeX_Caligraphic-Regular-CTRA-rTL.woff} +0 -0
  146. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-5d53e70a.woff2 → KaTeX_Caligraphic-Regular-Di6jR-x-.woff2} +0 -0
  147. /package/dist/client/assets/{KaTeX_Caligraphic-Regular-ed0b7437.ttf → KaTeX_Caligraphic-Regular-wX97UBjC.ttf} +0 -0
  148. /package/dist/client/assets/{KaTeX_Fraktur-Bold-9163df9c.ttf → KaTeX_Fraktur-Bold-BdnERNNW.ttf} +0 -0
  149. /package/dist/client/assets/{KaTeX_Fraktur-Bold-9be7ceb8.woff → KaTeX_Fraktur-Bold-BsDP51OF.woff} +0 -0
  150. /package/dist/client/assets/{KaTeX_Fraktur-Bold-74444efd.woff2 → KaTeX_Fraktur-Bold-CL6g_b3V.woff2} +0 -0
  151. /package/dist/client/assets/{KaTeX_Fraktur-Regular-1e6f9579.ttf → KaTeX_Fraktur-Regular-CB_wures.ttf} +0 -0
  152. /package/dist/client/assets/{KaTeX_Fraktur-Regular-51814d27.woff2 → KaTeX_Fraktur-Regular-CTYiF6lA.woff2} +0 -0
  153. /package/dist/client/assets/{KaTeX_Fraktur-Regular-5e28753b.woff → KaTeX_Fraktur-Regular-Dxdc4cR9.woff} +0 -0
  154. /package/dist/client/assets/{KaTeX_Main-Bold-0f60d1b8.woff2 → KaTeX_Main-Bold-Cx986IdX.woff2} +0 -0
  155. /package/dist/client/assets/{KaTeX_Main-Bold-c76c5d69.woff → KaTeX_Main-Bold-Jm3AIy58.woff} +0 -0
  156. /package/dist/client/assets/{KaTeX_Main-Bold-138ac28d.ttf → KaTeX_Main-Bold-waoOVXN0.ttf} +0 -0
  157. /package/dist/client/assets/{KaTeX_Main-BoldItalic-99cd42a3.woff2 → KaTeX_Main-BoldItalic-DxDJ3AOS.woff2} +0 -0
  158. /package/dist/client/assets/{KaTeX_Main-BoldItalic-70ee1f64.ttf → KaTeX_Main-BoldItalic-DzxPMmG6.ttf} +0 -0
  159. /package/dist/client/assets/{KaTeX_Main-BoldItalic-a6f7ec0d.woff → KaTeX_Main-BoldItalic-SpSLRI95.woff} +0 -0
  160. /package/dist/client/assets/{KaTeX_Main-Italic-0d85ae7c.ttf → KaTeX_Main-Italic-3WenGoN9.ttf} +0 -0
  161. /package/dist/client/assets/{KaTeX_Main-Italic-f1d6ef86.woff → KaTeX_Main-Italic-BMLOBm91.woff} +0 -0
  162. /package/dist/client/assets/{KaTeX_Main-Italic-97479ca6.woff2 → KaTeX_Main-Italic-NWA7e6Wa.woff2} +0 -0
  163. /package/dist/client/assets/{KaTeX_Main-Regular-c2342cd8.woff2 → KaTeX_Main-Regular-B22Nviop.woff2} +0 -0
  164. /package/dist/client/assets/{KaTeX_Main-Regular-c6368d87.woff → KaTeX_Main-Regular-Dr94JaBh.woff} +0 -0
  165. /package/dist/client/assets/{KaTeX_Main-Regular-d0332f52.ttf → KaTeX_Main-Regular-ypZvNtVU.ttf} +0 -0
  166. /package/dist/client/assets/{KaTeX_Math-BoldItalic-f9377ab0.ttf → KaTeX_Math-BoldItalic-B3XSjfu4.ttf} +0 -0
  167. /package/dist/client/assets/{KaTeX_Math-BoldItalic-dc47344d.woff2 → KaTeX_Math-BoldItalic-CZnvNsCZ.woff2} +0 -0
  168. /package/dist/client/assets/{KaTeX_Math-BoldItalic-850c0af5.woff → KaTeX_Math-BoldItalic-iY-2wyZ7.woff} +0 -0
  169. /package/dist/client/assets/{KaTeX_Math-Italic-8a8d2445.woff → KaTeX_Math-Italic-DA0__PXp.woff} +0 -0
  170. /package/dist/client/assets/{KaTeX_Math-Italic-08ce98e5.ttf → KaTeX_Math-Italic-flOr_0UB.ttf} +0 -0
  171. /package/dist/client/assets/{KaTeX_Math-Italic-7af58c5e.woff2 → KaTeX_Math-Italic-t53AETM-.woff2} +0 -0
  172. /package/dist/client/assets/{KaTeX_SansSerif-Bold-1ece03f7.ttf → KaTeX_SansSerif-Bold-CFMepnvq.ttf} +0 -0
  173. /package/dist/client/assets/{KaTeX_SansSerif-Bold-e99ae511.woff2 → KaTeX_SansSerif-Bold-D1sUS0GD.woff2} +0 -0
  174. /package/dist/client/assets/{KaTeX_SansSerif-Bold-ece03cfd.woff → KaTeX_SansSerif-Bold-DbIhKOiC.woff} +0 -0
  175. /package/dist/client/assets/{KaTeX_SansSerif-Italic-00b26ac8.woff2 → KaTeX_SansSerif-Italic-C3H0VqGB.woff2} +0 -0
  176. /package/dist/client/assets/{KaTeX_SansSerif-Italic-91ee6750.woff → KaTeX_SansSerif-Italic-DN2j7dab.woff} +0 -0
  177. /package/dist/client/assets/{KaTeX_SansSerif-Italic-3931dd81.ttf → KaTeX_SansSerif-Italic-YYjJ1zSn.ttf} +0 -0
  178. /package/dist/client/assets/{KaTeX_SansSerif-Regular-f36ea897.ttf → KaTeX_SansSerif-Regular-BNo7hRIc.ttf} +0 -0
  179. /package/dist/client/assets/{KaTeX_SansSerif-Regular-11e4dc8a.woff → KaTeX_SansSerif-Regular-CS6fqUqJ.woff} +0 -0
  180. /package/dist/client/assets/{KaTeX_SansSerif-Regular-68e8c73e.woff2 → KaTeX_SansSerif-Regular-DDBCnlJ7.woff2} +0 -0
  181. /package/dist/client/assets/{KaTeX_Script-Regular-1c67f068.ttf → KaTeX_Script-Regular-C5JkGWo-.ttf} +0 -0
  182. /package/dist/client/assets/{KaTeX_Script-Regular-036d4e95.woff2 → KaTeX_Script-Regular-D3wIWfF6.woff2} +0 -0
  183. /package/dist/client/assets/{KaTeX_Script-Regular-d96cdf2b.woff → KaTeX_Script-Regular-D5yQViql.woff} +0 -0
  184. /package/dist/client/assets/{KaTeX_Size1-Regular-c943cc98.woff → KaTeX_Size1-Regular-C195tn64.woff} +0 -0
  185. /package/dist/client/assets/{KaTeX_Size1-Regular-95b6d2f1.ttf → KaTeX_Size1-Regular-Dbsnue_I.ttf} +0 -0
  186. /package/dist/client/assets/{KaTeX_Size1-Regular-6b47c401.woff2 → KaTeX_Size1-Regular-mCD8mA8B.woff2} +0 -0
  187. /package/dist/client/assets/{KaTeX_Size2-Regular-a6b2099f.ttf → KaTeX_Size2-Regular-B7gKUWhC.ttf} +0 -0
  188. /package/dist/client/assets/{KaTeX_Size2-Regular-d04c5421.woff2 → KaTeX_Size2-Regular-Dy4dx90m.woff2} +0 -0
  189. /package/dist/client/assets/{KaTeX_Size2-Regular-2014c523.woff → KaTeX_Size2-Regular-oD1tc_U0.woff} +0 -0
  190. /package/dist/client/assets/{KaTeX_Size3-Regular-6ab6b62e.woff → KaTeX_Size3-Regular-CTq5MqoE.woff} +0 -0
  191. /package/dist/client/assets/{KaTeX_Size3-Regular-500e04d5.ttf → KaTeX_Size3-Regular-DgpXs0kz.ttf} +0 -0
  192. /package/dist/client/assets/{KaTeX_Size4-Regular-99f9c675.woff → KaTeX_Size4-Regular-BF-4gkZK.woff} +0 -0
  193. /package/dist/client/assets/{KaTeX_Size4-Regular-c647367d.ttf → KaTeX_Size4-Regular-DWFBv043.ttf} +0 -0
  194. /package/dist/client/assets/{KaTeX_Size4-Regular-a4af7d41.woff2 → KaTeX_Size4-Regular-Dl5lxZxV.woff2} +0 -0
  195. /package/dist/client/assets/{KaTeX_Typewriter-Regular-e14fed02.woff → KaTeX_Typewriter-Regular-C0xS9mPB.woff} +0 -0
  196. /package/dist/client/assets/{KaTeX_Typewriter-Regular-71d517d6.woff2 → KaTeX_Typewriter-Regular-CO6r4hn1.woff2} +0 -0
  197. /package/dist/client/assets/{KaTeX_Typewriter-Regular-f01f3e87.ttf → KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf} +0 -0
  198. /package/dist/client/assets/{colors-98f238a0.js → colors-Cc3OSVma.js} +0 -0
  199. /package/dist/client/assets/{defaultLocale-a8476f12.js → defaultLocale-CzDG__Ur.js} +0 -0
  200. /package/dist/client/assets/{index-a1656aec.js → index-Bi7T_FA4.js} +0 -0
  201. /package/dist/client/assets/{init-a5b10ee5.js → init-Dmth1JHB.js} +0 -0
  202. /package/dist/client/assets/{range-6bc2a9a0.js → range-OtVwhkKS.js} +0 -0
@@ -1,47 +1,12 @@
1
1
  import { readFileSync } from "fs";
2
2
  import { createRequire } from "module";
3
3
  import { createHash } from "crypto";
4
- import { Window } from "happy-dom";
4
+ import { JSDOM } from "jsdom";
5
5
  import katex from "katex";
6
6
  import * as vl from "vega-lite";
7
7
  import * as vega from "vega";
8
8
  import DOMPurify from "dompurify";
9
9
  import { badgeType } from "./snapshot-reader.js";
10
- function saveGlobals() {
11
- const keys = [
12
- "document", "window", "CSSStyleSheet", "SVGElement", "HTMLElement",
13
- "Element", "DOMParser", "MutationObserver",
14
- "requestAnimationFrame", "cancelAnimationFrame",
15
- ];
16
- const saved = new Map();
17
- for (const k of keys)
18
- saved.set(k, global[k]);
19
- return saved;
20
- }
21
- function setGlobals(win) {
22
- const g = global;
23
- const w = win;
24
- g["document"] = win.document;
25
- g["window"] = win;
26
- g["CSSStyleSheet"] = w["CSSStyleSheet"];
27
- g["SVGElement"] = w["SVGElement"];
28
- g["HTMLElement"] = w["HTMLElement"];
29
- g["Element"] = w["Element"];
30
- g["DOMParser"] = w["DOMParser"];
31
- g["MutationObserver"] = w["MutationObserver"];
32
- g["requestAnimationFrame"] = (fn) => setTimeout(() => fn(Date.now()), 16);
33
- g["cancelAnimationFrame"] = clearTimeout;
34
- }
35
- function restoreGlobals(saved) {
36
- for (const [k, v] of saved) {
37
- if (v === undefined) {
38
- delete global[k];
39
- }
40
- else {
41
- global[k] = v;
42
- }
43
- }
44
- }
45
10
  // ── Renderers ──────────────────────────────────────────────────────────────
46
11
  function renderMermaidContainer(payload) {
47
12
  return `<pre class="mermaid">${escapeHtml(payload)}</pre>`;
@@ -59,7 +24,13 @@ function renderSvgPayload(payload, purify) {
59
24
  return purify.sanitize(payload, { USE_PROFILES: { svg: true } });
60
25
  }
61
26
  function renderHtmlPayload(payload, purify) {
62
- return purify.sanitize(payload, { USE_PROFILES: { html: true } });
27
+ // ADD_TAGS: the "html" profile's default allowlist excludes <style> (unlike
28
+ // the "svg" profile below, which already allows it) — explicit here since
29
+ // scopeEmbeddedStyles() (B20) depends on <style> surviving sanitization.
30
+ // FORCE_BODY: without it, a leading <style> is parsed as implicit <head>
31
+ // content and silently dropped from DOMPurify's body-only output — ADD_TAGS
32
+ // alone isn't enough.
33
+ return purify.sanitize(payload, { USE_PROFILES: { html: true }, ADD_TAGS: ["style"], FORCE_BODY: true });
63
34
  }
64
35
  /**
65
36
  * Pure string-transform primitive: wraps a CSS string in `@scope (<selector-list>)
@@ -102,6 +73,9 @@ async function renderByType(type, payload, purify) {
102
73
  return renderSvgPayload(payload, purify);
103
74
  case "html":
104
75
  return renderHtmlPayload(payload, purify);
76
+ case "quiz":
77
+ // Quizzes are interactive and carry no static representation (F50) — export notes it instead of failing.
78
+ return '<p class="export-note">This item is an interactive quiz and is not included in HTML exports.</p>';
105
79
  default:
106
80
  throw new Error(`unsupported render type: ${type}`);
107
81
  }
@@ -276,6 +250,7 @@ const LAYOUT_CSS = `
276
250
  .type-badge { background: #e8f4fd; color: #2980b9; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
277
251
  .frame-section { margin-top: 16px; padding: 12px 16px; border: 1px solid #f0f0f0; border-radius: 4px; background: #fafafa; overflow-x: auto; }
278
252
  .frame-heading { font-size: 12px; color: #777; margin: 0 0 10px; font-style: italic; }
253
+ .export-note { color: #555; background: #f4f5f6; padding: 10px 14px; border-radius: 4px; border-left: 3px solid #aaa; font-size: 13px; margin: 0; }
279
254
  .export-error { color: #c0392b; background: #fdf0f0; padding: 10px 14px; border-radius: 4px; border-left: 3px solid #e74c3c; font-size: 13px; margin: 0; }
280
255
  svg { max-width: 100%; height: auto; }
281
256
  table { max-width: 100%; border-collapse: collapse; }
@@ -412,10 +387,24 @@ function itemsIncludeType(items, type) {
412
387
  return items.some(({ record }) => record.frames.some((f) => f.type === type));
413
388
  }
414
389
  // ── Public entrypoint ──────────────────────────────────────────────────────
415
- async function generateExportHtmlInner(items, mode) {
416
- const win = new Window();
417
- const savedGlobals = saveGlobals();
418
- setGlobals(win);
390
+ /**
391
+ * Each call builds its own jsdom `Window` and passes it explicitly to
392
+ * `DOMPurify(win)` — sanitization runs entirely against that instance, never
393
+ * against Node's global object. KaTeX's `renderToString()` and Vega's
394
+ * `View.toSVG()` (via `renderer: "none"`) need no DOM at all. Because
395
+ * nothing here reads or writes `global.document`/`window`, concurrent calls
396
+ * (reachable from both `POST /export-html` and the `export_html` MCP tool,
397
+ * see bug B14 in `01`) can never stomp on each other's state — no
398
+ * serialization queue is needed.
399
+ *
400
+ * jsdom, not happy-dom: DOMPurify 3.4.8+ silently fails to sanitize
401
+ * descendant nodes against a happy-dom `Window` (upstream incompatibility,
402
+ * capricorn86/happy-dom#1810) — <script> tags and on* attributes pass
403
+ * through unstripped. DOMPurify's own maintainers recommend jsdom for
404
+ * server-side use; confirmed this sanitizes correctly against jsdom.
405
+ */
406
+ export async function generateExportHtml(items, mode) {
407
+ const win = new JSDOM("").window;
419
408
  const purify = DOMPurify(win);
420
409
  const rendered = [];
421
410
  try {
@@ -431,7 +420,6 @@ async function generateExportHtmlInner(items, mode) {
431
420
  }
432
421
  }
433
422
  finally {
434
- restoreGlobals(savedGlobals);
435
423
  win.close();
436
424
  }
437
425
  const hasKatex = itemsIncludeType(items, "katex");
@@ -441,21 +429,3 @@ async function generateExportHtmlInner(items, mode) {
441
429
  const downloadFilename = buildDownloadFilename(uniqueWorkspaces);
442
430
  return { html, downloadFilename };
443
431
  }
444
- // generateExportHtmlInner() patches global DOM state (document, window, ...)
445
- // for the duration of a call so happy-dom-backed rendering works, then
446
- // restores it in a `finally`. That save/set/restore isn't reentrant: two
447
- // overlapping calls (POST /export-html and the export_html MCP tool can run
448
- // concurrently) each save/restore against whatever the *other* call happened
449
- // to have in place at that moment, which can leave global DOM state pointing
450
- // at an already-closed Window once both settle (B14). A simple queue forces
451
- // calls to run one at a time, in the order they were made, so only one
452
- // call's globals are ever active at once.
453
- let exportQueue = Promise.resolve();
454
- export function generateExportHtml(items, mode) {
455
- const result = exportQueue.then(() => generateExportHtmlInner(items, mode));
456
- // Chain the queue off a version that never rejects, so one failed export
457
- // doesn't permanently wedge every export queued after it; the caller of
458
- // generateExportHtml() still sees the original rejection via `result`.
459
- exportQueue = result.then(() => undefined, () => undefined);
460
- return result;
461
- }
@@ -0,0 +1,83 @@
1
+ import { createRequire } from "module";
2
+ import archiver from "archiver";
3
+ import { findSnapshotFileByIdInWorkspace } from "./snapshot-reader.js";
4
+ /**
5
+ * Reads own package.json version via createRequire (works whether this
6
+ * module runs as server/export-zip.ts under tsx, one level below repo root,
7
+ * or as the compiled dist/server/export-zip.js, two levels below).
8
+ */
9
+ function getAppVersion() {
10
+ const req = createRequire(import.meta.url);
11
+ for (const rel of ["../package.json", "../../package.json"]) {
12
+ try {
13
+ const pkg = req(rel);
14
+ if (typeof pkg.version === "string")
15
+ return pkg.version;
16
+ }
17
+ catch {
18
+ // try next candidate
19
+ }
20
+ }
21
+ return "unknown";
22
+ }
23
+ function buildZipDownloadFilename(workspace) {
24
+ const now = new Date();
25
+ const pad = (n) => String(n).padStart(2, "0");
26
+ const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
27
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
28
+ const sanitized = workspace.replace(/[^a-zA-Z0-9_.-]/g, "-").slice(0, 24);
29
+ return `${sanitized}-${ts}.zip`;
30
+ }
31
+ /**
32
+ * Builds a downloadable zip (F36) of the given snapshots' raw JSON files
33
+ * plus a generated manifest.json — no rendering/sanitization pass, unlike
34
+ * generateExportHtml(). Every item must share the same workspace (the
35
+ * manifest format is single-workspace); a mixed-workspace list is rejected
36
+ * rather than silently exporting a subset. Ids that don't resolve to a file
37
+ * on disk are silently skipped (matching /export-html's per-item leniency);
38
+ * an all-miss request is rejected. Viewport-cache data is never included —
39
+ * this only ever touches the snapshot JSON files themselves.
40
+ */
41
+ export async function generateExportZip(items, root) {
42
+ if (items.length === 0) {
43
+ return { ok: false, error: "items must be a non-empty array" };
44
+ }
45
+ const workspace = items[0].workspace;
46
+ if (items.some((item) => item.workspace !== workspace)) {
47
+ return { ok: false, error: "all items must belong to the same workspace" };
48
+ }
49
+ const files = [];
50
+ for (const item of items) {
51
+ const found = await findSnapshotFileByIdInWorkspace(workspace, item.id, root);
52
+ if (found !== null)
53
+ files.push(found);
54
+ }
55
+ if (files.length === 0) {
56
+ return { ok: false, error: "no valid items to export" };
57
+ }
58
+ const manifest = {
59
+ formatVersion: 1,
60
+ workspace,
61
+ exportedAt: new Date().toISOString(),
62
+ appVersion: getAppVersion(),
63
+ snapshots: files.map((f) => f.filename),
64
+ };
65
+ const archive = archiver("zip", { zlib: { level: 9 } });
66
+ const chunks = [];
67
+ archive.on("data", (chunk) => chunks.push(chunk));
68
+ const done = new Promise((resolve, reject) => {
69
+ archive.on("end", resolve);
70
+ archive.on("error", reject);
71
+ });
72
+ for (const f of files) {
73
+ archive.append(f.raw, { name: f.filename });
74
+ }
75
+ archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
76
+ await archive.finalize();
77
+ await done;
78
+ return {
79
+ ok: true,
80
+ zip: Buffer.concat(chunks),
81
+ downloadFilename: buildZipDownloadFilename(workspace),
82
+ };
83
+ }
@@ -0,0 +1,196 @@
1
+ import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import extract from "extract-zip";
5
+ import { z } from "zod";
6
+ import { isValidSnapshotFilename, validateWorkspaceInput } from "./validate.js";
7
+ import { findSnapshotFileByIdInWorkspace } from "./snapshot-reader.js";
8
+ const DEFAULT_LIMITS = {
9
+ maxEntryUncompressedBytes: 20 * 1024 * 1024, // 20MB — generous for a snapshot JSON file
10
+ maxTotalUncompressedBytes: 200 * 1024 * 1024, // 200MB total across the whole archive
11
+ maxEntries: 2000,
12
+ };
13
+ const manifestSchema = z.object({
14
+ formatVersion: z.number(),
15
+ workspace: z.string().min(1),
16
+ exportedAt: z.string(),
17
+ appVersion: z.string(),
18
+ snapshots: z.array(z.string()),
19
+ });
20
+ function parseIdTimestamp(raw) {
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ return {
24
+ id: typeof parsed.id === "string" ? parsed.id : undefined,
25
+ timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp : undefined,
26
+ };
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ /**
33
+ * F40 — picks the filename of the newest (by ISO-8601 timestamp string
34
+ * comparison, same convention as snapshot-reader.ts's own sort) entry among
35
+ * everything actually added/updated by this import, so the caller can load
36
+ * it and switch the client's active workspace as a side effect.
37
+ */
38
+ function pickNewestFilename(entries) {
39
+ const withTimestamp = entries.filter((e) => e.timestamp !== undefined);
40
+ if (withTimestamp.length === 0)
41
+ return undefined;
42
+ return withTimestamp.reduce((newest, e) => (e.timestamp > newest.timestamp ? e : newest)).filename;
43
+ }
44
+ /**
45
+ * F39/NF55 — imports an uploaded workspace-export zip (F36's format).
46
+ * Extracts to a fresh temp directory first (never directly into the live
47
+ * snapshots tree) via `extract-zip`, which rejects any entry whose resolved
48
+ * path would escape that directory (zip-slip) as part of its own
49
+ * extraction — confirmed empirically, not just by reading its docs. Only
50
+ * `manifest.json` and the files it explicitly lists are ever trusted; a
51
+ * listed filename is re-validated with `isValidSnapshotFilename()` before
52
+ * being joined into any path, since the manifest itself is just JSON text
53
+ * an attacker could otherwise use to point outside the temp/destination
54
+ * directory even though the zip *entries* themselves are already safe.
55
+ */
56
+ export async function importZip(zipBuffer, targetWorkspaceInput, mode, root, limits = {}) {
57
+ const { maxEntryUncompressedBytes, maxTotalUncompressedBytes, maxEntries } = { ...DEFAULT_LIMITS, ...limits };
58
+ const validated = validateWorkspaceInput(targetWorkspaceInput);
59
+ if (!validated.ok) {
60
+ return { ok: false, error: validated.error };
61
+ }
62
+ const targetWorkspace = validated.workspace;
63
+ const tempRoot = await mkdtemp(join(tmpdir(), "agent-whiteboard-import-"));
64
+ const zipPath = join(tempRoot, "upload.zip");
65
+ const extractDir = join(tempRoot, "extracted");
66
+ try {
67
+ await writeFile(zipPath, zipBuffer);
68
+ await mkdir(extractDir, { recursive: true });
69
+ let totalUncompressed = 0;
70
+ let entryCount = 0;
71
+ try {
72
+ await extract(zipPath, {
73
+ dir: extractDir,
74
+ onEntry: (entry) => {
75
+ entryCount++;
76
+ if (entryCount > maxEntries) {
77
+ throw new Error("zip contains too many entries");
78
+ }
79
+ if (entry.uncompressedSize > maxEntryUncompressedBytes) {
80
+ throw new Error("zip contains an entry that is too large");
81
+ }
82
+ totalUncompressed += entry.uncompressedSize;
83
+ if (totalUncompressed > maxTotalUncompressedBytes) {
84
+ throw new Error("zip's total uncompressed size is too large");
85
+ }
86
+ },
87
+ });
88
+ }
89
+ catch (err) {
90
+ return { ok: false, error: err instanceof Error ? err.message : "failed to extract zip" };
91
+ }
92
+ let manifestRaw;
93
+ try {
94
+ manifestRaw = await readFile(join(extractDir, "manifest.json"), "utf-8");
95
+ }
96
+ catch {
97
+ return { ok: false, error: "manifest.json is missing from the zip" };
98
+ }
99
+ let manifestJson;
100
+ try {
101
+ manifestJson = JSON.parse(manifestRaw);
102
+ }
103
+ catch {
104
+ return { ok: false, error: "manifest.json is not valid JSON" };
105
+ }
106
+ const manifestResult = manifestSchema.safeParse(manifestJson);
107
+ if (!manifestResult.success) {
108
+ return { ok: false, error: "manifest.json is malformed" };
109
+ }
110
+ const manifest = manifestResult.data;
111
+ const destDir = join(root, targetWorkspace);
112
+ const destExists = await stat(destDir).then(() => true, () => false);
113
+ // F40 — every added/updated file's {filename, timestamp}, so the newest
114
+ // one (by timestamp) can be reported back for the client's post-import
115
+ // POST /snapshots/load. Skipped files are deliberately excluded — nothing
116
+ // about them changed.
117
+ const changed = [];
118
+ if (mode === "create") {
119
+ if (destExists) {
120
+ return { ok: false, error: `workspace "${targetWorkspace}" already exists` };
121
+ }
122
+ await mkdir(destDir, { recursive: true });
123
+ let added = 0;
124
+ for (const filename of manifest.snapshots) {
125
+ if (!isValidSnapshotFilename(filename))
126
+ continue;
127
+ const srcPath = join(extractDir, filename);
128
+ let raw;
129
+ try {
130
+ raw = await readFile(srcPath, "utf-8");
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ await writeFile(join(destDir, filename), raw, "utf-8");
136
+ added++;
137
+ changed.push({ filename, timestamp: parseIdTimestamp(raw).timestamp });
138
+ }
139
+ return { ok: true, workspace: targetWorkspace, added, updated: 0, skipped: 0, newestFilename: pickNewestFilename(changed) };
140
+ }
141
+ // mode === "merge"
142
+ if (!destExists) {
143
+ return { ok: false, error: `workspace "${targetWorkspace}" does not exist` };
144
+ }
145
+ let added = 0;
146
+ let updated = 0;
147
+ let skipped = 0;
148
+ for (const filename of manifest.snapshots) {
149
+ if (!isValidSnapshotFilename(filename))
150
+ continue;
151
+ let raw;
152
+ try {
153
+ raw = await readFile(join(extractDir, filename), "utf-8");
154
+ }
155
+ catch {
156
+ continue;
157
+ }
158
+ const incoming = parseIdTimestamp(raw);
159
+ if (incoming.id === undefined) {
160
+ // Legacy/id-less (pre-v0.11) — always imported as new, never matched.
161
+ await writeFile(join(destDir, filename), raw, "utf-8");
162
+ added++;
163
+ changed.push({ filename, timestamp: incoming.timestamp });
164
+ continue;
165
+ }
166
+ const existing = await findSnapshotFileByIdInWorkspace(targetWorkspace, incoming.id, root);
167
+ if (existing === null) {
168
+ await writeFile(join(destDir, filename), raw, "utf-8");
169
+ added++;
170
+ changed.push({ filename, timestamp: incoming.timestamp });
171
+ continue;
172
+ }
173
+ const existingTimestamp = parseIdTimestamp(existing.raw).timestamp;
174
+ if (existingTimestamp === incoming.timestamp) {
175
+ skipped++;
176
+ continue;
177
+ }
178
+ if (existingTimestamp !== undefined && incoming.timestamp !== undefined && existingTimestamp > incoming.timestamp) {
179
+ // Destination already has the newer version — nothing to do.
180
+ skipped++;
181
+ continue;
182
+ }
183
+ // Incoming is newer — overwrite (at most one file per id afterward).
184
+ if (existing.filename !== filename) {
185
+ await unlink(join(destDir, existing.filename)).catch(() => { });
186
+ }
187
+ await writeFile(join(destDir, filename), raw, "utf-8");
188
+ updated++;
189
+ changed.push({ filename, timestamp: incoming.timestamp });
190
+ }
191
+ return { ok: true, workspace: targetWorkspace, added, updated, skipped, newestFilename: pickNewestFilename(changed) };
192
+ }
193
+ finally {
194
+ await rm(tempRoot, { recursive: true, force: true });
195
+ }
196
+ }
@@ -11,6 +11,7 @@ import { pathToFileURL } from "node:url";
11
11
  import { WebSocketServer } from "ws";
12
12
  import { createApp } from "./app.js";
13
13
  import { createMcpServer } from "./mcp.js";
14
+ import { parsePort } from "./port.js";
14
15
  import { addClient } from "./ws.js";
15
16
  // F27 (v1.0) — the server has no authentication on any endpoint (accepted
16
17
  // trade-off, see docs/02_assumptions-and-risks.md), so single-user/local-only
@@ -26,7 +27,7 @@ function assertLoopbackHost(host) {
26
27
  "Set ALLOW_NON_LOOPBACK=1 if you understand the risk and want to override this.");
27
28
  }
28
29
  export function startServer(options = {}) {
29
- const PORT = parseInt(process.env.PORT ?? "3000", 10);
30
+ const PORT = parsePort(process.env.PORT, 3000, "PORT");
30
31
  const HOST = process.env.HOST ?? "localhost";
31
32
  assertLoopbackHost(HOST);
32
33
  const app = createApp({ staticRoot: options.staticRoot });
@@ -4,10 +4,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { z } from "zod";
5
5
  import { clearCanvas, exportCanvas } from "./session.js";
6
6
  import { broadcast } from "./ws.js";
7
- import { nodeActionsSchema, nodeToFrameSchema, validateFrame } from "./validate.js";
7
+ import { nodeActionsSchema, nodeToFrameSchema, validateFrame, validateRenderPayload } from "./validate.js";
8
8
  import { getSnapshotsRoot } from "./paths.js";
9
9
  import { cancelSlideshow, startSlideshow } from "./slideshow.js";
10
10
  import { waitForClick, waitForDone } from "./interaction.js";
11
+ import { waitForQuiz } from "./quiz-submission.js";
11
12
  import { findSnapshotById, findSnapshotByIdInWorkspace, listSnapshots } from "./snapshot-reader.js";
12
13
  import { generateExportHtml } from "./export-html.js";
13
14
  import { appendFrameAndBroadcast, commitRenderResult, commitStepFramesResult, initStepFramesResult, seekAndBroadcast, stepAndBroadcast, validateWorkspaceInput, } from "./render-core.js";
@@ -25,18 +26,19 @@ export function createMcpServer() {
25
26
  ' • "html" — HTML fragment, styled with Bootstrap 5 (CSS-only build, always available — no need to link or import it). Use its component classes instead of hand-authored CSS: "card"/"card-body"/"card-title" for panels, "alert alert-info"/"alert-warning"/"alert-danger" for callouts, "badge bg-primary"/"bg-success" for tags, "table table-striped"/"table-bordered" for data tables. No Bootstrap JS is loaded — components that depend on it (dropdowns, modals, tooltips, popovers, collapses, carousels, offcanvas) render their static markup but are not interactive, so avoid them. Example: render({ type: "html", payload: "<div class="card"><div class="card-body"><h5 class="card-title">Hello</h5><span class="badge bg-success">Done</span></div></div>" })\n' +
26
27
  ' • "katex" — LaTeX string, rendered in display mode. Example: render({ type: "katex", payload: "E = mc^2" })\n' +
27
28
  ' • "vega-lite" — Vega-Lite JSON spec (must be valid JSON). Example: render({ type: "vega-lite", payload: "{"$schema":"...","mark":"bar",...}" })\n' +
29
+ ' • "quiz" — JSON quiz: { title?, questions: [{ id, text, kind: "single"|"multiple", options: [{ id, text, correct?, weight? }], scoring?: "all_or_nothing"|"partial", points? }] }. Either every question marks its correct option(s) (inline: the browser scores it) or none does (deferred). "partial" scoring (multiple-choice only) needs a weight on each correct option, summing to 1. Valid in render() only — not in slideshow() or step-frames.\n' +
28
30
  "render() is single-frame only. For a step-through sequence (multiple frames navigable via step()/seek()), use init_step_frames()/append_frame()/commit_step_frames() instead (see below).\n" +
29
31
  'options: { "workspace": "my-course", "title": "My diagram" }. workspace is REQUIRED — snapshot routing fails without it. title is optional.\n' +
30
32
  'options.workspace: workspace name for snapshot routing. Must be alphanumeric with dashes, underscores, dots, or spaces — no path separators. No env var fallback: always pass explicitly.\n' +
31
33
  'Example: render({ type: "mermaid", payload: "graph TD; A --> B", options: { workspace: "course_2", title: "System flow" } })',
32
34
  inputSchema: z.object({
33
35
  type: z
34
- .enum(["mermaid", "svg", "html", "katex", "vega-lite"])
36
+ .enum(["mermaid", "svg", "html", "katex", "vega-lite", "quiz"])
35
37
  .describe("Content type."),
36
38
  payload: z
37
39
  .string()
38
40
  .describe("The content source. For mermaid: must begin with a valid diagram keyword. " +
39
- "For vega-lite: must be valid JSON. For svg/html/katex: any string."),
41
+ "For vega-lite and quiz: must be valid JSON. For svg/html/katex: any string."),
40
42
  options: z
41
43
  .object({
42
44
  workspace: z.string().describe("REQUIRED. Workspace name for snapshot routing. " +
@@ -55,7 +57,7 @@ export function createMcpServer() {
55
57
  };
56
58
  }
57
59
  const { workspace } = workspaceResult;
58
- const validationError = await validateFrame({ type, payload });
60
+ const validationError = await validateRenderPayload(type, payload);
59
61
  if (validationError) {
60
62
  return {
61
63
  content: [{ type: "text", text: JSON.stringify({ ok: false, error: validationError }) }],
@@ -76,9 +78,9 @@ export function createMcpServer() {
76
78
  .enum(["next", "prev"])
77
79
  .describe('"next" to advance, "prev" to rewind.'),
78
80
  }),
79
- }, ({ direction }) => {
81
+ }, async ({ direction }) => {
80
82
  return {
81
- content: [{ type: "text", text: JSON.stringify(stepAndBroadcast(direction)) }],
83
+ content: [{ type: "text", text: JSON.stringify(await stepAndBroadcast(direction)) }],
82
84
  };
83
85
  });
84
86
  // seek(frame) — jump the step cursor to an arbitrary frame index.
@@ -90,9 +92,9 @@ export function createMcpServer() {
90
92
  inputSchema: z.object({
91
93
  frame: z.number().int().nonnegative().describe("Zero-based frame index to jump to."),
92
94
  }),
93
- }, ({ frame }) => {
95
+ }, async ({ frame }) => {
94
96
  return {
95
- content: [{ type: "text", text: JSON.stringify(seekAndBroadcast(frame)) }],
97
+ content: [{ type: "text", text: JSON.stringify(await seekAndBroadcast(frame)) }],
96
98
  };
97
99
  });
98
100
  // clear() — reset the canvas.
@@ -224,6 +226,19 @@ export function createMcpServer() {
224
226
  content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
225
227
  };
226
228
  });
229
+ // wait_quiz() — block until the user submits the quiz currently on the canvas.
230
+ server.registerTool("wait_quiz", {
231
+ description: 'Block until the user clicks Submit on the quiz currently on the whiteboard (render({ type: "quiz", ... })). ' +
232
+ 'Returns { "ok": true, "type": "submitted", "questions": <the quiz>, "answers": [{ "questionId": "...", "selected": ["optionId", ...] }] } ' +
233
+ "— for an inline quiz also a client-computed \"score\": { earned, possible }; for a deferred quiz (no answer key in the payload) you evaluate and score the answers yourself. " +
234
+ "A submission made before you call wait_quiz() is kept and returned immediately, as long as that quiz is still on screen. " +
235
+ 'Times out after 10 minutes with { "ok": true, "type": "timeout" }; a second wait_quiz() supersedes a pending one ({ "type": "superseded" }). ' +
236
+ 'Returns { "ok": false, "error": "..." } if no quiz is on the canvas. ' +
237
+ "Example flow: render({ type: \"quiz\", ... }) → wait_quiz() → evaluate answers",
238
+ inputSchema: z.object({}),
239
+ }, async () => ({
240
+ content: [{ type: "text", text: JSON.stringify(await waitForQuiz()) }],
241
+ }));
227
242
  // init_step_frames(frame_type, workspace, title?) — begin an incremental step-frames build.
228
243
  server.registerTool("init_step_frames", {
229
244
  description: "Begin a step-frames sequence — a step-through diagram built one frame at a time and navigable afterwards via step()/seek(). This is the only way to create a multi-frame sequence (render() is single-frame only).\n" +
@@ -317,10 +332,10 @@ export function createMcpServer() {
317
332
  id: z.string().optional().describe("Optional UUID returned by a previous render() or commit_step_frames() call. " +
318
333
  "When provided, retrieves that specific snapshot's payload instead of the current canvas state."),
319
334
  }),
320
- }, ({ id }) => {
335
+ }, async ({ id }) => {
321
336
  if (id !== undefined && id !== "") {
322
337
  const root = getSnapshotsRoot();
323
- const payload = findSnapshotById(id, root);
338
+ const payload = await findSnapshotById(id, root);
324
339
  if (payload === null) {
325
340
  return {
326
341
  content: [{ type: "text", text: JSON.stringify({ ok: false, error: "graph not found" }) }],
@@ -349,7 +364,7 @@ export function createMcpServer() {
349
364
  .describe("REQUIRED. Workspace name to list snapshots for. " +
350
365
  "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
351
366
  }),
352
- }, ({ workspace }) => {
367
+ }, async ({ workspace }) => {
353
368
  const workspaceResult = validateWorkspaceInput(workspace);
354
369
  if (!workspaceResult.ok) {
355
370
  return {
@@ -357,7 +372,7 @@ export function createMcpServer() {
357
372
  };
358
373
  }
359
374
  const root = getSnapshotsRoot();
360
- const snapshots = listSnapshots(workspaceResult.workspace, root);
375
+ const snapshots = await listSnapshots(workspaceResult.workspace, root);
361
376
  return {
362
377
  content: [{ type: "text", text: JSON.stringify({ ok: true, snapshots }) }],
363
378
  };
@@ -402,7 +417,7 @@ export function createMcpServer() {
402
417
  const root = getSnapshotsRoot();
403
418
  const validItems = [];
404
419
  for (const id of ids) {
405
- const record = findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
420
+ const record = await findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
406
421
  if (record !== null) {
407
422
  validItems.push({ workspace: validatedWorkspace, id, record });
408
423
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shared port-env-var validation (NF52) — used by index.ts's PORT and
3
+ * channel.ts/app.ts's CHANNEL_PORT, so an invalid value fails fast with a
4
+ * clear error instead of a low-level bind/listen/fetch failure.
5
+ *
6
+ * 0 is accepted (not just 1–65535): Node's listen()/serve() treat it as "let
7
+ * the OS assign a free port", which tests/unit/server/index.test.ts already
8
+ * relies on for ephemeral test binding.
9
+ */
10
+ export function parsePort(value, fallback, envVarName) {
11
+ if (value === undefined)
12
+ return fallback;
13
+ const port = Number(value);
14
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
15
+ throw new Error(`Invalid ${envVarName} "${value}": must be an integer between 0 and 65535.`);
16
+ }
17
+ return port;
18
+ }