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
@@ -14,23 +14,44 @@ describe('SUPPORTED_CAPABILITIES', () => {
14
14
  const names = SUPPORTED_CAPABILITIES.map((c) => c.name).sort();
15
15
  expect(names).toEqual(
16
16
  [
17
+ 'account-notify',
17
18
  'account-tag',
18
19
  'away-notify',
19
20
  'batch',
20
21
  'chghost',
21
22
  'draft/chathistory',
23
+ 'draft/multiline',
24
+ 'draft/pre-away',
25
+ 'draft/read-marker',
26
+ 'draft/typing',
22
27
  'echo-message',
23
28
  'extended-join',
24
29
  'invite-notify',
30
+ 'labeled-response',
25
31
  'message-tags',
32
+ 'msgid',
33
+ 'monitor',
26
34
  'multi-prefix',
27
35
  'safelist',
28
36
  'sasl',
29
37
  'server-time',
38
+ 'standard-replies',
30
39
  ].sort(),
31
40
  );
32
41
  });
33
42
 
43
+ it('advertises draft/typing as a bare name (no value parameter)', () => {
44
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/typing');
45
+ expect(cap).toBeDefined();
46
+ expect(cap?.value).toBeUndefined();
47
+ });
48
+
49
+ it('advertises the standard-replies cap as a bare name (no value parameter)', () => {
50
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'standard-replies');
51
+ expect(cap).toBeDefined();
52
+ expect(cap?.value).toBeUndefined();
53
+ });
54
+
34
55
  it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
35
56
  const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
36
57
  expect(sasl).toBeDefined();
@@ -38,9 +59,11 @@ describe('SUPPORTED_CAPABILITIES', () => {
38
59
  });
39
60
 
40
61
  it('does not attach a value to caps that have no parameter', () => {
41
- // sasl is the only cap with a value.
62
+ // sasl and draft/multiline are the caps with a value parameter.
63
+ const withValue = SUPPORTED_CAPABILITIES.filter((c) => c.value !== undefined);
64
+ expect(withValue.map((c) => c.name).sort()).toEqual(['draft/multiline', 'sasl']);
42
65
  const noValue = SUPPORTED_CAPABILITIES.filter((c) => c.value === undefined);
43
- expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length - 1);
66
+ expect(noValue.length).toBe(SUPPORTED_CAPABILITIES.length - withValue.length);
44
67
  });
45
68
 
46
69
  it('advertises safelist as a bare name (no value parameter)', () => {
@@ -134,6 +157,100 @@ describe('draft/chathistory advertisement', () => {
134
157
  });
135
158
  });
136
159
 
