serverless-ircd 0.5.0 → 0.7.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 (394) hide show
  1. package/.github/workflows/ci.yml +40 -3
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/.gitmodules +3 -0
  6. package/CHANGELOG.md +474 -0
  7. package/README.md +219 -22
  8. package/apps/aws-stack/README.md +33 -0
  9. package/apps/aws-stack/package.json +1 -1
  10. package/apps/aws-stack/src/aws-stack.ts +74 -9
  11. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  12. package/apps/aws-stack/tests/stack.test.ts +2 -2
  13. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  14. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  15. package/apps/aws-stack/vitest.config.ts +3 -0
  16. package/apps/cf-tcp-container/package.json +1 -1
  17. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  18. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  20. package/apps/cf-tcp-container/wrangler.toml +1 -1
  21. package/apps/cf-worker/package.json +2 -1
  22. package/apps/cf-worker/src/origin-allowlist.ts +99 -0
  23. package/apps/cf-worker/src/worker.ts +89 -9
  24. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +18 -0
  25. package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
  26. package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
  27. package/apps/cf-worker/tests/smoke.test.ts +28 -2
  28. package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
  29. package/apps/cf-worker/vitest.config.ts +1 -1
  30. package/apps/cf-worker/wrangler.test.toml +23 -0
  31. package/apps/cf-worker/wrangler.toml +59 -3
  32. package/apps/local-cli/package.json +1 -1
  33. package/apps/local-cli/src/config-loader.ts +6 -6
  34. package/apps/local-cli/src/main.ts +20 -0
  35. package/apps/local-cli/src/server.ts +9 -7
  36. package/apps/local-cli/tests/e2e.test.ts +112 -28
  37. package/apps/local-cli/tests/tcp.test.ts +71 -19
  38. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  39. package/apps/web/landing/index.html +217 -0
  40. package/apps/web/package.json +28 -0
  41. package/apps/web/scripts/build.mjs +125 -0
  42. package/apps/web/src/build-env.ts +43 -0
  43. package/apps/web/src/config-schema.ts +138 -0
  44. package/apps/web/static/config.json +28 -0
  45. package/apps/web/static/config.prod.json +28 -0
  46. package/apps/web/static/config.staging.json +28 -0
  47. package/apps/web/tests/build-env.test.ts +63 -0
  48. package/apps/web/tests/build-smoke.test.ts +103 -0
  49. package/apps/web/tests/config-schema.test.ts +432 -0
  50. package/apps/web/tests/workspace.test.ts +12 -0
  51. package/apps/web/tsconfig.json +10 -0
  52. package/apps/web/upstream/.browserslistrc +3 -0
  53. package/apps/web/upstream/.editorconfig +9 -0
  54. package/apps/web/upstream/.eslintignore +3 -0
  55. package/apps/web/upstream/.eslintrc.js +90 -0
  56. package/apps/web/upstream/.github/FUNDING.yml +12 -0
  57. package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  58. package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  59. package/apps/web/upstream/.github/workflows/push.yml +26 -0
  60. package/apps/web/upstream/.github/workflows/release.yml +33 -0
  61. package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
  62. package/apps/web/upstream/.prettierrc.js +9 -0
  63. package/apps/web/upstream/.stylelintignore +5 -0
  64. package/apps/web/upstream/.stylelintrc.js +21 -0
  65. package/apps/web/upstream/LICENSE +201 -0
  66. package/apps/web/upstream/README.md +76 -0
  67. package/apps/web/upstream/babel.config.js +25 -0
  68. package/apps/web/upstream/crowdin.yml +3 -0
  69. package/apps/web/upstream/index.html +35 -0
  70. package/apps/web/upstream/jest.config.js +7 -0
  71. package/apps/web/upstream/jsdoc.json +22 -0
  72. package/apps/web/upstream/package.json +80 -0
  73. package/apps/web/upstream/scripts/pre-commit +43 -0
  74. package/apps/web/upstream/src/components/App.vue +516 -0
  75. package/apps/web/upstream/src/components/AppSettings.vue +587 -0
  76. package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
  77. package/apps/web/upstream/src/components/Avatar.vue +105 -0
  78. package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
  79. package/apps/web/upstream/src/components/BufferKey.vue +121 -0
  80. package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
  81. package/apps/web/upstream/src/components/Captcha.vue +85 -0
  82. package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
  83. package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
  84. package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
  85. package/apps/web/upstream/src/components/ChannelList.vue +345 -0
  86. package/apps/web/upstream/src/components/Container.vue +401 -0
  87. package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
  88. package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
  89. package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
  90. package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
  91. package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
  92. package/apps/web/upstream/src/components/MessageList.vue +1028 -0
  93. package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
  94. package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
  95. package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
  96. package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
  97. package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
  98. package/apps/web/upstream/src/components/Nicklist.vue +309 -0
  99. package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
  100. package/apps/web/upstream/src/components/NotConnected.vue +271 -0
  101. package/apps/web/upstream/src/components/SelfUser.vue +283 -0
  102. package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
  103. package/apps/web/upstream/src/components/ServerView.vue +126 -0
  104. package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
  105. package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
  106. package/apps/web/upstream/src/components/Sidebar.vue +429 -0
  107. package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
  108. package/apps/web/upstream/src/components/SidebarState.vue +145 -0
  109. package/apps/web/upstream/src/components/StartupError.vue +35 -0
  110. package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
  111. package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
  112. package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
  113. package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
  114. package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
  115. package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
  116. package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
  117. package/apps/web/upstream/src/components/UserBox.vue +728 -0
  118. package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
  119. package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
  120. package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
  121. package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
  122. package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
  123. package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
  124. package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
  125. package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
  126. package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
  127. package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
  128. package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
  129. package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
  130. package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
  131. package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
  132. package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
  133. package/apps/web/upstream/src/helpers/Colours.js +128 -0
  134. package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
  135. package/apps/web/upstream/src/helpers/Md5.js +193 -0
  136. package/apps/web/upstream/src/helpers/Misc.js +401 -0
  137. package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
  138. package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
  139. package/apps/web/upstream/src/libs/AudioManager.js +60 -0
  140. package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
  141. package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
  142. package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
  143. package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
  144. package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
  145. package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
  146. package/apps/web/upstream/src/libs/IPC.js +51 -0
  147. package/apps/web/upstream/src/libs/InputHandler.js +921 -0
  148. package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
  149. package/apps/web/upstream/src/libs/Logger.js +71 -0
  150. package/apps/web/upstream/src/libs/Message.js +169 -0
  151. package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
  152. package/apps/web/upstream/src/libs/MessageParser.js +249 -0
  153. package/apps/web/upstream/src/libs/Notifications.js +78 -0
  154. package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
  155. package/apps/web/upstream/src/libs/ServerSession.js +102 -0
  156. package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
  157. package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
  158. package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
  159. package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
  160. package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
  161. package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
  162. package/apps/web/upstream/src/libs/bufferTools.js +177 -0
  163. package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
  164. package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
  165. package/apps/web/upstream/src/libs/settingTools.js +31 -0
  166. package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
  167. package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
  168. package/apps/web/upstream/src/libs/state/UserState.js +89 -0
  169. package/apps/web/upstream/src/libs/state/common.js +26 -0
  170. package/apps/web/upstream/src/libs/state.js +961 -0
  171. package/apps/web/upstream/src/libs/storage/Local.js +51 -0
  172. package/apps/web/upstream/src/main.js +566 -0
  173. package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
  174. package/apps/web/upstream/src/res/configTemplates.js +373 -0
  175. package/apps/web/upstream/src/res/globalStyle.css +277 -0
  176. package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
  177. package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
  178. package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
  179. package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
  180. package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
  181. package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
  182. package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
  183. package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
  184. package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
  185. package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
  186. package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
  187. package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
  188. package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
  189. package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
  190. package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
  191. package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
  192. package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
  193. package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
  194. package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
  195. package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
  196. package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
  197. package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
  198. package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
  199. package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
  200. package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
  201. package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
  202. package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
  203. package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
  204. package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
  205. package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
  206. package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
  207. package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
  208. package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
  209. package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
  210. package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
  211. package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
  212. package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
  213. package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
  214. package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
  215. package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
  216. package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
  217. package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
  218. package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
  219. package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
  220. package/apps/web/upstream/src/res/localesList.json +27 -0
  221. package/apps/web/upstream/src/res/logo.png +0 -0
  222. package/apps/web/upstream/src/thirdparty/about.html +43 -0
  223. package/apps/web/upstream/src/thirdparty/index.js +5 -0
  224. package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
  225. package/apps/web/upstream/static/config.json +28 -0
  226. package/apps/web/upstream/static/emoticons/smile.png +0 -0
  227. package/apps/web/upstream/static/favicon.png +0 -0
  228. package/apps/web/upstream/static/highlight.mp3 +0 -0
  229. package/apps/web/upstream/static/highlight.ogg +0 -0
  230. package/apps/web/upstream/static/locales/.gitignore +2 -0
  231. package/apps/web/upstream/static/plugins/customise.html.example +10 -0
  232. package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
  233. package/apps/web/upstream/static/themes/common/base.css +1064 -0
  234. package/apps/web/upstream/static/themes/dark/theme.css +196 -0
  235. package/apps/web/upstream/static/themes/default/theme.css +92 -0
  236. package/apps/web/upstream/static/themes/elite/theme.css +248 -0
  237. package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
  238. package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
  239. package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
  240. package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
  241. package/apps/web/upstream/static/themes/sky/theme.css +107 -0
  242. package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
  243. package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
  244. package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
  245. package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
  246. package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
  247. package/apps/web/upstream/vue.config.js +150 -0
  248. package/apps/web/upstream/yarn.lock +9767 -0
  249. package/apps/web/vitest.config.ts +13 -0
  250. package/biome.json +2 -0
  251. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  252. package/docs/ADR-002-location-of-authority.md +82 -0
  253. package/docs/ADR-003-durable-object-sharding.md +110 -0
  254. package/docs/ADR-004-dynamodb-schema.md +96 -0
  255. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  256. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  257. package/docs/ADR-007-deterministic-ports.md +82 -0
  258. package/docs/ADR-008-monorepo-tooling.md +60 -0
  259. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  260. package/docs/ADR-Index.md +15 -0
  261. package/docs/AWS-Adapter-Architecture.md +496 -0
  262. package/docs/AWS-Deployment.md +1275 -0
  263. package/docs/AWS-TCP-Deployment.md +410 -0
  264. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  265. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  266. package/docs/Home.md +16 -0
  267. package/docs/Observability.md +87 -0
  268. package/docs/PlanExtensions.md +812 -0
  269. package/docs/PlanIRCv3Websocket.md +503 -0
  270. package/docs/PlanWebClient.md +451 -0
  271. package/docs/Release-Process.md +443 -0
  272. package/docs/WebClientGuide.md +535 -0
  273. package/package.json +2 -2
  274. package/packages/aws-adapter/package.json +1 -1
  275. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  276. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  277. package/packages/aws-adapter/src/serialize.ts +29 -1
  278. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  279. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  280. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  281. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  282. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  283. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  284. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  285. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  286. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  287. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  288. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  289. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  290. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  291. package/packages/cf-adapter/package.json +2 -1
  292. package/packages/cf-adapter/src/connection-do.ts +3 -0
  293. package/packages/cf-adapter/src/serialize.ts +25 -4
  294. package/packages/cf-adapter/tests/cf-harness.ts +12 -1
  295. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  296. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  297. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  298. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  299. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  300. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  301. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  302. package/packages/cf-adapter/vitest.config.ts +1 -1
  303. package/packages/in-memory-runtime/package.json +1 -1
  304. package/packages/irc-core/package.json +1 -1
  305. package/packages/irc-core/reports/mutation/mutation.html +342 -0
  306. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  307. package/packages/irc-core/src/caps/index.ts +1 -0
  308. package/packages/irc-core/src/caps/sts.ts +84 -0
  309. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  310. package/packages/irc-core/src/commands/away.ts +9 -3
  311. package/packages/irc-core/src/commands/cap.ts +23 -5
  312. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  313. package/packages/irc-core/src/commands/index.ts +34 -0
  314. package/packages/irc-core/src/commands/isupport.ts +53 -0
  315. package/packages/irc-core/src/commands/mode.ts +8 -1
  316. package/packages/irc-core/src/commands/monitor.ts +327 -0
  317. package/packages/irc-core/src/commands/multiline.ts +256 -0
  318. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  319. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  320. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  321. package/packages/irc-core/src/commands/registration.ts +8 -0
  322. package/packages/irc-core/src/commands/sasl.ts +24 -1
  323. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  324. package/packages/irc-core/src/commands/whois.ts +5 -0
  325. package/packages/irc-core/src/config.ts +65 -10
  326. package/packages/irc-core/src/effects.ts +14 -0
  327. package/packages/irc-core/src/ports.ts +389 -0
  328. package/packages/irc-core/src/protocol/index.ts +14 -0
  329. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  330. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  331. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  332. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  333. package/packages/irc-core/src/state/connection.ts +32 -1
  334. package/packages/irc-core/src/types.ts +112 -0
  335. package/packages/irc-core/stryker.commands.conf.json +1 -2
  336. package/packages/irc-core/tests/away-store.test.ts +73 -0
  337. package/packages/irc-core/tests/batch.test.ts +15 -0
  338. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  339. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  340. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  341. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  342. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  343. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  344. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  345. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  346. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  347. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  348. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  349. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  350. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  351. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  352. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  353. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  354. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  355. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  356. package/packages/irc-core/tests/config.test.ts +103 -13
  357. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  358. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  359. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  360. package/packages/irc-core/tests/numerics.test.ts +13 -0
  361. package/packages/irc-core/tests/parser.test.ts +109 -0
  362. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  363. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  364. package/packages/irc-core/tests/types.test.ts +27 -0
  365. package/packages/irc-server/package.json +1 -1
  366. package/packages/irc-server/src/actor.ts +450 -3
  367. package/packages/irc-server/src/dispatch.ts +108 -16
  368. package/packages/irc-server/tests/actor.test.ts +1432 -39
  369. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  370. package/packages/irc-test-support/package.json +1 -1
  371. package/packages/irc-test-support/src/index.ts +6 -0
  372. package/packages/irc-test-support/src/scenarios.ts +24 -2
  373. package/packages/irc-test-support/src/test-config.ts +54 -0
  374. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  375. package/tools/ci-hardening/package.json +1 -1
  376. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  377. package/tools/load-test/package.json +33 -0
  378. package/tools/load-test/src/client.ts +351 -0
  379. package/tools/load-test/src/config.ts +313 -0
  380. package/tools/load-test/src/harness.ts +116 -0
  381. package/tools/load-test/src/main.ts +120 -0
  382. package/tools/load-test/src/metrics.ts +168 -0
  383. package/tools/load-test/src/report.ts +106 -0
  384. package/tools/load-test/tests/client.test.ts +212 -0
  385. package/tools/load-test/tests/config.test.ts +152 -0
  386. package/tools/load-test/tests/framing.test.ts +37 -0
  387. package/tools/load-test/tests/harness.test.ts +165 -0
  388. package/tools/load-test/tests/metrics.test.ts +174 -0
  389. package/tools/load-test/tests/report.test.ts +161 -0
  390. package/tools/load-test/tests/smoke.test.ts +67 -0
  391. package/tools/load-test/tsconfig.build.json +12 -0
  392. package/tools/load-test/tsconfig.test.json +10 -0
  393. package/tools/load-test/vitest.config.ts +29 -0
  394. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type AccountStore,
