serverless-ircd 0.6.0 → 0.8.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 (350) hide show
  1. package/.github/workflows/ci.yml +9 -4
  2. package/.gitmodules +4 -1
  3. package/CHANGELOG.md +480 -22
  4. package/README.md +244 -57
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/origin-allowlist.ts +99 -0
  11. package/apps/cf-worker/src/worker.ts +89 -9
  12. package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
  13. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  14. package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
  15. package/apps/cf-worker/tests/smoke.test.ts +28 -2
  16. package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
  17. package/apps/cf-worker/wrangler.test.toml +23 -0
  18. package/apps/cf-worker/wrangler.toml +56 -0
  19. package/apps/local-cli/package.json +1 -1
  20. package/apps/local-cli/src/config-loader.ts +8 -0
  21. package/apps/local-cli/src/main.ts +16 -0
  22. package/apps/local-cli/src/server.ts +1 -0
  23. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  24. package/apps/web/landing/index.html +215 -0
  25. package/apps/web/package.json +28 -0
  26. package/apps/web/scripts/build.mjs +132 -0
  27. package/apps/web/src/build-env.ts +43 -0
  28. package/apps/web/src/config-schema.ts +138 -0
  29. package/apps/web/static/config.json +28 -0
  30. package/apps/web/static/config.prod.json +28 -0
  31. package/apps/web/static/config.staging.json +28 -0
  32. package/apps/web/tests/build-env.test.ts +63 -0
  33. package/apps/web/tests/build-smoke.test.ts +103 -0
  34. package/apps/web/tests/config-schema.test.ts +432 -0
  35. package/apps/web/tests/workspace.test.ts +12 -0
  36. package/apps/web/tsconfig.json +10 -0
  37. package/apps/web/upstream/.browserslistrc +3 -0
  38. package/apps/web/upstream/.editorconfig +9 -0
  39. package/apps/web/upstream/.eslintignore +3 -0
  40. package/apps/web/upstream/.eslintrc.js +90 -0
  41. package/apps/web/upstream/.github/FUNDING.yml +12 -0
  42. package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  43. package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  44. package/apps/web/upstream/.github/workflows/push.yml +26 -0
  45. package/apps/web/upstream/.github/workflows/release.yml +33 -0
  46. package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
  47. package/apps/web/upstream/.prettierrc.js +9 -0
  48. package/apps/web/upstream/.stylelintignore +5 -0
  49. package/apps/web/upstream/.stylelintrc.js +21 -0
  50. package/apps/web/upstream/LICENSE +201 -0
  51. package/apps/web/upstream/README.md +76 -0
  52. package/apps/web/upstream/babel.config.js +25 -0
  53. package/apps/web/upstream/crowdin.yml +3 -0
  54. package/apps/web/upstream/index.html +35 -0
  55. package/apps/web/upstream/jest.config.js +7 -0
  56. package/apps/web/upstream/jsdoc.json +22 -0
  57. package/apps/web/upstream/package.json +80 -0
  58. package/apps/web/upstream/scripts/pre-commit +43 -0
  59. package/apps/web/upstream/src/components/App.vue +516 -0
  60. package/apps/web/upstream/src/components/AppSettings.vue +587 -0
  61. package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
  62. package/apps/web/upstream/src/components/Avatar.vue +105 -0
  63. package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
  64. package/apps/web/upstream/src/components/BufferKey.vue +121 -0
  65. package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
  66. package/apps/web/upstream/src/components/Captcha.vue +85 -0
  67. package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
  68. package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
  69. package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
  70. package/apps/web/upstream/src/components/ChannelList.vue +345 -0
  71. package/apps/web/upstream/src/components/Container.vue +401 -0
  72. package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
  73. package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
  74. package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
  75. package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
  76. package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
  77. package/apps/web/upstream/src/components/MessageList.vue +1028 -0
  78. package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
  79. package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
  80. package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
  81. package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
  82. package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
  83. package/apps/web/upstream/src/components/Nicklist.vue +309 -0
  84. package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
  85. package/apps/web/upstream/src/components/NotConnected.vue +271 -0
  86. package/apps/web/upstream/src/components/SelfUser.vue +283 -0
  87. package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
  88. package/apps/web/upstream/src/components/ServerView.vue +126 -0
  89. package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
  90. package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
  91. package/apps/web/upstream/src/components/Sidebar.vue +429 -0
  92. package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
  93. package/apps/web/upstream/src/components/SidebarState.vue +145 -0
  94. package/apps/web/upstream/src/components/StartupError.vue +35 -0
  95. package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
  96. package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
  97. package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
  98. package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
  99. package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
  100. package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
  101. package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
  102. package/apps/web/upstream/src/components/UserBox.vue +728 -0
  103. package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
  104. package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
  105. package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
  106. package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
  107. package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
  108. package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
  109. package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
  110. package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
  111. package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
  112. package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
  113. package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
  114. package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
  115. package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
  116. package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
  117. package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
  118. package/apps/web/upstream/src/helpers/Colours.js +128 -0
  119. package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
  120. package/apps/web/upstream/src/helpers/Md5.js +193 -0
  121. package/apps/web/upstream/src/helpers/Misc.js +401 -0
  122. package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
  123. package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
  124. package/apps/web/upstream/src/libs/AudioManager.js +60 -0
  125. package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
  126. package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
  127. package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
  128. package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
  129. package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
  130. package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
  131. package/apps/web/upstream/src/libs/IPC.js +51 -0
  132. package/apps/web/upstream/src/libs/InputHandler.js +921 -0
  133. package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
  134. package/apps/web/upstream/src/libs/Logger.js +71 -0
  135. package/apps/web/upstream/src/libs/Message.js +169 -0
  136. package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
  137. package/apps/web/upstream/src/libs/MessageParser.js +249 -0
  138. package/apps/web/upstream/src/libs/Notifications.js +78 -0
  139. package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
  140. package/apps/web/upstream/src/libs/ServerSession.js +102 -0
  141. package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
  142. package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
  143. package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
  144. package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
  145. package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
  146. package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
  147. package/apps/web/upstream/src/libs/bufferTools.js +177 -0
  148. package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
  149. package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
  150. package/apps/web/upstream/src/libs/settingTools.js +31 -0
  151. package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
  152. package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
  153. package/apps/web/upstream/src/libs/state/UserState.js +89 -0
  154. package/apps/web/upstream/src/libs/state/common.js +26 -0
  155. package/apps/web/upstream/src/libs/state.js +961 -0
  156. package/apps/web/upstream/src/libs/storage/Local.js +51 -0
  157. package/apps/web/upstream/src/main.js +566 -0
  158. package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
  159. package/apps/web/upstream/src/res/configTemplates.js +373 -0
  160. package/apps/web/upstream/src/res/globalStyle.css +277 -0
  161. package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
  162. package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
  163. package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
  164. package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
  165. package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
  166. package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
  167. package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
  168. package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
  169. package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
  170. package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
  171. package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
  172. package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
  173. package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
  174. package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
  175. package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
  176. package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
  177. package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
  178. package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
  179. package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
  180. package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
  181. package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
  182. package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
  183. package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
  184. package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
  185. package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
  186. package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
  187. package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
  188. package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
  189. package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
  190. package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
  191. package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
  192. package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
  193. package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
  194. package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
  195. package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
  196. package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
  197. package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
  198. package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
  199. package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
  200. package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
  201. package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
  202. package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
  203. package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
  204. package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
  205. package/apps/web/upstream/src/res/localesList.json +27 -0
  206. package/apps/web/upstream/src/res/logo.png +0 -0
  207. package/apps/web/upstream/src/thirdparty/about.html +43 -0
  208. package/apps/web/upstream/src/thirdparty/index.js +5 -0
  209. package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
  210. package/apps/web/upstream/static/config.json +28 -0
  211. package/apps/web/upstream/static/emoticons/smile.png +0 -0
  212. package/apps/web/upstream/static/favicon.png +0 -0
  213. package/apps/web/upstream/static/highlight.mp3 +0 -0
  214. package/apps/web/upstream/static/highlight.ogg +0 -0
  215. package/apps/web/upstream/static/locales/.gitignore +2 -0
  216. package/apps/web/upstream/static/plugins/customise.html.example +10 -0
  217. package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
  218. package/apps/web/upstream/static/themes/common/base.css +1064 -0
  219. package/apps/web/upstream/static/themes/dark/theme.css +196 -0
  220. package/apps/web/upstream/static/themes/default/theme.css +92 -0
  221. package/apps/web/upstream/static/themes/elite/theme.css +248 -0
  222. package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
  223. package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
  224. package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
  225. package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
  226. package/apps/web/upstream/static/themes/sky/theme.css +107 -0
  227. package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
  228. package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
  229. package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
  230. package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
  231. package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
  232. package/apps/web/upstream/vue.config.js +150 -0
  233. package/apps/web/upstream/yarn.lock +9767 -0
  234. package/apps/web/vitest.config.ts +13 -0
  235. package/biome.json +1 -0
  236. package/docs/AWS-Deployment.md +21 -8
  237. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  238. package/docs/Home.md +1 -0
  239. package/docs/PlanExtensions.md +113 -3
  240. package/docs/Release-Process.md +23 -13
  241. package/docs/Services.md +546 -0
  242. package/docs/WebClientGuide.md +536 -0
  243. package/package.json +2 -2
  244. package/packages/aws-adapter/package.json +1 -1
  245. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  246. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  247. package/packages/aws-adapter/src/config-loader.ts +11 -0
  248. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  249. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  250. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  251. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  252. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  253. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  254. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  255. package/packages/aws-adapter/src/index.ts +4 -0
  256. package/packages/aws-adapter/src/stats.ts +6 -1
  257. package/packages/aws-adapter/src/tables.ts +34 -4
  258. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  259. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  260. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  261. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  262. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  263. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  264. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  265. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  266. package/packages/cf-adapter/package.json +1 -1
  267. package/packages/cf-adapter/src/config-loader.ts +11 -0
  268. package/packages/cf-adapter/src/connection-do.ts +112 -2
  269. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  270. package/packages/cf-adapter/src/env.ts +8 -0
  271. package/packages/cf-adapter/src/index.ts +5 -0
  272. package/packages/cf-adapter/tests/cf-harness.ts +12 -1
  273. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  274. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  275. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  276. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  277. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  278. package/packages/in-memory-runtime/package.json +1 -1
  279. package/packages/irc-core/package.json +1 -1
  280. package/packages/irc-core/reports/mutation/mutation.html +342 -0
  281. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  282. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  283. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  284. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  285. package/packages/irc-core/src/commands/index.ts +12 -0
  286. package/packages/irc-core/src/commands/join.ts +164 -8
  287. package/packages/irc-core/src/commands/markread.ts +202 -0
  288. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  289. package/packages/irc-core/src/commands/mode.ts +96 -4
  290. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  291. package/packages/irc-core/src/commands/oper.ts +18 -1
  292. package/packages/irc-core/src/commands/operserv.ts +346 -0
  293. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  294. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  295. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  296. package/packages/irc-core/src/commands/registration.ts +61 -6
  297. package/packages/irc-core/src/commands/sasl.ts +18 -49
  298. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  299. package/packages/irc-core/src/commands/topic.ts +37 -0
  300. package/packages/irc-core/src/config.ts +36 -5
  301. package/packages/irc-core/src/effects.ts +56 -1
  302. package/packages/irc-core/src/ports.ts +1653 -84
  303. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  304. package/packages/irc-core/src/state/channel.ts +21 -1
  305. package/packages/irc-core/src/state/connection.ts +25 -1
  306. package/packages/irc-core/src/types.ts +48 -12
  307. package/packages/irc-core/tests/batch.test.ts +15 -0
  308. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  309. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  310. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  311. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  312. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  313. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  314. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  315. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  316. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  317. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  318. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  319. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  320. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  321. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  322. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  323. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  324. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  325. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  326. package/packages/irc-core/tests/config.test.ts +49 -5
  327. package/packages/irc-core/tests/effects.test.ts +19 -0
  328. package/packages/irc-core/tests/message-store.test.ts +63 -0
  329. package/packages/irc-core/tests/numerics.test.ts +13 -0
  330. package/packages/irc-core/tests/parser.test.ts +109 -0
  331. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  332. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  333. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  334. package/packages/irc-server/package.json +1 -1
  335. package/packages/irc-server/src/actor.ts +71 -16
  336. package/packages/irc-server/src/dispatch.ts +94 -7
  337. package/packages/irc-server/src/routing.ts +19 -0
  338. package/packages/irc-server/tests/actor.test.ts +623 -12
  339. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  340. package/packages/irc-server/tests/routing.test.ts +6 -0
  341. package/packages/irc-test-support/package.json +1 -1
  342. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  343. package/packages/irc-test-support/src/index.ts +1 -0
  344. package/packages/irc-test-support/src/scenarios.ts +24 -2
  345. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  346. package/tools/ci-hardening/package.json +1 -1
  347. package/tools/load-test/package.json +1 -1
  348. package/tools/tcp-ws-forwarder/package.json +1 -1
  349. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  350. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -16,7 +16,7 @@ import {
16
16
  InMemoryMessageStore,
17
17
  InMemoryMonitorStore,
18
18
  InMemoryNickHistoryStore,
19
- InMemoryReadMarkerStore,
19
+ InMemoryServicesStore,
20
20
  LogLevel,
21
21
  type Logger,
22
22
  type MessageStore,
@@ -26,18 +26,19 @@ import {
26
26
  type NickHistoryStore,
27
27
  Numerics,
28
28
  type RawLine,
29
- type ReadMarkerStore,
30
29
  type SaslPayload,
31
30
  type SaslResult,
32
31
  SequentialIdFactory,
33
32
  type ServerConfig,
34
33
  type ServerStats,
35
34
  type ServerStatsSnapshot,
35
+ type ServicesStore,
36
36
  type StoredMessage,
37
37
  applyChannelDelta as applyDelta,
38
38
  createChannel,
39
39
  createConnection,
40
40
  encodeBase64,
41
+ formatServerTime,
41
42
  isChannelTarget,
42
43
  toChannelSnapshot,
43
44
  toSnapshot,
@@ -228,7 +229,7 @@ function makeActor(opts: {
228
229
  secure?: boolean;
229
230
  monitor?: MonitorStore;
230
231
  away?: AwayStore;
231
- readMarkers?: ReadMarkerStore;
232
+ services?: ServicesStore;
232
233
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
233
234
  const clock = opts.clock ?? new FakeClock(1_000);
234
235
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -306,8 +307,8 @@ function makeActor(opts: {
306
307
  if (opts.away !== undefined) {
307
308
  finalOpts.away = opts.away;
308
309
  }
309
- if (opts.readMarkers !== undefined) {
310
- finalOpts.readMarkers = opts.readMarkers;
310
+ if (opts.services !== undefined) {
311
+ finalOpts.services = opts.services;
311
312
  }
312
313
  const actor = new ConnectionActor(finalOpts);
313
314
  return { actor, runtime, conn };
@@ -1229,9 +1230,13 @@ describe('ConnectionActor — additional routing', () => {
1229
1230
  await actor.receiveTextFrame('OPER alice secret\r\n');
1230
1231
 
1231
1232
  const sends = runtime.calls.filter((c) => c.method === 'send');
1232
- expect(sends).toHaveLength(1);
1233
- const lines = sends[0]?.args[1] as RawLine[];
1234
- expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
1233
+ // OPER success emits two Send effects: the 381 numeric, then a
1234
+ // `:<hostmask> MODE <nick> +o` echo (mirrors the user MODE echo).
1235
+ expect(sends).toHaveLength(2);
1236
+ const firstLines = sends[0]?.args[1] as RawLine[];
1237
+ expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
1238
+ const secondLines = sends[1]?.args[1] as RawLine[];
1239
+ expect(secondLines[0]?.text).toBe(':alice!alice@example.com MODE alice +o');
1235
1240
  expect(conn.userModes.oper).toBe(true);
1236
1241
  // Critical: never 421 for OPER.
1237
1242
  for (const s of sends) {
@@ -1307,6 +1312,7 @@ describe('ConnectionActor — additional routing', () => {
1307
1312
  wallops: false,
1308
1313
  serverNotices: false,
1309
1314
  tls: false,
1315
+ registered: false,
1310
1316
  },
1311
1317
  },
1312
1318
  });
@@ -1359,6 +1365,7 @@ describe('ConnectionActor — additional routing', () => {
1359
1365
  wallops: false,
1360
1366
  serverNotices: false,
1361
1367
  tls: false,
1368
+ registered: false,
1362
1369
  },
1363
1370
  },
1364
1371
  });
@@ -1387,6 +1394,7 @@ describe('ConnectionActor — additional routing', () => {
1387
1394
  wallops: false,
1388
1395
  serverNotices: false,
1389
1396
  tls: false,
1397
+ registered: false,
1390
1398
  },
1391
1399
  },
1392
1400
  configSource: 'KV',
@@ -1456,6 +1464,7 @@ describe('ConnectionActor — additional routing', () => {
1456
1464
  wallops: false,
1457
1465
  serverNotices: false,
1458
1466
  tls: false,
1467
+ registered: false,
1459
1468
  },
1460
1469
  },
1461
1470
  configSource: 'KV',
@@ -1495,6 +1504,7 @@ describe('ConnectionActor — additional routing', () => {
1495
1504
  wallops: false,
1496
1505
  serverNotices: false,
1497
1506
  tls: false,
1507
+ registered: false,
1498
1508
  },
1499
1509
  },
1500
1510
  configSource: 'KV',
@@ -1528,6 +1538,7 @@ describe('ConnectionActor — additional routing', () => {
1528
1538
  wallops: false,
1529
1539
  serverNotices: false,
1530
1540
  tls: false,
1541
+ registered: false,
1531
1542
  },
1532
1543
  },
1533
1544
  configSource: 'KV',
@@ -4020,9 +4031,9 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
4020
4031
  expect(away.get('alice')).toBeUndefined();
4021
4032
  });
4022
4033
 
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 });
4034
+ it('persists a +draft/read-marker TAGMSG to the bound ServicesStore for an identified member', async () => {
4035
+ const services = new InMemoryServicesStore();
4036
+ const { actor, runtime, conn } = makeActor({ services });
4026
4037
  conn.account = 'alice';
4027
4038
  conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4028
4039
  await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
@@ -4030,7 +4041,225 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
4030
4041
 
4031
4042
  await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
4032
4043
 
4033
- expect(readMarkers.get('alice', '#foo')).toBe('msg-42');
4044
+ expect(services.getLastReadMarker('alice', '#foo')).toBe('msg-42');
4045
+ });
4046
+ });
4047
+
4048
+ // ---------------------------------------------------------------------------
4049
+ // draft/read-marker — account-scoped fanout (spec compliance)
4050
+ //
4051
+ // The ircv3 draft/read-marker spec mandates that a read marker is private to
4052
+ // the originator's account: the mark TAGMSG is delivered only to the
4053
+ // originator's own connections, never to other accounts in the channel. The
4054
+ // reducer annotates the Broadcast with the originator account; dispatch
4055
+ // restricts the recipient set accordingly. These end-to-end tests drive the
4056
+ // full actor → reducer → dispatch → FakeRuntime path.
4057
+ // ---------------------------------------------------------------------------
4058
+
4059
+ describe('ConnectionActor — draft/read-marker account-scoped fanout', () => {
4060
+ function addIdentifiedMember(
4061
+ runtime: FakeRuntime,
4062
+ connId: ConnId,
4063
+ nick: string,
4064
+ account: string | undefined,
4065
+ caps: ReadonlyArray<string>,
4066
+ ): ConnectionState {
4067
+ const state = createConnection({ id: connId, connectedSince: 0 });
4068
+ state.nick = nick;
4069
+ state.user = nick;
4070
+ state.host = `${nick}.example`;
4071
+ state.realname = nick;
4072
+ state.registration = 'registered';
4073
+ state.caps = new Set<string>(caps);
4074
+ if (account !== undefined) state.account = account;
4075
+ runtime.addConnection(state);
4076
+ return state;
4077
+ }
4078
+
4079
+ async function seedChannel(
4080
+ runtime: FakeRuntime,
4081
+ members: ReadonlyArray<{ conn: ConnId; nick: string }>,
4082
+ ): Promise<void> {
4083
+ await runtime.applyChannelDelta('#foo', {
4084
+ memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
4085
+ });
4086
+ }
4087
+
4088
+ it('delivers the mark ONLY to same-account members; other accounts never receive it', async () => {
4089
+ const { actor, runtime, conn } = makeActor({});
4090
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4091
+ conn.account = 'alice';
4092
+ // c2 = alice's other session (same account); c3 = bob (different account).
4093
+ addIdentifiedMember(runtime, 'c2', 'alice2', 'alice', ['message-tags', 'draft/read-marker']);
4094
+ addIdentifiedMember(runtime, 'c3', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
4095
+ await seedChannel(runtime, [
4096
+ { conn: 'c1', nick: 'alice' },
4097
+ { conn: 'c2', nick: 'alice2' },
4098
+ { conn: 'c3', nick: 'bob' },
4099
+ ]);
4100
+ runtime.calls.length = 0;
4101
+
4102
+ await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
4103
+
4104
+ // alice's other session (c2) receives the mark...
4105
+ expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-42'))).toBe(
4106
+ true,
4107
+ );
4108
+ // ...bob (c3, different account) never does.
4109
+ expect(sentTo(runtime, 'c3')).toEqual([]);
4110
+ });
4111
+
4112
+ it("delivers the mark to the originator's other session on the same account (multi-device sync)", async () => {
4113
+ const { actor, runtime, conn } = makeActor({});
4114
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4115
+ conn.account = 'alice';
4116
+ addIdentifiedMember(runtime, 'c2', 'alice-laptop', 'alice', [
4117
+ 'message-tags',
4118
+ 'draft/read-marker',
4119
+ ]);
4120
+ await seedChannel(runtime, [
4121
+ { conn: 'c1', nick: 'alice' },
4122
+ { conn: 'c2', nick: 'alice-laptop' },
4123
+ ]);
4124
+ runtime.calls.length = 0;
4125
+
4126
+ await actor.receiveTextFrame('@+draft/read-marker=msg-99 TAGMSG #foo\r\n');
4127
+
4128
+ expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-99'))).toBe(
4129
+ true,
4130
+ );
4131
+ });
4132
+
4133
+ it('does NOT fan out a mark from an unidentified originator to anyone', async () => {
4134
+ const { actor, runtime, conn } = makeActor({});
4135
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4136
+ // originator unidentified (no account).
4137
+ addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
4138
+ addIdentifiedMember(runtime, 'c3', 'carol', undefined, ['message-tags', 'draft/read-marker']);
4139
+ await seedChannel(runtime, [
4140
+ { conn: 'c1', nick: 'alice' },
4141
+ { conn: 'c2', nick: 'bob' },
4142
+ { conn: 'c3', nick: 'carol' },
4143
+ ]);
4144
+ runtime.calls.length = 0;
4145
+
4146
+ await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
4147
+
4148
+ // No recipient receives the mark (session-local only).
4149
+ expect(sentTo(runtime, 'c2')).toEqual([]);
4150
+ expect(sentTo(runtime, 'c3')).toEqual([]);
4151
+ });
4152
+
4153
+ it('self-echoes the mark to an unidentified originator only when echo-message is negotiated', async () => {
4154
+ const { actor, runtime, conn } = makeActor({});
4155
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker', 'echo-message']);
4156
+ // originator unidentified (no account).
4157
+ addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
4158
+ await seedChannel(runtime, [
4159
+ { conn: 'c1', nick: 'alice' },
4160
+ { conn: 'c2', nick: 'bob' },
4161
+ ]);
4162
+ runtime.calls.length = 0;
4163
+
4164
+ await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
4165
+
4166
+ // No fanout to bob...
4167
+ expect(sentTo(runtime, 'c2')).toEqual([]);
4168
+ // ...but the originator sees its own mark reflected back via echo-message.
4169
+ expect(sentTo(runtime, 'c1').some((l) => l.text.includes('+draft/read-marker=msg-1'))).toBe(
4170
+ true,
4171
+ );
4172
+ });
4173
+
4174
+ it('keeps the +draft/read-marker tag for a draft/read-marker-only same-account peer via the filterClientTags whitelist', async () => {
4175
+ const { actor, runtime, conn } = makeActor({});
4176
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4177
+ conn.account = 'alice';
4178
+ // c2 announced ONLY draft/read-marker (not message-tags). The whitelist
4179
+ // in filterClientTags keeps the +draft/read-marker client tag for it.
4180
+ addIdentifiedMember(runtime, 'c2', 'alice-min', 'alice', ['draft/read-marker']);
4181
+ await seedChannel(runtime, [
4182
+ { conn: 'c1', nick: 'alice' },
4183
+ { conn: 'c2', nick: 'alice-min' },
4184
+ ]);
4185
+ runtime.calls.length = 0;
4186
+
4187
+ await actor.receiveTextFrame('@+draft/read-marker=msg-5 TAGMSG #foo\r\n');
4188
+
4189
+ const lines = sentTo(runtime, 'c2');
4190
+ expect(lines).toHaveLength(1);
4191
+ // The tag survives the whitelist for a draft/read-marker-only peer.
4192
+ expect(lines[0]?.text).toBe('@+draft/read-marker=msg-5 :alice!alice@example.com TAGMSG #foo');
4193
+ });
4194
+ });
4195
+
4196
+ // ---------------------------------------------------------------------------
4197
+ // draft/read-marker — MARKREAD verb (draft-spec client compatibility)
4198
+ //
4199
+ // The deployment stores read markers by msgid but accepts the draft's
4200
+ // timestamped MARKREAD verb from cap-enabled clients, translating the
4201
+ // timestamp to the most recent retained message at or before it. These
4202
+ // end-to-end tests drive the full actor -> reducer path and assert the verb
4203
+ // is routed (never 421 for a cap-enabled client).
4204
+ // ---------------------------------------------------------------------------
4205
+
4206
+ describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
4207
+ const TS = (ms: number): string => `timestamp=${formatServerTime(ms)}`;
4208
+
4209
+ /** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
4210
+ function seedMessages(store: MessageStore, chan: string, count: number): void {
4211
+ for (let i = 1; i <= count; i++) {
4212
+ const m: StoredMessage = {
4213
+ msgid: `m${i}`,
4214
+ time: i * 1_000,
4215
+ chan: chan.toLowerCase(),
4216
+ command: 'PRIVMSG',
4217
+ nick: 'bob',
4218
+ user: 'bob',
4219
+ host: 'ex.org',
4220
+ text: `msg ${i}`,
4221
+ };
4222
+ store.record(m);
4223
+ }
4224
+ }
4225
+
4226
+ it('routes MARKREAD to the reducer (never 421) and echoes the resolved timestamp', async () => {
4227
+ const store = new InMemoryMessageStore();
4228
+ const { actor, runtime, conn } = makeActor({ messages: store });
4229
+ conn.caps = new Set<string>(['draft/read-marker']);
4230
+ await runtime.applyChannelDelta('#foo', {
4231
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
4232
+ });
4233
+ seedMessages(store, '#foo', 3); // m1=1s, m2=2s, m3=3s
4234
+ runtime.calls.length = 0;
4235
+
4236
+ await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
4237
+
4238
+ const sends = runtime.calls.filter((c) => c.method === 'send');
4239
+ expect(sends).toHaveLength(1);
4240
+ const lines = sends[0]?.args[1] as RawLine[];
4241
+ expect(lines[0]?.text).toBe(`:irc.example.com MARKREAD #foo ${TS(2_000)}`);
4242
+ // Critical: never 421 for a cap-enabled MARKREAD.
4243
+ expect(lines[0]?.text).not.toContain(' 421 ');
4244
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
4245
+ });
4246
+
4247
+ it('still emits 421 for MARKREAD when the cap is not negotiated (reducer cap-gating)', async () => {
4248
+ const store = new InMemoryMessageStore();
4249
+ const { actor, runtime } = makeActor({ messages: store });
4250
+ // No draft/read-marker cap.
4251
+ await runtime.applyChannelDelta('#foo', {
4252
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
4253
+ });
4254
+ seedMessages(store, '#foo', 3);
4255
+ runtime.calls.length = 0;
4256
+
4257
+ await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
4258
+
4259
+ const sends = runtime.calls.filter((c) => c.method === 'send');
4260
+ expect(sends).toHaveLength(1);
4261
+ const lines = sends[0]?.args[1] as RawLine[];
4262
+ expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
4034
4263
  });
4035
4264
  });
4036
4265
 
@@ -4367,3 +4596,385 @@ describe('ConnectionActor — draft/multiline chathistory recording', () => {
4367
4596
  expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
4368
4597
  });
4369
4598
  });
4599
+
4600
+ // ============================================================================
4601
+ // NickServ routing — PRIVMSG NickServ :<command>
4602
+ // ============================================================================
4603
+
4604
+ describe('ConnectionActor — NickServ routing', () => {
4605
+ it('routes PRIVMSG NickServ :IDENTIFY to the NickServ reducer and sets +r', async () => {
4606
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4607
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4608
+ const { actor, runtime, conn } = makeActor({ services });
4609
+ runtime.calls.length = 0;
4610
+
4611
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
4612
+
4613
+ expect(conn.account).toBe('alice');
4614
+ expect(conn.userModes.registered).toBe(true);
4615
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4616
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4617
+ expect(allText).toContain(
4618
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
4619
+ );
4620
+ });
4621
+
4622
+ it('routes PRIVMSG NickServ :REGISTER and confirms via NOTICE', async () => {
4623
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4624
+ const { actor, runtime } = makeActor({ services });
4625
+ runtime.calls.length = 0;
4626
+
4627
+ await actor.receiveTextFrame('PRIVMSG NickServ :REGISTER hunter2 alice@example.com\r\n');
4628
+
4629
+ expect(services.isRegisteredNick('alice')).toBe(true);
4630
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4631
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4632
+ expect(allText).toContain(
4633
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
4634
+ );
4635
+ });
4636
+
4637
+ it('does NOT route NOTICE NickServ to the NickServ reducer (RFC: NOTICE is silent)', async () => {
4638
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4639
+ const { actor, runtime, conn } = makeActor({ services });
4640
+ runtime.calls.length = 0;
4641
+
4642
+ await actor.receiveTextFrame('NOTICE NickServ :IDENTIFY hunter2\r\n');
4643
+
4644
+ // NOTICE to NickServ falls through to the normal user-target path (no
4645
+ // NickServ NOTICE emitted, no state change).
4646
+ expect(conn.account).toBeUndefined();
4647
+ const nickservNotices = runtime.calls.filter(
4648
+ (c) =>
4649
+ c.method === 'send' &&
4650
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('NickServ!NickServ@services')),
4651
+ );
4652
+ expect(nickservNotices).toHaveLength(0);
4653
+ });
4654
+
4655
+ it('treats PRIVMSG NickServ as a normal nick-target when no services store is bound', async () => {
4656
+ const { actor, runtime, conn } = makeActor({});
4657
+ runtime.calls.length = 0;
4658
+
4659
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
4660
+
4661
+ // Without a services store, NickServ is just a nick target → the normal
4662
+ // user-target reducer emits a SendToNick with a 401 fallback.
4663
+ expect(conn.account).toBeUndefined();
4664
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4665
+ expect(toNick).toHaveLength(1);
4666
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4667
+ expect(notFound?.[0]?.text).toContain('401');
4668
+ });
4669
+
4670
+ it('routes a case-insensitive PRIVMSG nickserv :… to the NickServ reducer', async () => {
4671
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4672
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4673
+ const { actor, conn } = makeActor({ services });
4674
+
4675
+ await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
4676
+
4677
+ expect(conn.account).toBe('alice');
4678
+ });
4679
+ });
4680
+
4681
+ // ============================================================================
4682
+ // ChanServ routing — PRIVMSG ChanServ :<command>
4683
+ // ============================================================================
4684
+
4685
+ describe('ConnectionActor — ChanServ routing', () => {
4686
+ it('routes PRIVMSG ChanServ :REGISTER and applies the +r ApplyChannelDelta', async () => {
4687
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4688
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4689
+ const { actor, runtime, conn } = makeActor({ services });
4690
+ conn.account = 'alice';
4691
+ conn.userModes.registered = true;
4692
+ runtime.calls.length = 0;
4693
+
4694
+ await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
4695
+
4696
+ expect(services.getChannelFounder('#home')).toBe('alice');
4697
+ const applyDeltas = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
4698
+ expect(applyDeltas.length).toBeGreaterThan(0);
4699
+ });
4700
+
4701
+ it('routes PRIVMSG ChanServ :SET KEEPTOPIC #home ON and persists', async () => {
4702
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4703
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4704
+ services.registerChannel('#home', 'alice');
4705
+ const { actor, conn } = makeActor({ services });
4706
+ conn.account = 'alice';
4707
+ conn.userModes.registered = true;
4708
+
4709
+ await actor.receiveTextFrame('PRIVMSG ChanServ :SET KEEPTOPIC #home ON\r\n');
4710
+
4711
+ expect(services.getChannel('#home')?.keepTopic).toBe(true);
4712
+ });
4713
+
4714
+ it('does NOT route NOTICE ChanServ to the ChanServ reducer', async () => {
4715
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4716
+ const { actor, runtime } = makeActor({ services });
4717
+ runtime.calls.length = 0;
4718
+
4719
+ await actor.receiveTextFrame('NOTICE ChanServ :REGISTER #home\r\n');
4720
+
4721
+ const chanservNotices = runtime.calls.filter(
4722
+ (c) =>
4723
+ c.method === 'send' &&
4724
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('ChanServ!ChanServ@services')),
4725
+ );
4726
+ expect(chanservNotices).toHaveLength(0);
4727
+ });
4728
+
4729
+ it('treats PRIVMSG ChanServ as a normal nick-target when no services store is bound', async () => {
4730
+ const { actor, runtime } = makeActor({});
4731
+ runtime.calls.length = 0;
4732
+
4733
+ await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
4734
+
4735
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4736
+ expect(toNick).toHaveLength(1);
4737
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4738
+ expect(notFound?.[0]?.text).toContain('401');
4739
+ });
4740
+
4741
+ it('routes a case-insensitive PRIVMSG chanserv :… to the ChanServ reducer', async () => {
4742
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4743
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4744
+ const { actor, conn } = makeActor({ services });
4745
+ conn.account = 'alice';
4746
+ conn.userModes.registered = true;
4747
+
4748
+ await actor.receiveTextFrame('PRIVMSG chanserv :REGISTER #home\r\n');
4749
+
4750
+ expect(services.getChannelFounder('#home')).toBe('alice');
4751
+ });
4752
+ });
4753
+
4754
+ // ============================================================================
4755
+ // MemoServ routing — PRIVMSG MemoServ :<command>
4756
+ // ============================================================================
4757
+
4758
+ describe('ConnectionActor — MemoServ routing', () => {
4759
+ it('routes PRIVMSG MemoServ :SEND and records the memo against the recipient', async () => {
4760
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4761
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4762
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
4763
+ const { actor, conn } = makeActor({ services });
4764
+ conn.account = 'alice';
4765
+ conn.userModes.registered = true;
4766
+
4767
+ await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi from actor\r\n');
4768
+
4769
+ const memos = services.listMemos('bob');
4770
+ expect(memos).toHaveLength(1);
4771
+ expect(memos[0]?.body).toBe('Hi from actor');
4772
+ });
4773
+
4774
+ it('delivers a queued memo via MemoServ NOTICE on NickServ IDENTIFY', async () => {
4775
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4776
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4777
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'queued', sentAt: 1_000 });
4778
+ const { actor, runtime } = makeActor({ services });
4779
+ runtime.calls.length = 0;
4780
+
4781
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
4782
+
4783
+ const memoNotices = runtime.calls.filter(
4784
+ (c) =>
4785
+ c.method === 'send' &&
4786
+ (c.args[1] as RawLine[]).some((l) =>
4787
+ l.text.includes('MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] queued'),
4788
+ ),
4789
+ );
4790
+ expect(memoNotices).toHaveLength(1);
4791
+ });
4792
+
4793
+ it('does NOT route NOTICE MemoServ to the MemoServ reducer', async () => {
4794
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4795
+ const { actor, runtime } = makeActor({ services });
4796
+ runtime.calls.length = 0;
4797
+
4798
+ await actor.receiveTextFrame('NOTICE MemoServ :SEND bob Hi\r\n');
4799
+
4800
+ const memoservNotices = runtime.calls.filter(
4801
+ (c) =>
4802
+ c.method === 'send' &&
4803
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('MemoServ!MemoServ@services')),
4804
+ );
4805
+ expect(memoservNotices).toHaveLength(0);
4806
+ });
4807
+
4808
+ it('treats PRIVMSG MemoServ as a normal nick-target when no services store is bound', async () => {
4809
+ const { actor, runtime } = makeActor({});
4810
+ runtime.calls.length = 0;
4811
+
4812
+ await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi\r\n');
4813
+
4814
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4815
+ expect(toNick).toHaveLength(1);
4816
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4817
+ expect(notFound?.[0]?.text).toContain('401');
4818
+ });
4819
+
4820
+ it('routes a case-insensitive PRIVMSG memoserv :… to the MemoServ reducer', async () => {
4821
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4822
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4823
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
4824
+ const { actor, conn } = makeActor({ services });
4825
+ conn.account = 'alice';
4826
+ conn.userModes.registered = true;
4827
+
4828
+ await actor.receiveTextFrame('PRIVMSG memoserv :SEND bob lower-case\r\n');
4829
+
4830
+ expect(services.listMemos('bob')[0]?.body).toBe('lower-case');
4831
+ });
4832
+ });
4833
+
4834
+ // ============================================================================
4835
+ // OperServ routing — PRIVMSG OperServ :<command> (oper-gated management)
4836
+ // ============================================================================
4837
+
4838
+ describe('ConnectionActor — OperServ routing', () => {
4839
+ it('routes PRIVMSG OperServ :AKILL LIST from an oper to the OperServ reducer (not 401)', async () => {
4840
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4841
+ const { actor, runtime, conn } = makeActor({ services });
4842
+ conn.userModes.oper = true;
4843
+ runtime.calls.length = 0;
4844
+
4845
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
4846
+
4847
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4848
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4849
+ expect(allText).toContain(
4850
+ ':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
4851
+ );
4852
+ // Critical: never 401 for a routed OperServ PRIVMSG.
4853
+ for (const t of allText) {
4854
+ expect(t).not.toContain(' 401 ');
4855
+ }
4856
+ });
4857
+
4858
+ it('round-trips oper AKILL ADD / DEL via PRIVMSG OperServ', async () => {
4859
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4860
+ const { actor, runtime, conn } = makeActor({ services });
4861
+ conn.userModes.oper = true;
4862
+ runtime.calls.length = 0;
4863
+
4864
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
4865
+ expect(services.listAkills()).toHaveLength(1);
4866
+ const addSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4867
+ const addText = addSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4868
+ expect(addText).toContain(
4869
+ ':OperServ!OperServ@services NOTICE alice :AKILL *bad@host added (reason: flooding).',
4870
+ );
4871
+
4872
+ runtime.calls.length = 0;
4873
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL DEL *bad@host\r\n');
4874
+ expect(services.listAkills()).toHaveLength(0);
4875
+ const delSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4876
+ const delText = delSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4877
+ expect(delText).toContain(':OperServ!OperServ@services NOTICE alice :AKILL *bad@host removed.');
4878
+ });
4879
+
4880
+ it('round-trips oper JUPE / UNJUPE via PRIVMSG OperServ', async () => {
4881
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4882
+ const { actor, runtime, conn } = makeActor({ services });
4883
+ conn.userModes.oper = true;
4884
+ runtime.calls.length = 0;
4885
+
4886
+ await actor.receiveTextFrame('PRIVMSG OperServ :JUPE malkovich\r\n');
4887
+ expect(services.isJuped('malkovich')).toBe(true);
4888
+ const jupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4889
+ const jupeText = jupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4890
+ expect(jupeText).toContain(':OperServ!OperServ@services NOTICE alice :JUPE malkovich added.');
4891
+
4892
+ runtime.calls.length = 0;
4893
+ await actor.receiveTextFrame('PRIVMSG OperServ :UNJUPE malkovich\r\n');
4894
+ expect(services.isJuped('malkovich')).toBe(false);
4895
+ const unjupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4896
+ const unjupeText = unjupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4897
+ expect(unjupeText).toContain(
4898
+ ':OperServ!OperServ@services NOTICE alice :JUPE malkovich removed.',
4899
+ );
4900
+ });
4901
+
4902
+ it('round-trips oper RAW via PRIVMSG OperServ', async () => {
4903
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4904
+ const { actor, runtime, conn } = makeActor({ services });
4905
+ conn.userModes.oper = true;
4906
+ runtime.calls.length = 0;
4907
+
4908
+ await actor.receiveTextFrame('PRIVMSG OperServ :RAW :srv.example.com STATS u\r\n');
4909
+
4910
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4911
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4912
+ expect(allText).toContain(
4913
+ ':OperServ!OperServ@services NOTICE alice :RAW line accepted and logged.',
4914
+ );
4915
+ });
4916
+
4917
+ it('rejects a non-oper PRIVMSG OperServ :AKILL ADD with permission-denied NOTICE (not 401, no state change)', async () => {
4918
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4919
+ const { actor, runtime } = makeActor({ services });
4920
+ // conn.userModes.oper left false
4921
+ runtime.calls.length = 0;
4922
+
4923
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
4924
+
4925
+ expect(services.listAkills()).toHaveLength(0);
4926
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4927
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4928
+ expect(allText).toContain(
4929
+ ':OperServ!OperServ@services NOTICE alice :Permission denied — oper privileges required.',
4930
+ );
4931
+ for (const t of allText) {
4932
+ expect(t).not.toContain(' 401 ');
4933
+ }
4934
+ });
4935
+
4936
+ it('does NOT route NOTICE OperServ to the OperServ reducer (PRIVMSG-only interception)', async () => {
4937
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4938
+ const { actor, runtime, conn } = makeActor({ services });
4939
+ conn.userModes.oper = true;
4940
+ runtime.calls.length = 0;
4941
+
4942
+ await actor.receiveTextFrame('NOTICE OperServ :AKILL LIST\r\n');
4943
+
4944
+ // NOTICE to OperServ falls through to the normal user-target path (no
4945
+ // OperServ NOTICE emitted, no state change).
4946
+ const operservNotices = runtime.calls.filter(
4947
+ (c) =>
4948
+ c.method === 'send' &&
4949
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('OperServ!OperServ@services')),
4950
+ );
4951
+ expect(operservNotices).toHaveLength(0);
4952
+ });
4953
+
4954
+ it('treats PRIVMSG OperServ as a normal nick-target when no services store is bound', async () => {
4955
+ const { actor, runtime } = makeActor({});
4956
+ runtime.calls.length = 0;
4957
+
4958
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
4959
+
4960
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4961
+ expect(toNick).toHaveLength(1);
4962
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4963
+ expect(notFound?.[0]?.text).toContain('401');
4964
+ });
4965
+
4966
+ it('routes a case-insensitive PRIVMSG operserv :… to the OperServ reducer', async () => {
4967
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4968
+ const { actor, runtime, conn } = makeActor({ services });
4969
+ conn.userModes.oper = true;
4970
+ runtime.calls.length = 0;
4971
+
4972
+ await actor.receiveTextFrame('PRIVMSG operserv :AKILL LIST\r\n');
4973
+
4974
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4975
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4976
+ expect(allText).toContain(
4977
+ ':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
4978
+ );
4979
+ });
4980
+ });