160
+ describe('msgid advertisement', () => {
161
+ it('is advertised as a bare name (no value parameter)', () => {
162
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'msgid');
163
+ expect(cap).toBeDefined();
164
+ expect(cap?.value).toBeUndefined();
165
+ });
166
+
167
+ it('appears in CAP LS output as the bare token', () => {
168
+ expect(getLsString()).toContain('msgid');
169
+ expect(getLsString()).not.toContain('msgid=');
170
+ });
171
+
172
+ it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
173
+ expect(SUPPORTED_CAP_NAMES.has('msgid')).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe('draft/typing advertisement', () => {
178
+ it('appears in CAP LS output as the bare token', () => {
179
+ expect(getLsString()).toContain('draft/typing');
180
+ expect(getLsString()).not.toContain('draft/typing=');
181
+ });
182
+
183
+ it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
184
+ expect(SUPPORTED_CAP_NAMES.has('draft/typing')).toBe(true);
185
+ });
186
+
187
+ it('is recognized by isSupportedCap', () => {
188
+ expect(isSupportedCap('draft/typing')).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe('draft/read-marker advertisement', () => {
193
+ it('is advertised as a bare name (no value parameter)', () => {
194
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/read-marker');
195
+ expect(cap).toBeDefined();
196
+ expect(cap?.value).toBeUndefined();
197
+ });
198
+
199
+ it('appears in CAP LS output as the bare token', () => {
200
+ expect(getLsString()).toContain('draft/read-marker');
201
+ expect(getLsString()).not.toContain('draft/read-marker=');
202
+ });
203
+
204
+ it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
205
+ expect(SUPPORTED_CAP_NAMES.has('draft/read-marker')).toBe(true);
206
+ });
207
+
208
+ it('is recognized by isSupportedCap', () => {
209
+ expect(isSupportedCap('draft/read-marker')).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe('draft/pre-away advertisement', () => {
214
+ it('is advertised as a bare name (no value parameter)', () => {
215
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/pre-away');
216
+ expect(cap).toBeDefined();
217
+ expect(cap?.value).toBeUndefined();
218
+ });
219
+
220
+ it('appears in CAP LS output as the bare token', () => {
221
+ expect(getLsString()).toContain('draft/pre-away');
222
+ expect(getLsString()).not.toContain('draft/pre-away=');
223
+ });
224
+
225
+ it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
226
+ expect(SUPPORTED_CAP_NAMES.has('draft/pre-away')).toBe(true);
227
+ });
228
+
229
+ it('is recognized by isSupportedCap', () => {
230
+ expect(isSupportedCap('draft/pre-away')).toBe(true);
231
+ });
232
+ });
233
+
234
+ describe('draft/multiline advertisement', () => {
235
+ it('is advertised with the default byte budget value', () => {
236
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/multiline');
237
+ expect(cap).toBeDefined();
238
+ expect(cap?.value).toBe('4096');
239
+ });
240
+
241
+ it('appears in CAP LS output as the name=4096 token', () => {
242
+ expect(getLsString()).toContain('draft/multiline=4096');
243
+ });
244
+
245
+ it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
246
+ expect(SUPPORTED_CAP_NAMES.has('draft/multiline')).toBe(true);
247
+ });
248
+
249
+ it('is recognized by isSupportedCap', () => {
250
+ expect(isSupportedCap('draft/multiline')).toBe(true);
251
+ });
252
+ });
253
+
137
254
  describe('splitCapsForWire', () => {
138
255
  it('returns a single chunk when everything fits', () => {
139
256
  const tokens = ['a', 'b', 'c'];
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ DEFAULT_STS_DURATION_SECONDS,
4
+ DEFAULT_STS_PORT,
5
+ STS_CAP_NAME,
6
+ stsCapValue,
7
+ } from '../../src/caps/sts';
8
+ import type { StsPolicy } from '../../src/caps/sts';
9
+
10
+ /**
11
+ * IRCv3 `sts` (Strict Transport Security) policy builder.
12
+ *
13
+ * Spec: https://ircv3.net/specs/extensions/sts
14
+ *
15
+ * On a plaintext connection the server advertises the full policy
16
+ * (`duration=…,port=…` plus optional `preload`) so compliant clients
17
+ * reconnect over TLS. On a TLS connection only `duration` is sent —
18
+ * the port is meaningless once the client is already on the secure
19
+ * listener, and `preload` is only meaningful as an upgrade directive.
20
+ */
21
+ describe('stsCapValue', () => {
22
+ describe('STS_CAP_NAME', () => {
23
+ it('exposes the IRCv3 wire name', () => {
24
+ expect(STS_CAP_NAME).toBe('sts');
25
+ });
26
+ });
27
+
28
+ describe('plaintext connection (secure=false)', () => {
29
+ it('builds duration+port from the supplied config', () => {
30
+ const cfg: StsPolicy = { duration: 600, port: 6697 };
31
+ expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
32
+ });
33
+
34
+ it('appends `preload` when preload=true', () => {
35
+ const cfg: StsPolicy = { duration: 600, port: 6697, preload: true };
36
+ expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697,preload');
37
+ });
38
+
39
+ it('omits `preload` when preload=false', () => {
40
+ const cfg: StsPolicy = { duration: 600, port: 6697, preload: false };
41
+ expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
42
+ });
43
+
44
+ it('omits `preload` when preload is undefined', () => {
45
+ const cfg: StsPolicy = { duration: 600, port: 6697 };
46
+ expect(stsCapValue(cfg, false)).toBe('duration=600,port=6697');
47
+ });
48
+
49
+ it('honours an overridden port', () => {
50
+ const cfg: StsPolicy = { duration: 600, port: 7000 };
51
+ expect(stsCapValue(cfg, false)).toBe('duration=600,port=7000');
52
+ });
53
+
54
+ it('honours an overridden duration', () => {
55
+ const cfg: StsPolicy = { duration: 86_400, port: 6697 };
56
+ expect(stsCapValue(cfg, false)).toBe('duration=86400,port=6697');
57
+ });
58
+ });
59
+
60
+ describe('TLS connection (secure=true)', () => {
61
+ it('advertises only the duration (port is meaningless on TLS)', () => {
62
+ const cfg: StsPolicy = { duration: 600, port: 6697 };
63
+ expect(stsCapValue(cfg, true)).toBe('duration=600');
64
+ });
65
+
66
+ it('omits preload on TLS (upgrade-only directive)', () => {
67
+ const cfg: StsPolicy = { duration: 600, port: 6697, preload: true };
68
+ expect(stsCapValue(cfg, true)).toBe('duration=600');
69
+ });
70
+
71
+ it('honours an overridden duration on TLS', () => {
72
+ const cfg: StsPolicy = { duration: 86_400, port: 6697 };
73
+ expect(stsCapValue(cfg, true)).toBe('duration=86400');
74
+ });
75
+ });
76
+
77
+ describe('disabled (no policy configured)', () => {
78
+ it('returns undefined when config is undefined', () => {
79
+ expect(stsCapValue(undefined, false)).toBeUndefined();
80
+ });
81
+
82
+ it('returns undefined when config is undefined on a TLS connection', () => {
83
+ expect(stsCapValue(undefined, true)).toBeUndefined();
84
+ });
85
+ });
86
+
87
+ describe('field defaults', () => {
88
+ it('falls back to DEFAULT_STS_DURATION_SECONDS when duration is omitted (plaintext)', () => {
89
+ const value = stsCapValue({ port: 6697 }, false);
90
+ expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS},port=6697`);
91
+ });
92
+
93
+ it('falls back to DEFAULT_STS_DURATION_SECONDS when duration is omitted (TLS)', () => {
94
+ const value = stsCapValue({ port: 6697 }, true);
95
+ expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS}`);
96
+ });
97
+
98
+ it('falls back to DEFAULT_STS_PORT when port is omitted (plaintext)', () => {
99
+ const value = stsCapValue({ duration: 600 }, false);
100
+ expect(value).toBe(`duration=600,port=${DEFAULT_STS_PORT}`);
101
+ });
102
+
103
+ it('uses both defaults when only an empty object is supplied (plaintext)', () => {
104
+ const value = stsCapValue({}, false);
105
+ expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS},port=${DEFAULT_STS_PORT}`);
106
+ });
107
+
108
+ it('uses both defaults when only an empty object is supplied (TLS)', () => {
109
+ const value = stsCapValue({}, true);
110
+ expect(value).toBe(`duration=${DEFAULT_STS_DURATION_SECONDS}`);
111
+ });
112
+ });
113
+
114
+ describe('defaults', () => {
115
+ it('exposes 6697 as the canonical IRC-over-TLS port', () => {
116
+ expect(DEFAULT_STS_PORT).toBe(6697);
117
+ });
118
+
119
+ it('exposes a positive duration default (seconds)', () => {
120
+ expect(DEFAULT_STS_DURATION_SECONDS).toBeGreaterThan(0);
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { emitAccountNotify } from '../../src/commands/account-notify';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
6
+
7
+ const L = (text: string): RawLine => ({ text });
8
+
9
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
10
+ const s = createConnection({ id, connectedSince: 0 });
11
+ s.nick = nick;
12
+ s.user = 'alice';
13
+ s.host = 'example.com';
14
+ s.realname = 'Alice';
15
+ s.registration = 'registered';
16
+ return s;
17
+ }
18
+
19
+ describe('emitAccountNotify — login', () => {
20
+ it('emits a cap-split Broadcast per joined channel with the account name', () => {
21
+ const conn = makeConn();
22
+ conn.joinedChannels.add('#foo');
23
+ conn.joinedChannels.add('#bar');
24
+
25
+ const effects = emitAccountNotify({ conn, account: 'alice' });
26
+
27
+ expect(effects).toEqual<EffectType[]>([
28
+ Effect.broadcast('#foo', [], 'c1', 'account-notify', [
29
+ L(':alice!alice@example.com ACCOUNT alice'),
30
+ ]),
31
+ Effect.broadcast('#bar', [], 'c1', 'account-notify', [
32
+ L(':alice!alice@example.com ACCOUNT alice'),
33
+ ]),
34
+ ]);
35
+ });
36
+
37
+ it('excludes the sender (except) so non-echo peers do not see their own ACCOUNT', () => {
38
+ const conn = makeConn();
39
+ conn.joinedChannels.add('#foo');
40
+
41
+ const effects = emitAccountNotify({ conn, account: 'alice' });
42
+
43
+ const bcast = effects[0];
44
+ if (bcast?.tag === 'Broadcast') {
45
+ expect(bcast.except).toBe('c1');
46
+ }
47
+ });
48
+
49
+ it('uses empty legacy lines so non-cap peers receive nothing', () => {
50
+ const conn = makeConn();
51
+ conn.joinedChannels.add('#foo');
52
+
53
+ const effects = emitAccountNotify({ conn, account: 'alice' });
54
+
55
+ const bcast = effects[0];
56
+ if (bcast?.tag === 'Broadcast') {
57
+ expect(bcast.lines).toEqual([]);
58
+ expect(bcast.cap).toBe('account-notify');
59
+ }
60
+ });
61
+ });
62
+
63
+ describe('emitAccountNotify — logout', () => {
64
+ it('emits ACCOUNT * for a logout', () => {
65
+ const conn = makeConn();
66
+ conn.joinedChannels.add('#foo');
67
+
68
+ const effects = emitAccountNotify({ conn, account: undefined });
69
+
70
+ expect(effects).toEqual<EffectType[]>([
71
+ Effect.broadcast('#foo', [], 'c1', 'account-notify', [
72
+ L(':alice!alice@example.com ACCOUNT *'),
73
+ ]),
74
+ ]);
75
+ });
76
+ });
77
+
78
+ describe('emitAccountNotify — gating', () => {
79
+ it('emits nothing before registration completes', () => {
80
+ const conn = makeConn();
81
+ conn.registration = 'pre-registration';
82
+ conn.joinedChannels.add('#foo');
83
+
84
+ const effects = emitAccountNotify({ conn, account: 'alice' });
85
+
86
+ expect(effects).toEqual([]);
87
+ });
88
+
89
+ it('emits nothing during registration', () => {
90
+ const conn = makeConn();
91
+ conn.registration = 'registering';
92
+ conn.joinedChannels.add('#foo');
93
+
94
+ const effects = emitAccountNotify({ conn, account: 'alice' });
95
+
96
+ expect(effects).toEqual([]);
97
+ });
98
+
99
+ it('emits nothing when the connection has no joined channels', () => {
100
+ const conn = makeConn();
101
+
102
+ const effects = emitAccountNotify({ conn, account: 'alice' });
103
+
104
+ expect(effects).toEqual([]);
105
+ });
106
+
107
+ it('falls back to ? for the source when nick/user/host are absent', () => {
108
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
109
+ conn.registration = 'registered';
110
+ conn.joinedChannels.add('#foo');
111
+
112
+ const effects = emitAccountNotify({ conn, account: 'alice' });
113
+
114
+ const bcast = effects[0];
115
+ if (bcast?.tag === 'Broadcast') {
116
+ expect(bcast.capLines?.[0]?.text).toBe(':? ACCOUNT alice');
117
+ }
118
+ });
119
+ });
120
+
121
+ describe('emitAccountNotify — echo-message', () => {
122
+ it('echoes ACCOUNT back to a sender with echo-message negotiated', () => {
123
+ const conn = makeConn();
124
+ conn.caps.add('echo-message');
125
+ conn.joinedChannels.add('#foo');
126
+
127
+ const effects = emitAccountNotify({ conn, account: 'alice' });
128
+
129
+ // First: the broadcast (sender excluded). Then the echo Send to sender.
130
+ expect(effects).toEqual<EffectType[]>([
131
+ Effect.broadcast('#foo', [], 'c1', 'account-notify', [
132
+ L(':alice!alice@example.com ACCOUNT alice'),
133
+ ]),
134
+ Effect.send('c1', [L(':alice!alice@example.com ACCOUNT alice')]),
135
+ ]);
136
+ });
137
+
138
+ it('does not echo when the sender lacks echo-message', () => {
139
+ const conn = makeConn();
140
+ conn.joinedChannels.add('#foo');
141
+
142
+ const effects = emitAccountNotify({ conn, account: 'alice' });
143
+
144
+ expect(effects).toHaveLength(1);
145
+ expect(effects[0]?.tag).toBe('Broadcast');
146
+ });
147
+
148
+ it('does not echo when the sender has echo-message but no joined channels', () => {
149
+ const conn = makeConn();
150
+ conn.caps.add('echo-message');
151
+
152
+ const effects = emitAccountNotify({ conn, account: 'alice' });
153
+
154
+ expect(effects).toEqual([]);
155
+ });
156
+
157
+ it('echoes a single ACCOUNT even across multiple shared channels', () => {
158
+ const conn = makeConn();
159
+ conn.caps.add('echo-message');
160
+ conn.joinedChannels.add('#foo');
161
+ conn.joinedChannels.add('#bar');
162
+
163
+ const effects = emitAccountNotify({ conn, account: 'alice' });
164
+
165
+ // Two broadcasts (one per channel) + exactly one echo Send.
166
+ const broadcasts = effects.filter((e) => e.tag === 'Broadcast');
167
+ const sends = effects.filter((e) => e.tag === 'Send');
168
+ expect(broadcasts).toHaveLength(2);
169
+ expect(sends).toHaveLength(1);
170
+ });
171
+ });
@@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest';
2
2
  import { awayReducer } from '../../src/commands/away';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
5
+ import {
6
+ type AwayStore,
7
+ EmptyMotdProvider,
8
+ FakeClock,
9
+ InMemoryAwayStore,
10
+ SequentialIdFactory,
11
+ } from '../../src/ports';
6
12
  import { type ConnectionState, createConnection } from '../../src/state/connection';
7
13
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
14
 
@@ -18,13 +24,14 @@ const serverConfig: ServerConfig = {
18
24
  quitMessage: 'Client Quit',
19
25
  };
20
26
 
21
- function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
27
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000), away?: AwayStore): Ctx {
22
28
  return buildCtx({
23
29
  serverConfig,
24
30
  clock,
25
31
  ids: new SequentialIdFactory(),
26
32
  motd: EmptyMotdProvider,
27
33
  connection: conn,
34
+ ...(away !== undefined ? { away } : {}),
28
35
  });
29
36
  }
30
37
 
@@ -231,3 +238,79 @@ describe('awayReducer — defensive', () => {
231
238
  expect(broadcast?.lines[0]?.text).toBe(':? AWAY :gone');
232
239
  });
233
240
  });
241
+
242
+ // ============================================================================
243
+ // awayReducer — draft/pre-away persistence
244
+ // ============================================================================
245
+
246
+ describe('awayReducer — draft/pre-away persistence', () => {
247
+ it('persists the reason to the AwayStore keyed by account when setting away', () => {
248
+ const store = new InMemoryAwayStore();
249
+ const conn = makeConn();
250
+ conn.account = 'alice';
251
+ const ctx = makeCtx(conn, new FakeClock(1_000), store);
252
+
253
+ awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
254
+
255
+ expect(store.get('alice')).toBe('brb');
256
+ });
257
+
258
+ it('overwrites the persisted reason when the away reason changes', () => {
259
+ const store = new InMemoryAwayStore();
260
+ const conn = makeConn();
261
+ conn.account = 'alice';
262
+ const ctx = makeCtx(conn, new FakeClock(1_000), store);
263
+
264
+ awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
265
+ awayReducer(conn, { command: 'AWAY', params: ['lunch'], tags: {} }, ctx);
266
+
267
+ expect(store.get('alice')).toBe('lunch');
268
+ });
269
+
270
+ it('clears the persisted reason when AWAY is unset (no param)', () => {
271
+ const store = new InMemoryAwayStore();
272
+ store.set('alice', 'brb');
273
+ const conn = makeConn();
274
+ conn.account = 'alice';
275
+ conn.away = 'brb';
276
+ const ctx = makeCtx(conn, new FakeClock(1_000), store);
277
+
278
+ awayReducer(conn, { command: 'AWAY', params: [], tags: {} }, ctx);
279
+
280
+ expect(store.get('alice')).toBeUndefined();
281
+ });
282
+
283
+ it('clears the persisted reason when AWAY is unset (empty param)', () => {
284
+ const store = new InMemoryAwayStore();
285
+ store.set('alice', 'brb');
286
+ const conn = makeConn();
287
+ conn.account = 'alice';
288
+ conn.away = 'brb';
289
+ const ctx = makeCtx(conn, new FakeClock(1_000), store);
290
+
291
+ awayReducer(conn, { command: 'AWAY', params: [''], tags: {} }, ctx);
292
+
293
+ expect(store.get('alice')).toBeUndefined();
294
+ });
295
+
296
+ it('does not persist when no AwayStore is bound (in-memory only)', () => {
297
+ const conn = makeConn();
298
+ conn.account = 'alice';
299
+ const ctx = makeCtx(conn); // no store
300
+
301
+ awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
302
+
303
+ // In-memory reason still set so the session's own view is consistent.
304
+ expect(conn.away).toBe('brb');
305
+ });
306
+
307
+ it('does not persist when the connection is unidentified (no account)', () => {
308
+ const store = new InMemoryAwayStore();
309
+ const conn = makeConn(); // no account
310
+ const ctx = makeCtx(conn, new FakeClock(1_000), store);
311
+
312
+ awayReducer(conn, { command: 'AWAY', params: ['brb'], tags: {} }, ctx);
313
+
314
+ expect(store.get('alice')).toBeUndefined();
315
+ });
316
+ });