3
+ type AwayStore,
3
4
  CapturingLogger,
4
5
  type ChanName,
5
6
  type ChanSnapshot,
@@ -11,16 +12,21 @@ import {
11
12
  EmptyMotdProvider,
12
13
  FakeClock,
13
14
  type FloodControlConfig,
15
+ InMemoryAwayStore,
14
16
  InMemoryMessageStore,
17
+ InMemoryMonitorStore,
15
18
  InMemoryNickHistoryStore,
19
+ InMemoryReadMarkerStore,
16
20
  LogLevel,
17
21
  type Logger,
18
22
  type MessageStore,
23
+ type MonitorStore,
19
24
  type MtlsIdentityProvider,
20
25
  type Nick,
21
26
  type NickHistoryStore,
22
27
  Numerics,
23
28
  type RawLine,
29
+ type ReadMarkerStore,
24
30
  type SaslPayload,
25
31
  type SaslResult,
26
32
  SequentialIdFactory,
@@ -122,14 +128,27 @@ class FakeRuntime implements IrcRuntime {
122
128
  }
123
129
  async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
124
130
  this.calls.push({ method: 'reserveNick', args: [nick, conn] });
131
+ const key = nick.toLowerCase();
132
+ const existing = this.nicks.get(key);
133
+ if (existing !== undefined && existing !== conn) {
134
+ return { ok: false };
135
+ }
136
+ this.nicks.set(key, conn);
125
137
  return { ok: true };
126
138
  }
127
139
  async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
128
140
  this.calls.push({ method: 'changeNick', args: [conn, oldNick, newNick] });
141
+ const newKey = newNick.toLowerCase();
142
+ const oldKey = oldNick.toLowerCase();
143
+ const owner = this.nicks.get(newKey);
144
+ if (owner !== undefined && owner !== conn) return false;
145
+ this.nicks.set(newKey, conn);
146
+ if (this.nicks.get(oldKey) === conn) this.nicks.delete(oldKey);
129
147
  return true;
130
148
  }
131
149
  async releaseNick(nick: Nick): Promise<void> {
132
150
  this.calls.push({ method: 'releaseNick', args: [nick] });
151
+ this.nicks.delete(nick.toLowerCase());
133
152
  }
134
153
  async applyChannelDelta(chan: ChanName, delta: ChannelDelta): Promise<void> {
135
154
  this.calls.push({ method: 'applyChannelDelta', args: [chan, delta] });
@@ -206,6 +225,10 @@ function makeActor(opts: {
206
225
  operCreds?: ReadonlyArray<{ user: string; password: string }>;
207
226
  transport?: Transport;
208
227
  configSource?: string;
228
+ secure?: boolean;
229
+ monitor?: MonitorStore;
230
+ away?: AwayStore;
231
+ readMarkers?: ReadMarkerStore;
209
232
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
210
233
  const clock = opts.clock ?? new FakeClock(1_000);
211
234
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -274,6 +297,18 @@ function makeActor(opts: {
274
297
  if (opts.configSource !== undefined) {
275
298
  finalOpts.configSource = opts.configSource;
276
299
  }
300
+ if (opts.secure !== undefined) {
301
+ finalOpts.secure = opts.secure;
302
+ }
303
+ if (opts.monitor !== undefined) {
304
+ finalOpts.monitor = opts.monitor;
305
+ }
306
+ if (opts.away !== undefined) {
307
+ finalOpts.away = opts.away;
308
+ }
309
+ if (opts.readMarkers !== undefined) {
310
+ finalOpts.readMarkers = opts.readMarkers;
311
+ }
277
312
  const actor = new ConnectionActor(finalOpts);
278
313
  return { actor, runtime, conn };
279
314
  }
@@ -283,23 +318,75 @@ void isChannelTarget;
283
318
 
284
319
  describe('ConnectionActor — framing', () => {
285
320
  it('processes one PRIVMSG in a single text frame into one broadcast', async () => {
321
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
322
+ bobConn.nick = 'bob';
323
+ bobConn.user = 'bob';
324
+ bobConn.host = 'example.org';
325
+ bobConn.registration = 'registered';
286
326
  const { actor, runtime } = makeActor({});
327
+ runtime.addConnection(bobConn);
287
328
  // Pre-add the connection to the channel so the broadcast excludes the sender.
288
329
  await runtime.applyChannelDelta('#foo', {
289
- memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
330
+ memberships: [
331
+ { type: 'add', conn: 'c1', nick: 'alice' },
332
+ { type: 'add', conn: 'c2', nick: 'bob' },
333
+ ],
290
334
  });
291
335
  runtime.calls.length = 0;
292
336
 
293
337
  await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
294
338
 
295
- const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
296
- expect(broadcasts).toHaveLength(1);
297
- expect(broadcasts[0]?.args[0]).toBe('#foo');
298
- expect(broadcasts[0]?.args[2]).toBe('c1'); // except sender
299
- const lines = broadcasts[0]?.args[1] as RawLine[];
339
+ // Channel PRIVMSG fans out cap-split (msgid): dispatch resolves each
340
+ // member's caps and sends per-recipient. bob (no msgid cap) gets the bare
341
+ // line; the sender (c1) is excluded.
342
+ const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
343
+ expect(toBob).toHaveLength(1);
344
+ expect(toBob[0]?.args[0]).toBe('c2');
345
+ const lines = toBob[0]?.args[1] as RawLine[];
300
346
  expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
301
347
  });
302
348
 
349
+ it('delivers @msgid=<id> to a msgid-cap peer and the bare line to a non-cap peer', async () => {
350
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
351
+ bobConn.nick = 'bob';
352
+ bobConn.user = 'bob';
353
+ bobConn.host = 'example.org';
354
+ bobConn.registration = 'registered';
355
+ bobConn.caps = new Set<string>(['msgid']);
356
+ const carolConn = createConnection({ id: 'c3', connectedSince: 0 });
357
+ carolConn.nick = 'carol';
358
+ carolConn.user = 'carol';
359
+ carolConn.host = 'example.net';
360
+ carolConn.registration = 'registered';
361
+ const { actor, runtime } = makeActor({});
362
+ runtime.addConnection(bobConn);
363
+ runtime.addConnection(carolConn);
364
+ await runtime.applyChannelDelta('#foo', {
365
+ memberships: [
366
+ { type: 'add', conn: 'c1', nick: 'alice' },
367
+ { type: 'add', conn: 'c2', nick: 'bob' },
368
+ { type: 'add', conn: 'c3', nick: 'carol' },
369
+ ],
370
+ });
371
+ runtime.calls.length = 0;
372
+
373
+ await actor.receiveTextFrame('PRIVMSG #foo :hi\r\n');
374
+
375
+ // bob (msgid cap) receives the decorated line; carol (no cap) receives
376
+ // the bare line. The sender (c1) is excluded from both.
377
+ const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
378
+ const toCarol = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c3');
379
+ expect(toBob).toHaveLength(1);
380
+ expect(toCarol).toHaveLength(1);
381
+ const bobLine = (toBob[0]?.args[1] as RawLine[])[0]?.text ?? '';
382
+ // bob sees the msgid-tagged line; carol sees the bare line. The msgid
383
+ // value is opaque (an IdFactory nonce), so match the shape not the value.
384
+ expect(bobLine).toMatch(/^@msgid=nonce-\d+ :alice!alice@example\.com PRIVMSG #foo :hi$/);
385
+ expect((toCarol[0]?.args[1] as RawLine[])[0]?.text).toBe(
386
+ ':alice!alice@example.com PRIVMSG #foo :hi',
387
+ );
388
+ });
389
+
303
390
  it('tolerates a frame without a trailing CRLF', async () => {
304
391
  const { actor, runtime } = makeActor({});
305
392
  await actor.receiveTextFrame('PING :tok');
@@ -339,9 +426,18 @@ describe('ConnectionActor — framing', () => {
339
426
 
340
427
  describe('ConnectionActor — TCP byte-stream transport seam', () => {
341
428
  it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
429
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
430
+ bobConn.nick = 'bob';
431
+ bobConn.user = 'bob';
432
+ bobConn.host = 'example.org';
433
+ bobConn.registration = 'registered';
342
434
  const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
435
+ runtime.addConnection(bobConn);
343
436
  await runtime.applyChannelDelta('#foo', {
344
- memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
437
+ memberships: [
438
+ { type: 'add', conn: 'c1', nick: 'alice' },
439
+ { type: 'add', conn: 'c2', nick: 'bob' },
440
+ ],
345
441
  });
346
442
  runtime.calls.length = 0;
347
443
 
@@ -351,10 +447,10 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
351
447
  expect(runtime.calls).toHaveLength(0);
352
448
  await actor.receive('lo\r\n');
353
449
 
354
- const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
355
- expect(broadcasts).toHaveLength(1);
356
- expect(broadcasts[0]?.args[0]).toBe('#foo');
357
- const lines = broadcasts[0]?.args[1] as RawLine[];
450
+ // Exactly one fanout to bob (cap-split dispatch sends per-recipient).
451
+ const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
452
+ expect(toBob).toHaveLength(1);
453
+ const lines = toBob[0]?.args[1] as RawLine[];
358
454
  expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
359
455
  });
360
456
 
@@ -365,8 +461,17 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
365
461
  const wsSetup = makeActor({});
366
462
  const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
367
463
  for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
464
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
465
+ bobConn.nick = 'bob';
466
+ bobConn.user = 'bob';
467
+ bobConn.host = 'example.org';
468
+ bobConn.registration = 'registered';
469
+ rt.addConnection(bobConn);
368
470
  await rt.applyChannelDelta('#foo', {
369
- memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
471
+ memberships: [
472
+ { type: 'add', conn: 'c1', nick: 'alice' },
473
+ { type: 'add', conn: 'c2', nick: 'bob' },
474
+ ],
370
475
  });
371
476
  }
372
477
  wsSetup.runtime.calls.length = 0;
@@ -379,18 +484,30 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
379
484
  await tcpSetup.actor.receive(stream.slice(i, i + 4));
380
485
  }
381
486
 
382
- const wsBroadcast = wsSetup.runtime.calls.filter((c) => c.method === 'broadcast');
383
- const tcpBroadcast = tcpSetup.runtime.calls.filter((c) => c.method === 'broadcast');
384
- expect(tcpBroadcast).toHaveLength(wsBroadcast.length);
385
- expect((tcpBroadcast[0]?.args[1] as RawLine[])[0]?.text).toBe(
386
- (wsBroadcast[0]?.args[1] as RawLine[])[0]?.text,
487
+ // Both transports deliver exactly one fanout to bob with identical bytes.
488
+ const wsSends = wsSetup.runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
489
+ const tcpSends = tcpSetup.runtime.calls.filter(
490
+ (c) => c.method === 'send' && c.args[0] === 'c2',
491
+ );
492
+ expect(tcpSends).toHaveLength(wsSends.length);
493
+ expect((tcpSends[0]?.args[1] as RawLine[])[0]?.text).toBe(
494
+ (wsSends[0]?.args[1] as RawLine[])[0]?.text,
387
495
  );
388
496
  });
389
497
 
390
498
  it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
499
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
500
+ bobConn.nick = 'bob';
501
+ bobConn.user = 'bob';
502
+ bobConn.host = 'example.org';
503
+ bobConn.registration = 'registered';
391
504
  const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
505
+ runtime.addConnection(bobConn);
392
506
  await runtime.applyChannelDelta('#foo', {
393
- memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
507
+ memberships: [
508
+ { type: 'add', conn: 'c1', nick: 'alice' },
509
+ { type: 'add', conn: 'c2', nick: 'bob' },
510
+ ],
394
511
  });
395
512
  runtime.calls.length = 0;
396
513
 
@@ -400,13 +517,14 @@ describe('ConnectionActor — TCP byte-stream transport seam', () => {
400
517
  for (const ch of stream) {
401
518
  await actor.receive(ch);
402
519
  }
403
- const sends = runtime.calls.filter((c) => c.method === 'send');
404
- const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
405
- expect(sends).toHaveLength(2); // PONG :a, PONG :c
406
- expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
407
- expect((sends[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
408
- expect(broadcasts).toHaveLength(1); // PRIVMSG #foo :b
409
- expect((broadcasts[0]?.args[1] as RawLine[])[0]?.text).toBe(
520
+ const pongs = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
521
+ expect(pongs).toHaveLength(2); // PONG :a, PONG :c
522
+ expect((pongs[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
523
+ expect((pongs[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
524
+ // PRIVMSG fans out to bob exactly once (cap-split per-recipient send).
525
+ const toBob = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
526
+ expect(toBob).toHaveLength(1); // PRIVMSG #foo :b
527
+ expect((toBob[0]?.args[1] as RawLine[])[0]?.text).toBe(
410
528
  ':alice!alice@example.com PRIVMSG #foo :b',
411
529
  );
412
530
  });
@@ -531,6 +649,50 @@ describe('ConnectionActor — routing', () => {
531
649
  });
532
650
  });
533
651
 
652
+ describe('ConnectionActor — secure transport (user mode `S`)', () => {
653
+ it('marks the connection state secure when constructed with secure: true', () => {
654
+ const { conn } = makeActor({ secure: true });
655
+ expect(conn.secure).toBe(true);
656
+ });
657
+
658
+ it('defaults to insecure when secure is omitted', () => {
659
+ const { conn } = makeActor({});
660
+ expect(conn.secure).toBe(false);
661
+ });
662
+
663
+ it('sets user mode `S` at registration when constructed with secure: true', async () => {
664
+ const { actor, conn } = makeActor({ secure: true });
665
+ // Reset to pre-registration so the NICK/USER handshake drives welcome.
666
+ conn.registration = 'pre-registration';
667
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
668
+ delete conn.nick;
669
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
670
+ delete conn.user;
671
+
672
+ await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
673
+ await actor.receiveTextFrame('NICK alice\r\n');
674
+
675
+ expect(conn.registration).toBe('registered');
676
+ expect(conn.userModes.tls).toBe(true);
677
+ expect(conn.secure).toBe(true);
678
+ });
679
+
680
+ it('does not set user mode `S` when constructed without secure', async () => {
681
+ const { actor, conn } = makeActor({});
682
+ conn.registration = 'pre-registration';
683
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
684
+ delete conn.nick;
685
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
686
+ delete conn.user;
687
+
688
+ await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
689
+ await actor.receiveTextFrame('NICK alice\r\n');
690
+
691
+ expect(conn.registration).toBe('registered');
692
+ expect(conn.userModes.tls).toBe(false);
693
+ });
694
+ });
695
+
534
696
  describe('ConnectionActor — additional routing', () => {
535
697
  it('routes PART through routeMultiChannel (one channel per invocation)', async () => {
536
698
  const { actor, runtime } = makeActor({});
@@ -652,7 +814,9 @@ describe('ConnectionActor — additional routing', () => {
652
814
  });
653
815
  runtime.calls.length = 0;
654
816
  await actor.receiveTextFrame('NOTICE #foo :note\r\n');
655
- expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
817
+ // NOTICE channel fanout is cap-split (msgid) so dispatch resolves via
818
+ // getChannelSnapshot rather than the fast-path broadcast.
819
+ expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
656
820
  });
657
821
 
658
822
  it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
@@ -713,8 +877,8 @@ describe('ConnectionActor — additional routing', () => {
713
877
  });
714
878
  runtime.calls.length = 0;
715
879
  await actor.receiveTextFrame('PRIVMSG #foo,bob :hi\r\n');
716
- // One broadcast (channel) + one sendToNick (user).
717
- expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
880
+ // One channel fanout (cap-split → getChannelSnapshot) + one sendToNick.
881
+ expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
718
882
  expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(1);
719
883
  });
720
884
 
@@ -1136,7 +1300,15 @@ describe('ConnectionActor — additional routing', () => {
1136
1300
 
1137
1301
  it('routes WALLOPS from an oper through wallopsReducer (broadcastWallops, never 421)', async () => {
1138
1302
  const { actor, runtime } = makeActor({
1139
- conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
1303
+ conn: {
1304
+ userModes: {
1305
+ invisible: false,
1306
+ oper: true,
1307
+ wallops: false,
1308
+ serverNotices: false,
1309
+ tls: false,
1310
+ },
1311
+ },
1140
1312
  });
1141
1313
  runtime.calls.length = 0;
1142
1314
 
@@ -1180,7 +1352,15 @@ describe('ConnectionActor — additional routing', () => {
1180
1352
 
1181
1353
  it('routes WALLOPS with no message through wallopsReducer (412, never 421)', async () => {
1182
1354
  const { actor, runtime } = makeActor({
1183
- conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
1355
+ conn: {
1356
+ userModes: {
1357
+ invisible: false,
1358
+ oper: true,
1359
+ wallops: false,
1360
+ serverNotices: false,
1361
+ tls: false,
1362
+ },
1363
+ },
1184
1364
  });
1185
1365
  runtime.calls.length = 0;
1186
1366
 
@@ -1200,7 +1380,15 @@ describe('ConnectionActor — additional routing', () => {
1200
1380
 
1201
1381
  it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
1202
1382
  const { actor, runtime, conn } = makeActor({
1203
- conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
1383
+ conn: {
1384
+ userModes: {
1385
+ invisible: false,
1386
+ oper: true,
1387
+ wallops: false,
1388
+ serverNotices: false,
1389
+ tls: false,
1390
+ },
1391
+ },
1204
1392
  configSource: 'KV',
1205
1393
  });
1206
1394
  runtime.reloadConfigResult = {
@@ -1261,7 +1449,15 @@ describe('ConnectionActor — additional routing', () => {
1261
1449
 
1262
1450
  it('keeps the oper connected and retains the prior config when the loader rejects', async () => {
1263
1451
  const { actor, runtime } = makeActor({
1264
- conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
1452
+ conn: {
1453
+ userModes: {
1454
+ invisible: false,
1455
+ oper: true,
1456
+ wallops: false,
1457
+ serverNotices: false,
1458
+ tls: false,
1459
+ },
1460
+ },
1265
1461
  configSource: 'KV',
1266
1462
  });
1267
1463
  runtime.reloadConfigError = new Error('store unreachable');
@@ -1292,7 +1488,15 @@ describe('ConnectionActor — additional routing', () => {
1292
1488
 
1293
1489
  it('stringifies a non-Error rejection in the graceful 382 suffix', async () => {
1294
1490
  const { actor, runtime } = makeActor({
1295
- conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
1491
+ conn: {
1492
+ userModes: {
1493
+ invisible: false,
1494
+ oper: true,
1495
+ wallops: false,
1496
+ serverNotices: false,
1497
+ tls: false,
1498
+ },
1499
+ },
1296
1500
  configSource: 'KV',
1297
1501
  });
1298
1502
  // A non-Error throw exercises the `String(err)` branch of the suffix
@@ -1317,7 +1521,15 @@ describe('ConnectionActor — additional routing', () => {
1317
1521
  // sets the nick before OPER can succeed) but the actor must still
1318
1522
  // format a valid numeric addressed to "*" rather than crash.
1319
1523
  const { actor, runtime, conn } = makeActor({
1320
- conn: { userModes: { invisible: false, oper: true, wallops: false, serverNotices: false } },
1524
+ conn: {
1525
+ userModes: {
1526
+ invisible: false,
1527
+ oper: true,
1528
+ wallops: false,
1529
+ serverNotices: false,
1530
+ tls: false,
1531
+ },
1532
+ },
1321
1533
  configSource: 'KV',
1322
1534
  });
1323
1535
  // Clear the nick post-construction (exactOptionalPropertyTypes forbids
@@ -1606,10 +1818,11 @@ describe('ConnectionActor — flood control', () => {
1606
1818
  await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
1607
1819
  }
1608
1820
 
1609
- // Disabled → every frame reaches the inner reducer and broadcasts; the
1610
- // close path is never invoked.
1821
+ // Disabled → every frame reaches the inner reducer and fans out; the
1822
+ // close path is never invoked. Channel PRIVMSG dispatches cap-split
1823
+ // (msgid) via getChannelSnapshot, once per accepted message.
1611
1824
  expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1612
- expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(20);
1825
+ expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(20);
1613
1826
  });
1614
1827
 
1615
1828
  it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
@@ -1893,8 +2106,9 @@ describe('ConnectionActor — CHATHISTORY', () => {
1893
2106
 
1894
2107
  await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1895
2108
 
1896
- // Broadcast still happens (the message is delivered live)
1897
- expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
2109
+ // Broadcast still happens (the message is delivered live). Channel
2110
+ // PRIVMSG fans out cap-split (msgid) via getChannelSnapshot…
2111
+ expect(runtime.calls.filter((c) => c.method === 'getChannelSnapshot')).toHaveLength(1);
1898
2112
  // …but there is no store to record into.
1899
2113
  });
1900
2114
  });
@@ -2974,3 +3188,1182 @@ describe('ConnectionActor — WHOIS channel list', () => {
2974
3188
  }
2975
3189
  });
2976
3190
  });
3191
+
3192
+ // ============================================================================
3193
+ // MONITOR (IRCv3) — synchronous reducer + async push hook
3194
+ // ============================================================================
3195
+
3196
+ describe('ConnectionActor — MONITOR (synchronous reducer + async push hook)', () => {
3197
+ /**
3198
+ * Builds two actors (alice on c1, bob on c2) sharing a single
3199
+ * {@link InMemoryMonitorStore} and the same FakeRuntime. Mirrors the
3200
+ * production wiring where every ConnectionActor in a deployment shares
3201
+ * one monitor store.
3202
+ */
3203
+ function makeMonitorPair(): {
3204
+ alice: ConnectionActor;
3205
+ bob: ConnectionActor;
3206
+ runtime: FakeRuntime;
3207
+ aliceState: ConnectionState;
3208
+ bobState: ConnectionState;
3209
+ monitor: InMemoryMonitorStore;
3210
+ } {
3211
+ const clock = new FakeClock(1_000);
3212
+ const aliceState = createConnection({ id: 'c1', connectedSince: 0 });
3213
+ aliceState.nick = 'alice';
3214
+ aliceState.user = 'alice';
3215
+ aliceState.host = 'example.com';
3216
+ aliceState.realname = 'Alice';
3217
+ aliceState.registration = 'registered';
3218
+ const bobState = createConnection({ id: 'c2', connectedSince: 0 });
3219
+ bobState.user = 'bob';
3220
+ bobState.host = 'example.com';
3221
+ bobState.realname = 'Bob';
3222
+ // bob is pre-registration so we can drive him through NICK to test the
3223
+ // reserve-time 730 push.
3224
+ const runtime = new FakeRuntime(clock);
3225
+ runtime.addConnection(aliceState);
3226
+ const monitor = new InMemoryMonitorStore();
3227
+ const serverConfig: ServerConfig = {
3228
+ serverName: 'irc.example.com',
3229
+ networkName: 'ExampleNet',
3230
+ maxChannelsPerUser: 30,
3231
+ maxTargetsPerCommand: 10,
3232
+ maxListEntries: 50,
3233
+ nickLen: 30,
3234
+ channelLen: 50,
3235
+ topicLen: 390,
3236
+ quitMessage: 'Client Quit',
3237
+ };
3238
+ const ids = new SequentialIdFactory();
3239
+ type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
3240
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
3241
+ const aliceOpts: Mutable<ActorOpts> = {
3242
+ state: aliceState,
3243
+ runtime,
3244
+ channels: runtime,
3245
+ serverConfig,
3246
+ clock,
3247
+ ids,
3248
+ motd: EmptyMotdProvider,
3249
+ monitor,
3250
+ };
3251
+ const bobOpts: Mutable<ActorOpts> = {
3252
+ state: bobState,
3253
+ runtime,
3254
+ channels: runtime,
3255
+ serverConfig,
3256
+ clock,
3257
+ ids,
3258
+ motd: EmptyMotdProvider,
3259
+ monitor,
3260
+ };
3261
+ return {
3262
+ alice: new ConnectionActor(aliceOpts),
3263
+ bob: new ConnectionActor(bobOpts),
3264
+ runtime,
3265
+ aliceState,
3266
+ bobState,
3267
+ monitor,
3268
+ };
3269
+ }
3270
+
3271
+ it('MONITOR + offline nick emits 731 RPL_MONOFFLINE (synchronous path)', async () => {
3272
+ const { alice, runtime } = makeMonitorPair();
3273
+ runtime.calls.length = 0;
3274
+
3275
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3276
+
3277
+ const sends = runtime.calls.filter((c) => c.method === 'send');
3278
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3279
+ expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
3280
+ });
3281
+
3282
+ it('MONITOR + online nick emits 730 RPL_MONONLINE (synchronous path)', async () => {
3283
+ // Drive bob through the actor's NICK reservation so the dispatch hook
3284
+ // fires monitor.onNickOnline('bob'). Then alice's MONITOR + bob sees
3285
+ // him online via the runtime nick registry (resolveOnlineMap) and
3286
+ // emits 730 synchronously.
3287
+ const { alice, bob, runtime, bobState } = makeMonitorPair();
3288
+ runtime.addConnection(bobState);
3289
+ await bob.receiveTextFrame('NICK bob\r\n');
3290
+ runtime.calls.length = 0;
3291
+
3292
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3293
+
3294
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3295
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3296
+ expect(lines.some((l) => l.text === ':irc.example.com 730 alice :bob')).toBe(true);
3297
+ });
3298
+
3299
+ it('alice MONITOR + bob (offline) then bob connects → 730 pushed to alice', async () => {
3300
+ const { alice, bob, runtime, bobState } = makeMonitorPair();
3301
+ // alice subscribes to bob while bob is offline.
3302
+ runtime.calls.length = 0;
3303
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3304
+ // Sanity: 731 emitted for the offline state.
3305
+ {
3306
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3307
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3308
+ expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
3309
+ }
3310
+
3311
+ // bob now registers — NICK bob reserves the nick. The dispatch hook
3312
+ // fires monitor.onNickOnline('bob'), and bob's actor's post-dispatch
3313
+ // drain emits a Send to alice's connId.
3314
+ runtime.addConnection(bobState);
3315
+ runtime.calls.length = 0;
3316
+ await bob.receiveTextFrame('NICK bob\r\n');
3317
+
3318
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3319
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3320
+ expect(lines.some((l) => l.text === ':irc.example.com 730 alice :bob')).toBe(true);
3321
+ });
3322
+
3323
+ it('bob quits after being monitored → 731 pushed to alice', async () => {
3324
+ const { alice, bob, runtime, bobState } = makeMonitorPair();
3325
+ // Bring bob online through the actor so the dispatch hook fires
3326
+ // monitor.onNickOnline('bob') on reserveNick.
3327
+ runtime.addConnection(bobState);
3328
+ await bob.receiveTextFrame('NICK bob\r\n');
3329
+ runtime.calls.length = 0;
3330
+ // alice subscribes while bob is online.
3331
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3332
+ runtime.calls.length = 0;
3333
+
3334
+ await bob.receiveTextFrame('QUIT :gone\r\n');
3335
+
3336
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3337
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3338
+ expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
3339
+ });
3340
+
3341
+ it('unmatched MONITOR subcommand yields 461 (never 421)', async () => {
3342
+ const { alice, runtime } = makeMonitorPair();
3343
+ runtime.calls.length = 0;
3344
+
3345
+ await alice.receiveTextFrame('MONITOR Z\r\n');
3346
+
3347
+ const sends = runtime.calls.filter((c) => c.method === 'send');
3348
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3349
+ expect(lines.some((l) => l.text.includes(' 461 '))).toBe(true);
3350
+ for (const line of lines) {
3351
+ expect(line.text).not.toContain(' 421 ');
3352
+ }
3353
+ });
3354
+
3355
+ it('MONITOR L lists the watchlist then 733 terminator', async () => {
3356
+ const { alice, runtime } = makeMonitorPair();
3357
+ await alice.receiveTextFrame('MONITOR + bob,carol\r\n');
3358
+ runtime.calls.length = 0;
3359
+
3360
+ await alice.receiveTextFrame('MONITOR L\r\n');
3361
+
3362
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3363
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3364
+ expect(lines.some((l) => l.text === ':irc.example.com 732 alice :bob')).toBe(true);
3365
+ expect(lines.some((l) => l.text === ':irc.example.com 732 alice :carol')).toBe(true);
3366
+ expect(lines.some((l) => l.text === ':irc.example.com 733 alice :End of MONITOR list')).toBe(
3367
+ true,
3368
+ );
3369
+ });
3370
+
3371
+ it('MONITOR C empties the watchlist silently', async () => {
3372
+ const { alice, runtime, aliceState } = makeMonitorPair();
3373
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3374
+ expect(aliceState.monitorList?.has('bob')).toBe(true);
3375
+ runtime.calls.length = 0;
3376
+
3377
+ await alice.receiveTextFrame('MONITOR C\r\n');
3378
+
3379
+ expect(aliceState.monitorList?.size ?? 0).toBe(0);
3380
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3381
+ expect(sends).toHaveLength(0);
3382
+ });
3383
+
3384
+ it('works without a bound MonitorStore (synchronous path only, no push fanout)', async () => {
3385
+ // No `monitor:` option → the actor still routes the reducer but skips
3386
+ // the post-dispatch push drain. Covers the `this.monitor === undefined`
3387
+ // branches in routeMonitor and drainMonitorPushes.
3388
+ const { actor, runtime } = makeActor({});
3389
+ runtime.calls.length = 0;
3390
+
3391
+ await actor.receiveTextFrame('MONITOR + bob\r\n');
3392
+
3393
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3394
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3395
+ expect(lines.some((l) => l.text === ':irc.example.com 731 alice :bob')).toBe(true);
3396
+ });
3397
+
3398
+ it('skips a nick whose connection has torn down between lookupNick and getConnection (race)', async () => {
3399
+ // Covers the `conn?.nick !== undefined` false branch in
3400
+ // resolveOnlineMap: lookupNick resolves but getConnection returns null.
3401
+ const { alice, runtime, bobState } = makeMonitorPair();
3402
+ runtime.addConnection(bobState);
3403
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3404
+ runtime.calls.length = 0;
3405
+ // Drop bob's live connection while leaving the nick map intact so
3406
+ // lookupNick still resolves.
3407
+ runtime.dropConnection('c2');
3408
+
3409
+ await alice.receiveTextFrame('MONITOR S\r\n');
3410
+
3411
+ // 730 absent (bob no longer reachable); 731 present instead.
3412
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3413
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3414
+ expect(lines.some((l) => l.text.includes(' 731 '))).toBe(true);
3415
+ for (const line of lines) {
3416
+ expect(line.text).not.toContain(' 730 ');
3417
+ }
3418
+ });
3419
+
3420
+ it('skips a nick whose live connection has no nick set (defensive)', async () => {
3421
+ // Covers the `conn.nick !== undefined` false branch in resolveOnlineMap:
3422
+ // lookupNick resolves but the connection state at that id has no nick
3423
+ // (should not happen under correct runtime invariants — defensive).
3424
+ const { alice, runtime, bobState } = makeMonitorPair();
3425
+ runtime.addConnection(bobState);
3426
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3427
+ runtime.calls.length = 0;
3428
+ // Manually clear bob's nick while leaving the registry intact.
3429
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
3430
+ delete bobState.nick;
3431
+
3432
+ await alice.receiveTextFrame('MONITOR S\r\n');
3433
+
3434
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3435
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3436
+ for (const line of lines) {
3437
+ expect(line.text).not.toContain(' 730 ');
3438
+ }
3439
+ });
3440
+
3441
+ it('push to a watcher whose live connection has torn down uses * as nick placeholder', async () => {
3442
+ // Covers the `watcher?.nick ?? '*'` `??` branch in drainMonitorPushes:
3443
+ // getConnection returns null → watcherNick falls back to '*'.
3444
+ const { alice, bob, runtime, bobState } = makeMonitorPair();
3445
+ runtime.addConnection(bobState);
3446
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3447
+ runtime.calls.length = 0;
3448
+ // Drop alice's live connection so the post-QUIT drain cannot resolve
3449
+ // her nick. The push is still attempted; the wire line uses '*'.
3450
+ runtime.dropConnection('c1');
3451
+
3452
+ await bob.receiveTextFrame('NICK bob\r\n');
3453
+
3454
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3455
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3456
+ expect(lines.some((l) => l.text.includes(' 730 * :bob'))).toBe(true);
3457
+ });
3458
+
3459
+ it('reserveNick failure does not fan out a 730 (collision)', async () => {
3460
+ // Two actors racing for the same nick: the second reserve returns
3461
+ // { ok: false } so the dispatch handler must NOT call
3462
+ // monitor.onNickOnline. Covers the `if (result.ok)` false branch.
3463
+ const { alice, bob, runtime, bobState } = makeMonitorPair();
3464
+ runtime.addConnection(bobState);
3465
+ // alice subscribes to bob.
3466
+ await alice.receiveTextFrame('MONITOR + bob\r\n');
3467
+ runtime.calls.length = 0;
3468
+
3469
+ // Pre-reserve bob under alice's connId so bob's NICK collides.
3470
+ await runtime.reserveNick('bob', 'c1');
3471
+ void bob;
3472
+ await bob.receiveTextFrame('NICK bob\r\n');
3473
+
3474
+ // No 730 push should reach alice (the reserve failed).
3475
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3476
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3477
+ for (const line of lines) {
3478
+ expect(line.text).not.toContain(' 730 ');
3479
+ }
3480
+ });
3481
+
3482
+ it('changeNick failure does not fan out push effects', async () => {
3483
+ // Covers the `if (ok)` false branch in changeNickEtc: changeNick
3484
+ // returns false on collision; monitor hooks must NOT fire.
3485
+ const { alice, bob, runtime } = makeMonitorPair();
3486
+ // bob subscribes to alice; alice then tries NICK <occupied>.
3487
+ await bob.receiveTextFrame('MONITOR + alice\r\n');
3488
+ // Seed both nicks: alice and bob both registered.
3489
+ const bobState2 = createConnection({ id: 'c2', connectedSince: 0 });
3490
+ bobState2.nick = 'bob';
3491
+ bobState2.registration = 'registered';
3492
+ runtime.addConnection(bobState2);
3493
+ runtime.calls.length = 0;
3494
+
3495
+ // alice attempts NICK bob — bob is taken, changeNick fails.
3496
+ await alice.receiveTextFrame('NICK bob\r\n');
3497
+
3498
+ // No push to the bob watcher: changeNick failed.
3499
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
3500
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
3501
+ for (const line of lines) {
3502
+ expect(line.text).not.toMatch(/ 73[01] /u);
3503
+ }
3504
+ });
3505
+ });
3506
+
3507
+ // ===========================================================================
3508
+ // ConnectionActor — IRCv3 labeled-response
3509
+ //
3510
+ // A client tags an inbound message with `+label=<id>`; the server wraps every
3511
+ // reply caused by that message (addressed back to the requester) in a single
3512
+ // `BATCH +id labeled-response … BATCH -id`. No-reply requests emit an empty
3513
+ // batch so the client can still correlate the label.
3514
+ // ===========================================================================
3515
+
3516
+ describe('ConnectionActor — IRCv3 labeled-response', () => {
3517
+ /** Caps a labeled-response-capable client negotiates (batch is required). */
3518
+ const LABELED_CAPS = new Set<string>(['batch', 'labeled-response']);
3519
+
3520
+ /** echo-message + labeled caps — a client that sees its own echoed replies. */
3521
+ const ECHO_LABELED_CAPS = new Set<string>(['echo-message', 'batch', 'labeled-response']);
3522
+
3523
+ function makeOnlineConn(id: string, nick: string): ConnectionState {
3524
+ const c = createConnection({ id, connectedSince: 0 });
3525
+ c.nick = nick;
3526
+ c.user = nick;
3527
+ c.host = `${nick}.org`;
3528
+ c.realname = nick;
3529
+ c.registration = 'registered';
3530
+ return c;
3531
+ }
3532
+
3533
+ it('wraps the echo of a labeled PRIVMSG in a BATCH +label labeled-response frame', async () => {
3534
+ const { actor, runtime, conn } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
3535
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
3536
+ // Put alice on #foo so the channel PRIVMSG is accepted.
3537
+ await runtime.applyChannelDelta('#foo', {
3538
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
3539
+ });
3540
+ runtime.calls.length = 0;
3541
+ void conn;
3542
+
3543
+ await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
3544
+
3545
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3546
+ expect(toAlice).toHaveLength(1);
3547
+ const lines = toAlice[0]?.args[1] as RawLine[];
3548
+ expect(lines.map((l) => l.text)).toEqual([
3549
+ 'BATCH +foo labeled-response',
3550
+ ':alice!alice@example.com PRIVMSG #foo :hi',
3551
+ 'BATCH -foo',
3552
+ ]);
3553
+ });
3554
+
3555
+ it('emits an empty labeled batch when the labeled request produces no reply', async () => {
3556
+ // NOTICE (no echo-message) is fire-and-forget for the sender: only a
3557
+ // broadcast to peers, no Send back to the requester.
3558
+ const { actor, runtime } = makeActor({ conn: { caps: LABELED_CAPS } });
3559
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
3560
+ await runtime.applyChannelDelta('#foo', {
3561
+ memberships: [
3562
+ { type: 'add', conn: 'c1', nick: 'alice' },
3563
+ { type: 'add', conn: 'c2', nick: 'bob' },
3564
+ ],
3565
+ });
3566
+ runtime.calls.length = 0;
3567
+
3568
+ await actor.receiveTextFrame('@+label=empty NOTICE #foo :hi\r\n');
3569
+
3570
+ // The requester receives ONLY the empty labeled batch (start + end).
3571
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3572
+ expect(toAlice).toHaveLength(1);
3573
+ const lines = toAlice[0]?.args[1] as RawLine[];
3574
+ expect(lines.map((l) => l.text)).toEqual(['BATCH +empty labeled-response', 'BATCH -empty']);
3575
+ });
3576
+
3577
+ it('accumulates every reply to the requester under a single labeled batch', async () => {
3578
+ // Multi-target PRIVMSG with echo-message produces one echo Send-to-self
3579
+ // per target; all echoes must land inside ONE labeled batch.
3580
+ const { actor, runtime } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
3581
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
3582
+ runtime.addConnection(makeOnlineConn('c3', 'carol'));
3583
+ runtime.calls.length = 0;
3584
+
3585
+ await actor.receiveTextFrame('@+label=m PRIVMSG bob,carol :hi\r\n');
3586
+
3587
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3588
+ expect(toAlice).toHaveLength(1);
3589
+ const lines = toAlice[0]?.args[1] as RawLine[];
3590
+ expect(lines.map((l) => l.text)).toEqual([
3591
+ 'BATCH +m labeled-response',
3592
+ ':alice!alice@example.com PRIVMSG bob :hi',
3593
+ ':alice!alice@example.com PRIVMSG carol :hi',
3594
+ 'BATCH -m',
3595
+ ]);
3596
+ });
3597
+
3598
+ it('wraps an error numeric (403) in the labeled batch', async () => {
3599
+ // JOIN <invalid-name> (no # prefix) yields 403 ERR_NOSUCHCHANNEL, which
3600
+ // must still be wrapped so the requester can correlate the failure.
3601
+ const { actor, runtime } = makeActor({ conn: { caps: LABELED_CAPS } });
3602
+ runtime.calls.length = 0;
3603
+
3604
+ await actor.receiveTextFrame('@+label=e JOIN badname\r\n');
3605
+
3606
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3607
+ expect(toAlice).toHaveLength(1);
3608
+ const lines = toAlice[0]?.args[1] as RawLine[];
3609
+ expect(lines.map((l) => l.text)).toEqual([
3610
+ 'BATCH +e labeled-response',
3611
+ ':irc.example.com 403 alice badname :No such channel',
3612
+ 'BATCH -e',
3613
+ ]);
3614
+ });
3615
+
3616
+ it('does NOT wrap when the labeled-response cap is absent', async () => {
3617
+ const { actor, runtime } = makeActor({
3618
+ conn: { caps: new Set<string>(['echo-message', 'batch']) },
3619
+ });
3620
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
3621
+ await runtime.applyChannelDelta('#foo', {
3622
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
3623
+ });
3624
+ runtime.calls.length = 0;
3625
+
3626
+ await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
3627
+
3628
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3629
+ expect(toAlice).toHaveLength(1);
3630
+ const lines = toAlice[0]?.args[1] as RawLine[];
3631
+ // No batch markers — the echo is delivered bare.
3632
+ expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
3633
+ });
3634
+
3635
+ it('does NOT wrap when the batch cap is absent (labeled-response requires batch)', async () => {
3636
+ const { actor, runtime } = makeActor({
3637
+ conn: { caps: new Set<string>(['echo-message', 'labeled-response']) },
3638
+ });
3639
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
3640
+ await runtime.applyChannelDelta('#foo', {
3641
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
3642
+ });
3643
+ runtime.calls.length = 0;
3644
+
3645
+ await actor.receiveTextFrame('@+label=foo PRIVMSG #foo :hi\r\n');
3646
+
3647
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3648
+ expect(toAlice).toHaveLength(1);
3649
+ const lines = toAlice[0]?.args[1] as RawLine[];
3650
+ expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
3651
+ });
3652
+
3653
+ it('does NOT wrap an unlabeled PRIVMSG even when both caps are negotiated', async () => {
3654
+ const { actor, runtime } = makeActor({ conn: { caps: ECHO_LABELED_CAPS } });
3655
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
3656
+ await runtime.applyChannelDelta('#foo', {
3657
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
3658
+ });
3659
+ runtime.calls.length = 0;
3660
+
3661
+ await actor.receiveTextFrame('PRIVMSG #foo :hi\r\n');
3662
+
3663
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
3664
+ expect(toAlice).toHaveLength(1);
3665
+ const lines = toAlice[0]?.args[1] as RawLine[];
3666
+ expect(lines.map((l) => l.text)).toEqual([':alice!alice@example.com PRIVMSG #foo :hi']);
3667
+ });
3668
+ });
3669
+
3670
+ // ---------------------------------------------------------------------------
3671
+ // draft/multiline BATCH (multi-line batching)
3672
+ // ---------------------------------------------------------------------------
3673
+
3674
+ /**
3675
+ * Builds a channel with the supplied members and returns per-connection
3676
+ * capture filters. Mirrors the setup used by the framing / msgid tests.
3677
+ */
3678
+ async function setupMultilineChannel(
3679
+ runtime: FakeRuntime,
3680
+ members: Array<{ conn: ConnId; nick: string; caps?: ReadonlyArray<string> }>,
3681
+ ): Promise<void> {
3682
+ await runtime.applyChannelDelta('#foo', {
3683
+ memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
3684
+ });
3685
+ for (const m of members) {
3686
+ const conn = createConnection({ id: m.conn, connectedSince: 0 });
3687
+ conn.nick = m.nick;
3688
+ conn.user = m.nick;
3689
+ conn.host = `${m.nick}.example`;
3690
+ conn.registration = 'registered';
3691
+ if (m.caps !== undefined) conn.caps = new Set<string>(m.caps);
3692
+ runtime.addConnection(conn);
3693
+ }
3694
+ }
3695
+
3696
+ function sentTo(runtime: FakeRuntime, connId: ConnId): RawLine[] {
3697
+ return runtime.calls
3698
+ .filter((c) => c.method === 'send' && c.args[0] === connId)
3699
+ .flatMap((c) => c.args[1] as RawLine[]);
3700
+ }
3701
+
3702
+ describe('ConnectionActor — draft/multiline BATCH', () => {
3703
+ it('strips the +draft/multiline tag for a non-message-tags legacy peer', async () => {
3704
+ const { actor, runtime, conn } = makeActor({});
3705
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3706
+ await setupMultilineChannel(runtime, [
3707
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3708
+ { conn: 'c2', nick: 'bob' },
3709
+ ]);
3710
+ runtime.calls.length = 0;
3711
+
3712
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3713
+ await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
3714
+ await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
3715
+ await actor.receiveTextFrame('BATCH -m1\r\n');
3716
+
3717
+ // Bob has no caps → bare joined PRIVMSG, no `@+draft/multiline` prefix.
3718
+ const bobLines = sentTo(runtime, 'c2');
3719
+ expect(
3720
+ bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :line one\nline two'),
3721
+ ).toBe(true);
3722
+ expect(bobLines.some((l) => l.text.startsWith('@+draft/multiline'))).toBe(false);
3723
+ });
3724
+
3725
+ it('preserves the +draft/multiline tag for a message-tags-only peer', async () => {
3726
+ const { actor, runtime, conn } = makeActor({});
3727
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3728
+ await setupMultilineChannel(runtime, [
3729
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3730
+ { conn: 'c4', nick: 'dave', caps: ['message-tags'] },
3731
+ ]);
3732
+ runtime.calls.length = 0;
3733
+
3734
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3735
+ await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
3736
+ await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
3737
+ await actor.receiveTextFrame('BATCH -m1\r\n');
3738
+
3739
+ // Dave has message-tags but NOT draft/multiline → tagged joined PRIVMSG.
3740
+ const daveLines = sentTo(runtime, 'c4');
3741
+ expect(
3742
+ daveLines.some(
3743
+ (l) =>
3744
+ l.text === '@+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two',
3745
+ ),
3746
+ ).toBe(true);
3747
+ });
3748
+
3749
+ it('relays a 2-line batch to a multiline-capable peer as a BATCH frame', async () => {
3750
+ const { actor, runtime, conn } = makeActor({});
3751
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3752
+ await setupMultilineChannel(runtime, [
3753
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3754
+ { conn: 'c3', nick: 'carol', caps: ['batch', 'draft/multiline'] },
3755
+ ]);
3756
+ runtime.calls.length = 0;
3757
+
3758
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3759
+ await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
3760
+ await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
3761
+ await actor.receiveTextFrame('BATCH -m1\r\n');
3762
+
3763
+ // Multiline-capable carol receives the replayed BATCH framing. The batch
3764
+ // ref id is allocated from the shared id factory (offset by per-frame
3765
+ // trace ids), so match the shape rather than the exact id.
3766
+ const carolLines = sentTo(runtime, 'c3');
3767
+ const joined = carolLines.map((l) => l.text).join('|');
3768
+ expect(joined).toMatch(/BATCH \+batch-\d+ draft\/multiline #foo/);
3769
+ expect(joined).toContain(':alice!alice@example.com PRIVMSG #foo :line one');
3770
+ expect(joined).toContain(':alice!alice@example.com PRIVMSG #foo :line two');
3771
+ expect(joined).toMatch(/BATCH -batch-\d+/);
3772
+ // And she does NOT receive the legacy joined form.
3773
+ expect(carolLines.some((l) => l.text.startsWith('@+draft/multiline'))).toBe(false);
3774
+ });
3775
+
3776
+ it('rejects an over-budget batch with FAIL and relays nothing', async () => {
3777
+ const { actor, runtime, conn } = makeActor({});
3778
+ conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
3779
+ await setupMultilineChannel(runtime, [
3780
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3781
+ { conn: 'c2', nick: 'bob' },
3782
+ ]);
3783
+ runtime.calls.length = 0;
3784
+
3785
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3786
+ await actor.receiveTextFrame('PRIVMSG #foo :aaaaaaaaaa\r\n');
3787
+ await actor.receiveTextFrame('PRIVMSG #foo :bbbbbbbbbb\r\n');
3788
+ // Two 10-char lines => joined 10 + 1 + 10 = 21 bytes. The actor uses the
3789
+ // default 4096 budget; force a tiny budget via a config-override actor.
3790
+ await actor.receiveTextFrame('BATCH -m1\r\n');
3791
+
3792
+ // With the default budget this is accepted; assert no FAIL on this path.
3793
+ expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
3794
+ });
3795
+
3796
+ it('rejects an over-budget batch with FAIL when the configured budget is small', async () => {
3797
+ const { actor, runtime, conn } = makeActor({});
3798
+ conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
3799
+ // Override the actor's config to advertise a tiny multiline budget.
3800
+ (actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
3801
+ ...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
3802
+ multilineMaxBytes: 10,
3803
+ };
3804
+ await setupMultilineChannel(runtime, [
3805
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3806
+ { conn: 'c2', nick: 'bob' },
3807
+ ]);
3808
+ runtime.calls.length = 0;
3809
+
3810
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3811
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
3812
+ await actor.receiveTextFrame('PRIVMSG #foo :world\r\n');
3813
+ await actor.receiveTextFrame('BATCH -m1\r\n');
3814
+
3815
+ // Sender receives the FAIL; no broadcast to bob.
3816
+ const aliceLines = sentTo(runtime, 'c1');
3817
+ expect(
3818
+ aliceLines.some(
3819
+ (l) =>
3820
+ l.text ===
3821
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
3822
+ ),
3823
+ ).toBe(true);
3824
+ expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
3825
+ });
3826
+
3827
+ it('ignores a BATCH open from a client without the draft/multiline cap (421)', async () => {
3828
+ const { actor, runtime } = makeActor({});
3829
+ // Sender has batch but NOT draft/multiline.
3830
+ await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice', caps: ['batch'] }]);
3831
+ runtime.calls.length = 0;
3832
+
3833
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3834
+
3835
+ // Falls back to 421 ERR_UNKNOWNCOMMAND for an unsupported batch open.
3836
+ const aliceLines = sentTo(runtime, 'c1');
3837
+ expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
3838
+ });
3839
+
3840
+ it('ignores inner PRIVMSG while no multiline batch is open (normal delivery)', async () => {
3841
+ const { actor, runtime } = makeActor({});
3842
+ await setupMultilineChannel(runtime, [
3843
+ { conn: 'c1', nick: 'alice' },
3844
+ { conn: 'c2', nick: 'bob' },
3845
+ ]);
3846
+ runtime.calls.length = 0;
3847
+
3848
+ // A standalone PRIVMSG (no open batch) is delivered as a normal channel
3849
+ // message, not swallowed by the multiline accumulator.
3850
+ await actor.receiveTextFrame('PRIVMSG #foo :plain\r\n');
3851
+
3852
+ const bobLines = sentTo(runtime, 'c2');
3853
+ expect(bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :plain')).toBe(
3854
+ true,
3855
+ );
3856
+ });
3857
+
3858
+ it('falls back to 421 when closing a batch with an unknown id', async () => {
3859
+ const { actor, runtime, conn } = makeActor({});
3860
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3861
+ await setupMultilineChannel(runtime, [
3862
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3863
+ ]);
3864
+ runtime.calls.length = 0;
3865
+
3866
+ // No batch is open; `BATCH -nope` hits the unknown-id path.
3867
+ await actor.receiveTextFrame('BATCH -nope\r\n');
3868
+
3869
+ const aliceLines = sentTo(runtime, 'c1');
3870
+ expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
3871
+ });
3872
+
3873
+ it('aborts the batch with FAIL when an inner line targets a different channel', async () => {
3874
+ const { actor, runtime, conn } = makeActor({});
3875
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3876
+ await setupMultilineChannel(runtime, [
3877
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3878
+ ]);
3879
+ runtime.calls.length = 0;
3880
+
3881
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3882
+ await actor.receiveTextFrame('PRIVMSG #bar :wrong target\r\n');
3883
+
3884
+ const aliceLines = sentTo(runtime, 'c1');
3885
+ expect(
3886
+ aliceLines.some((l) => l.text.includes('FAIL BATCH MULTILINE_INVALID_TARGET #foo')),
3887
+ ).toBe(true);
3888
+ });
3889
+
3890
+ it('aborts the batch with FAIL when inner lines mix PRIVMSG and NOTICE', async () => {
3891
+ const { actor, runtime, conn } = makeActor({});
3892
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3893
+ await setupMultilineChannel(runtime, [
3894
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3895
+ ]);
3896
+ runtime.calls.length = 0;
3897
+
3898
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3899
+ await actor.receiveTextFrame('PRIVMSG #foo :first\r\n');
3900
+ await actor.receiveTextFrame('NOTICE #foo :mixed\r\n');
3901
+
3902
+ const aliceLines = sentTo(runtime, 'c1');
3903
+ expect(
3904
+ aliceLines.some((l) => l.text.includes('FAIL BATCH MULTILINE_MIXED_COMMANDS #foo')),
3905
+ ).toBe(true);
3906
+ });
3907
+
3908
+ it('falls back to 421 for a BATCH frame with a malformed marker', async () => {
3909
+ const { actor, runtime, conn } = makeActor({});
3910
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3911
+ runtime.calls.length = 0;
3912
+
3913
+ // Neither `+id` nor `-id` — the marker is a bare word.
3914
+ await actor.receiveTextFrame('BATCH bogus\r\n');
3915
+
3916
+ const aliceLines = sentTo(runtime, 'c1');
3917
+ expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
3918
+ });
3919
+
3920
+ it('falls back to 421 for a BATCH open with the wrong batch type', async () => {
3921
+ const { actor, runtime, conn } = makeActor({});
3922
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3923
+ runtime.calls.length = 0;
3924
+
3925
+ await actor.receiveTextFrame('BATCH +m1 netsplit :#foo\r\n');
3926
+
3927
+ const aliceLines = sentTo(runtime, 'c1');
3928
+ expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
3929
+ });
3930
+
3931
+ it('falls back to 421 when opening a second batch before the first closes', async () => {
3932
+ const { actor, runtime, conn } = makeActor({});
3933
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3934
+ await setupMultilineChannel(runtime, [
3935
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3936
+ ]);
3937
+ runtime.calls.length = 0;
3938
+
3939
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3940
+ // Re-open without closing the first.
3941
+ await actor.receiveTextFrame('BATCH +m2 draft/multiline :#foo\r\n');
3942
+
3943
+ const aliceLines = sentTo(runtime, 'c1');
3944
+ expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
3945
+ });
3946
+
3947
+ it('falls back to 421 for a BATCH frame with no params', async () => {
3948
+ const { actor, runtime, conn } = makeActor({});
3949
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3950
+ runtime.calls.length = 0;
3951
+
3952
+ await actor.receiveTextFrame('BATCH\r\n');
3953
+
3954
+ const aliceLines = sentTo(runtime, 'c1');
3955
+ expect(aliceLines.some((l) => l.text.includes(' 421 '))).toBe(true);
3956
+ });
3957
+
3958
+ it('accumulates an inner PRIVMSG with no trailing text as empty', async () => {
3959
+ const { actor, runtime, conn } = makeActor({});
3960
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
3961
+ await setupMultilineChannel(runtime, [
3962
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
3963
+ { conn: 'c2', nick: 'bob' },
3964
+ ]);
3965
+ runtime.calls.length = 0;
3966
+
3967
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
3968
+ // Inner PRIVMSG with no trailing parameter — exercises the `?? ''` fallback.
3969
+ await actor.receiveTextFrame('PRIVMSG #foo\r\n');
3970
+ await actor.receiveTextFrame('PRIVMSG #foo :real text\r\n');
3971
+ await actor.receiveTextFrame('BATCH -m1\r\n');
3972
+
3973
+ // The batch still finalizes; bob receives the joined content (empty + real).
3974
+ const bobLines = sentTo(runtime, 'c2');
3975
+ expect(bobLines.some((l) => l.text.includes('PRIVMSG #foo :'))).toBe(true);
3976
+ });
3977
+
3978
+ it('treats accumulateMultilineLine as a no-op when no batch is open (defensive guard)', () => {
3979
+ const { actor } = makeActor({});
3980
+ // route() only dispatches PRIVMSG/NOTICE here while a batch is open; the
3981
+ // method still guards defensively so a future caller cannot crash. Pin
3982
+ // that no-op contract by invoking the private method with no batch open.
3983
+ const result = (
3984
+ actor as unknown as {
3985
+ accumulateMultilineLine: (msg: { command: string; params: string[] }) => unknown[];
3986
+ }
3987
+ ).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
3988
+ expect(result).toEqual([]);
3989
+ });
3990
+ });
3991
+
3992
+ // ---------------------------------------------------------------------------
3993
+ // draft/multiline BATCH — nick-target routing
3994
+ //
3995
+ // A batch whose target is a nick (not a channel) must be delivered to that
3996
+ // nick only. The pure reducer is channel-focused (Broadcast); nick-target
3997
+ // delivery requires actor-side per-recipient cap resolution.
3998
+ // ---------------------------------------------------------------------------
3999
+
4000
+ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-marker)', () => {
4001
+ it('persists an AWAY reason to the bound AwayStore for an identified connection', async () => {
4002
+ const away = new InMemoryAwayStore();
4003
+ const { actor, conn } = makeActor({ away, conn: { account: 'alice' } });
4004
+
4005
+ await actor.receiveTextFrame('AWAY :gone fishing\r\n');
4006
+
4007
+ expect(conn.away).toBe('gone fishing');
4008
+ expect(away.get('alice')).toBe('gone fishing');
4009
+ });
4010
+
4011
+ it('does not persist AWAY to the store when the connection is unidentified', async () => {
4012
+ const away = new InMemoryAwayStore();
4013
+ const { actor, conn } = makeActor({ away });
4014
+
4015
+ await actor.receiveTextFrame('AWAY :gone fishing\r\n');
4016
+
4017
+ // In-memory session state still tracks the reason; the persisted store
4018
+ // stays empty because the connection has no account.
4019
+ expect(conn.away).toBe('gone fishing');
4020
+ expect(away.get('alice')).toBeUndefined();
4021
+ });
4022
+
4023
+ it('persists a +draft/read-marker TAGMSG to the bound ReadMarkerStore for an identified member', async () => {
4024
+ const readMarkers = new InMemoryReadMarkerStore();
4025
+ const { actor, runtime, conn } = makeActor({ readMarkers });
4026
+ conn.account = 'alice';
4027
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4028
+ await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
4029
+ runtime.calls.length = 0;
4030
+
4031
+ await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
4032
+
4033
+ expect(readMarkers.get('alice', '#foo')).toBe('msg-42');
4034
+ });
4035
+ });
4036
+
4037
+ describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
4038
+ function addRecipient(
4039
+ runtime: FakeRuntime,
4040
+ conn: ConnId,
4041
+ nick: string,
4042
+ caps?: ReadonlyArray<string>,
4043
+ ): ConnectionState {
4044
+ const state = createConnection({ id: conn, connectedSince: 0 });
4045
+ state.nick = nick;
4046
+ state.user = nick;
4047
+ state.host = `${nick}.example`;
4048
+ state.registration = 'registered';
4049
+ if (caps !== undefined) state.caps = new Set<string>(caps);
4050
+ runtime.addConnection(state);
4051
+ return state;
4052
+ }
4053
+
4054
+ it('delivers a nick-target batch to a multiline-capable recipient as a BATCH frame', async () => {
4055
+ const { actor, runtime, conn } = makeActor({});
4056
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4057
+ addRecipient(runtime, 'c2', 'bob', ['batch', 'draft/multiline']);
4058
+ runtime.calls.length = 0;
4059
+
4060
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4061
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4062
+ await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
4063
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4064
+
4065
+ const bobLines = sentTo(runtime, 'c2');
4066
+ const joined = bobLines.map((l) => l.text).join('|');
4067
+ expect(joined).toMatch(/BATCH \+batch-\d+ draft\/multiline bob/);
4068
+ expect(joined).toContain(':alice!alice@example.com PRIVMSG bob :line one');
4069
+ expect(joined).toContain(':alice!alice@example.com PRIVMSG bob :line two');
4070
+ expect(joined).toMatch(/BATCH -batch-\d+/);
4071
+ });
4072
+
4073
+ it('delivers a nick-target batch to a legacy recipient as a single joined PRIVMSG', async () => {
4074
+ const { actor, runtime, conn } = makeActor({});
4075
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4076
+ addRecipient(runtime, 'c2', 'bob');
4077
+ runtime.calls.length = 0;
4078
+
4079
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4080
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4081
+ await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
4082
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4083
+
4084
+ const bobLines = sentTo(runtime, 'c2');
4085
+ expect(
4086
+ bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG bob :line one\nline two'),
4087
+ ).toBe(true);
4088
+ });
4089
+
4090
+ it('delivers a nick-target batch to a message-tags recipient as a tagged joined PRIVMSG', async () => {
4091
+ const { actor, runtime, conn } = makeActor({});
4092
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4093
+ addRecipient(runtime, 'c2', 'bob', ['message-tags']);
4094
+ runtime.calls.length = 0;
4095
+
4096
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4097
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4098
+ await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
4099
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4100
+
4101
+ const bobLines = sentTo(runtime, 'c2');
4102
+ expect(
4103
+ bobLines.some(
4104
+ (l) =>
4105
+ l.text === '@+draft/multiline :alice!alice@example.com PRIVMSG bob :line one\nline two',
4106
+ ),
4107
+ ).toBe(true);
4108
+ });
4109
+
4110
+ it('does not broadcast a nick-target batch to other connections', async () => {
4111
+ const { actor, runtime, conn } = makeActor({});
4112
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4113
+ addRecipient(runtime, 'c2', 'bob', ['batch', 'draft/multiline']);
4114
+ addRecipient(runtime, 'c3', 'carol', ['batch', 'draft/multiline']);
4115
+ runtime.calls.length = 0;
4116
+
4117
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4118
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4119
+ await actor.receiveTextFrame('PRIVMSG bob :line two\r\n');
4120
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4121
+
4122
+ // Carol is NOT the nick target and must not receive the batch.
4123
+ const carolLines = sentTo(runtime, 'c3');
4124
+ expect(carolLines).toHaveLength(0);
4125
+ });
4126
+
4127
+ it('surfaces 401 ERR_NOSUCHNICK for a nick-target PRIVMSG when the recipient is offline', async () => {
4128
+ const { actor, runtime, conn } = makeActor({});
4129
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4130
+ // bob is NOT registered → lookupNick returns null.
4131
+ runtime.calls.length = 0;
4132
+
4133
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4134
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4135
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4136
+
4137
+ const aliceLines = sentTo(runtime, 'c1');
4138
+ expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(true);
4139
+ expect(aliceLines.some((l) => l.text.includes('bob'))).toBe(true);
4140
+ });
4141
+
4142
+ it('omits 401 for a nick-target NOTICE when the recipient is offline', async () => {
4143
+ const { actor, runtime, conn } = makeActor({});
4144
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4145
+ runtime.calls.length = 0;
4146
+
4147
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4148
+ await actor.receiveTextFrame('NOTICE bob :quiet one\r\n');
4149
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4150
+
4151
+ const aliceLines = sentTo(runtime, 'c1');
4152
+ expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(false);
4153
+ });
4154
+
4155
+ it('delivers nothing for an empty nick-target batch', async () => {
4156
+ const { actor, runtime, conn } = makeActor({});
4157
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4158
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
4159
+ bobConn.nick = 'bob';
4160
+ bobConn.user = 'bob';
4161
+ bobConn.host = 'bob.example';
4162
+ bobConn.registration = 'registered';
4163
+ bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
4164
+ runtime.addConnection(bobConn);
4165
+ runtime.calls.length = 0;
4166
+
4167
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4168
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4169
+
4170
+ expect(sentTo(runtime, 'c2')).toHaveLength(0);
4171
+ });
4172
+
4173
+ it('rejects an over-budget nick-target batch with FAIL', async () => {
4174
+ const { actor, runtime, conn } = makeActor({});
4175
+ conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
4176
+ (actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
4177
+ ...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
4178
+ multilineMaxBytes: 5,
4179
+ };
4180
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
4181
+ bobConn.nick = 'bob';
4182
+ bobConn.user = 'bob';
4183
+ bobConn.host = 'bob.example';
4184
+ bobConn.registration = 'registered';
4185
+ runtime.addConnection(bobConn);
4186
+ runtime.calls.length = 0;
4187
+
4188
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4189
+ await actor.receiveTextFrame('PRIVMSG bob :hello world\r\n');
4190
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4191
+
4192
+ const aliceLines = sentTo(runtime, 'c1');
4193
+ expect(
4194
+ aliceLines.some(
4195
+ (l) =>
4196
+ l.text ===
4197
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES bob :Multi-line batch exceeds the 5 byte limit',
4198
+ ),
4199
+ ).toBe(true);
4200
+ expect(sentTo(runtime, 'c2')).toHaveLength(0);
4201
+ });
4202
+
4203
+ it('surfaces 401 when the recipient connection is gone mid-flight', async () => {
4204
+ const { actor, runtime, conn } = makeActor({});
4205
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4206
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
4207
+ bobConn.nick = 'bob';
4208
+ bobConn.user = 'bob';
4209
+ bobConn.host = 'bob.example';
4210
+ bobConn.registration = 'registered';
4211
+ runtime.addConnection(bobConn);
4212
+ // Drop the connection AFTER nick registration so lookupNick resolves but
4213
+ // getConnection returns null (the "gone between the two awaits" race).
4214
+ runtime.dropConnection('c2');
4215
+ runtime.calls.length = 0;
4216
+
4217
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4218
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4219
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4220
+
4221
+ const aliceLines = sentTo(runtime, 'c1');
4222
+ expect(aliceLines.some((l) => l.text.includes(' 401 '))).toBe(true);
4223
+ });
4224
+
4225
+ it('uses "?" and "*" fallbacks when the sender has no nick', async () => {
4226
+ const { actor, runtime, conn } = makeActor({});
4227
+ // A connection that negotiated caps but has no NICK set — exercises the
4228
+ // hostmask `?? '?'` and numeric `?? '*'` fallbacks. `delete` is required
4229
+ // because `exactOptionalPropertyTypes` forbids `= undefined`.
4230
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
4231
+ delete conn.nick;
4232
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
4233
+ delete conn.user;
4234
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
4235
+ delete conn.host;
4236
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4237
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
4238
+ bobConn.nick = 'bob';
4239
+ bobConn.user = 'bob';
4240
+ bobConn.host = 'bob.example';
4241
+ bobConn.registration = 'registered';
4242
+ bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
4243
+ runtime.addConnection(bobConn);
4244
+ runtime.calls.length = 0;
4245
+
4246
+ // Online recipient → hostmask falls back to "?".
4247
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4248
+ await actor.receiveTextFrame('PRIVMSG bob :line one\r\n');
4249
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4250
+
4251
+ const bobLines = sentTo(runtime, 'c2');
4252
+ expect(bobLines.some((l) => l.text.startsWith(':? PRIVMSG bob :line one'))).toBe(true);
4253
+
4254
+ // Offline recipient → numeric falls back to "*".
4255
+ runtime.calls.length = 0;
4256
+ await actor.receiveTextFrame('BATCH +m2 draft/multiline :carol\r\n');
4257
+ await actor.receiveTextFrame('PRIVMSG carol :no recipient\r\n');
4258
+ await actor.receiveTextFrame('BATCH -m2\r\n');
4259
+
4260
+ const aliceLines = sentTo(runtime, 'c1');
4261
+ expect(aliceLines.some((l) => l.text.includes(' 401 * carol'))).toBe(true);
4262
+ });
4263
+ });
4264
+
4265
+ // ---------------------------------------------------------------------------
4266
+ // draft/multiline BATCH — chathistory recording
4267
+ //
4268
+ // A finalized multi-line batch must be recorded once into the bound
4269
+ // MessageStore so CHATHISTORY AFTER/BEFORE/LATEST return exactly one entry
4270
+ // per batch, carrying the joined body, the assigned msgid, and the
4271
+ // +draft/multiline client tag.
4272
+ // ---------------------------------------------------------------------------
4273
+
4274
+ describe('ConnectionActor — draft/multiline chathistory recording', () => {
4275
+ it('records the joined channel batch into the MessageStore', async () => {
4276
+ const store = new InMemoryMessageStore();
4277
+ const { actor, runtime, conn } = makeActor({ messages: store });
4278
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4279
+ await setupMultilineChannel(runtime, [
4280
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4281
+ ]);
4282
+ runtime.calls.length = 0;
4283
+
4284
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4285
+ await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
4286
+ await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
4287
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4288
+
4289
+ const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
4290
+ expect(recorded).toHaveLength(1);
4291
+ expect(recorded[0]?.text).toBe('line one\nline two');
4292
+ expect(recorded[0]?.command).toBe('PRIVMSG');
4293
+ expect(recorded[0]?.nick).toBe('alice');
4294
+ expect(recorded[0]?.msgid).toMatch(/^[A-Za-z0-9_-]+$/);
4295
+ });
4296
+
4297
+ it('CHATHISTORY replay carries the +draft/multiline tag on the joined body', async () => {
4298
+ const store = new InMemoryMessageStore();
4299
+ const chathistoryCaps = new Set<string>([
4300
+ 'batch',
4301
+ 'draft/multiline',
4302
+ 'draft/chathistory',
4303
+ 'server-time',
4304
+ 'message-tags',
4305
+ ]);
4306
+ const { actor, runtime, conn } = makeActor({ messages: store });
4307
+ conn.caps = chathistoryCaps;
4308
+ await setupMultilineChannel(runtime, [
4309
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4310
+ ]);
4311
+ runtime.calls.length = 0;
4312
+
4313
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4314
+ await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
4315
+ await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
4316
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4317
+ runtime.calls.length = 0;
4318
+
4319
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 1\r\n');
4320
+
4321
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4322
+ const lines = sends[0]?.args[1] as RawLine[];
4323
+ const replayLine = lines.find((l) => l.text.includes('PRIVMSG #foo'));
4324
+ expect(replayLine?.text).toContain('+draft/multiline');
4325
+ expect(replayLine?.text).toContain('line one\nline two');
4326
+ });
4327
+
4328
+ it('records the joined nick-target batch into the MessageStore', async () => {
4329
+ const store = new InMemoryMessageStore();
4330
+ const { actor, runtime, conn } = makeActor({ messages: store });
4331
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4332
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
4333
+ bobConn.nick = 'bob';
4334
+ bobConn.user = 'bob';
4335
+ bobConn.host = 'bob.example';
4336
+ bobConn.registration = 'registered';
4337
+ bobConn.caps = new Set<string>(['batch', 'draft/multiline']);
4338
+ runtime.addConnection(bobConn);
4339
+ runtime.calls.length = 0;
4340
+
4341
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :bob\r\n');
4342
+ await actor.receiveTextFrame('PRIVMSG bob :pm one\r\n');
4343
+ await actor.receiveTextFrame('PRIVMSG bob :pm two\r\n');
4344
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4345
+
4346
+ const recorded = store.query({ chan: 'bob', direction: 'latest', limit: 10 });
4347
+ expect(recorded).toHaveLength(1);
4348
+ expect(recorded[0]?.text).toBe('pm one\npm two');
4349
+ });
4350
+
4351
+ it('does not record when no MessageStore is bound', async () => {
4352
+ const { actor, runtime, conn } = makeActor({});
4353
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4354
+ await setupMultilineChannel(runtime, [
4355
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4356
+ ]);
4357
+ runtime.calls.length = 0;
4358
+
4359
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4360
+ await actor.receiveTextFrame('PRIVMSG #foo :line one\r\n');
4361
+ await actor.receiveTextFrame('PRIVMSG #foo :line two\r\n');
4362
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4363
+
4364
+ // No crash, no store — the delivery still works (broadcast happened).
4365
+ expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
4366
+ // Cap-split path emits a send to bob via getChannelSnapshot…
4367
+ expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
4368
+ });
4369
+ });