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
@@ -142,6 +142,32 @@ describe('capReducer — LS', () => {
142
142
  }
143
143
  });
144
144
 
145
+ it('advertises draft/multiline=4096 with the default byte budget', () => {
146
+ const state = makeState();
147
+ const ctx = makeCtx(state);
148
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
149
+ const send = out.effects[0];
150
+ expect(send).toBeDefined();
151
+ if (send?.tag === 'Send') {
152
+ const body = send.lines[0]?.text ?? '';
153
+ expect(body).toContain('draft/multiline=4096');
154
+ }
155
+ });
156
+
157
+ it('advertises the configured multiline byte budget when overridden', () => {
158
+ const state = makeState();
159
+ const cfg: ServerConfig = { ...serverConfig, multilineMaxBytes: 8192 };
160
+ const ctx = makeCtx(state, cfg);
161
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
162
+ const send = out.effects[0];
163
+ expect(send).toBeDefined();
164
+ if (send?.tag === 'Send') {
165
+ const body = send.lines[0]?.text ?? '';
166
+ expect(body).toContain('draft/multiline=8192');
167
+ expect(body).not.toContain('draft/multiline=4096');
168
+ }
169
+ });
170
+
145
171
  it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
146
172
  const state = makeState();
147
173
  const ctx = makeCtx(state);
@@ -529,3 +555,142 @@ describe('capReducer — unknown subcommands', () => {
529
555
  ]);
530
556
  });
531
557
  });
558
+
559
+ // ============================================================================
560
+ // capReducer — IRCv3 `sts` (Strict Transport Security) advertisement
561
+ // ============================================================================
562
+
563
+ describe('capReducer — STS advertisement', () => {
564
+ it('omits the sts cap when no STS policy is configured', () => {
565
+ const state = makeState();
566
+ const ctx = makeCtx(state);
567
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
568
+ const send = out.effects[0];
569
+ expect(send).toBeDefined();
570
+ if (send?.tag === 'Send') {
571
+ const body = send.lines[0]?.text ?? '';
572
+ expect(body).not.toContain('sts=');
573
+ expect(body).not.toContain(' sts ');
574
+ }
575
+ });
576
+
577
+ it('advertises sts=duration=…,port=… on a plaintext connection', () => {
578
+ const state = makeState();
579
+ state.secure = false;
580
+ const cfg: ServerConfig = {
581
+ ...serverConfig,
582
+ sts: { duration: 600, port: 6697 },
583
+ };
584
+ const ctx = makeCtx(state, cfg);
585
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
586
+ const send = out.effects[0];
587
+ expect(send).toBeDefined();
588
+ if (send?.tag === 'Send') {
589
+ const body = send.lines[0]?.text ?? '';
590
+ expect(body).toContain('sts=duration=600,port=6697');
591
+ }
592
+ });
593
+
594
+ it('reads duration and port from ServerConfig (not literals)', () => {
595
+ const state = makeState();
596
+ state.secure = false;
597
+ const cfg: ServerConfig = {
598
+ ...serverConfig,
599
+ sts: { duration: 12345, port: 7000 },
600
+ };
601
+ const ctx = makeCtx(state, cfg);
602
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
603
+ const send = out.effects[0];
604
+ expect(send).toBeDefined();
605
+ if (send?.tag === 'Send') {
606
+ const body = send.lines[0]?.text ?? '';
607
+ expect(body).toContain('sts=duration=12345,port=7000');
608
+ expect(body).not.toContain('sts=duration=600');
609
+ expect(body).not.toContain('port=6697');
610
+ }
611
+ });
612
+
613
+ it('appends preload when configured on a plaintext connection', () => {
614
+ const state = makeState();
615
+ state.secure = false;
616
+ const cfg: ServerConfig = {
617
+ ...serverConfig,
618
+ sts: { duration: 600, port: 6697, preload: true },
619
+ };
620
+ const ctx = makeCtx(state, cfg);
621
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
622
+ const send = out.effects[0];
623
+ expect(send).toBeDefined();
624
+ if (send?.tag === 'Send') {
625
+ const body = send.lines[0]?.text ?? '';
626
+ expect(body).toContain('sts=duration=600,port=6697,preload');
627
+ }
628
+ });
629
+
630
+ it('advertises only duration on a TLS connection (wss / irc+tls)', () => {
631
+ const state = makeState();
632
+ state.secure = true;
633
+ const cfg: ServerConfig = {
634
+ ...serverConfig,
635
+ sts: { duration: 600, port: 6697 },
636
+ };
637
+ const ctx = makeCtx(state, cfg);
638
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
639
+ const send = out.effects[0];
640
+ expect(send).toBeDefined();
641
+ if (send?.tag === 'Send') {
642
+ const body = send.lines[0]?.text ?? '';
643
+ expect(body).toContain('sts=duration=600');
644
+ expect(body).not.toContain('port=');
645
+ expect(body).not.toContain('preload');
646
+ }
647
+ });
648
+
649
+ it('omits preload on a TLS connection even when configured', () => {
650
+ const state = makeState();
651
+ state.secure = true;
652
+ const cfg: ServerConfig = {
653
+ ...serverConfig,
654
+ sts: { duration: 600, port: 6697, preload: true },
655
+ };
656
+ const ctx = makeCtx(state, cfg);
657
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
658
+ const send = out.effects[0];
659
+ expect(send).toBeDefined();
660
+ if (send?.tag === 'Send') {
661
+ const body = send.lines[0]?.text ?? '';
662
+ expect(body).toContain('sts=duration=600');
663
+ expect(body).not.toContain('preload');
664
+ }
665
+ });
666
+
667
+ it('ACKs CAP REQ sts when configured', () => {
668
+ const state = readyState();
669
+ const cfg: ServerConfig = {
670
+ ...serverConfig,
671
+ sts: { duration: 600, port: 6697 },
672
+ };
673
+ const ctx = makeCtx(state, cfg);
674
+ const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);
675
+ const send = out.effects[0];
676
+ expect(send).toBeDefined();
677
+ if (send?.tag === 'Send') {
678
+ const body = send.lines[0]?.text ?? '';
679
+ expect(body).toContain('ACK :sts');
680
+ }
681
+ expect(out.state.caps.has('sts')).toBe(true);
682
+ });
683
+
684
+ it('NAKs CAP REQ sts when no STS policy is configured', () => {
685
+ const state = readyState();
686
+ const ctx = makeCtx(state);
687
+ const out = capReducer(state, { command: 'CAP', params: ['REQ', 'sts'], tags: {} }, ctx);
688
+ const send = out.effects[0];
689
+ expect(send).toBeDefined();
690
+ if (send?.tag === 'Send') {
691
+ const body = send.lines[0]?.text ?? '';
692
+ expect(body).toContain('NAK :sts');
693
+ }
694
+ expect(out.state.caps.has('sts')).toBe(false);
695
+ });
696
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { chathistoryReducer } from '../../src/commands/chathistory';
2
+ import { chathistoryReducer, formatReplayLine } from '../../src/commands/chathistory';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
5
  import {
@@ -396,6 +396,113 @@ describe('chathistoryReducer — BEFORE / AFTER / AROUND / BETWEEN', () => {
396
396
  });
397
397
  });
398
398
 
399
+ // ============================================================================
400
+ // BEFORE — draft/read-marker fallback (no explicit pivot)
401
+ // ============================================================================
402
+ //
403
+ // IRCv3 draft/read-marker integration: when a cap-enabled client omits the
404
+ // pivot msgid on CHATHISTORY BEFORE, the reducer falls back to the
405
+ // connection's per-channel last-read marker. This lets a client resume
406
+ // reading "everything before my last read position" without re-supplying the
407
+ // msgid. When no marker is recorded either, the reducer keeps the legacy
408
+ // 461 ERR_NEEDMOREPARAMS behaviour.
409
+ // ============================================================================
410
+
411
+ describe('chathistoryReducer — BEFORE read-marker fallback', () => {
412
+ it('falls back to the last-read marker when BEFORE omits the pivot', () => {
413
+ const store = new InMemoryMessageStore();
414
+ const seeded = seed(store, '#foo', 5);
415
+ const conn = makeConn();
416
+ conn.lastReadMarkers = new Map<string, string>([['#foo', 'm4']]);
417
+ const chan = makeChan();
418
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
419
+ const ctx = makeCtx(conn, store);
420
+
421
+ const out = chathistoryReducer(
422
+ chan,
423
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
424
+ ctx,
425
+ );
426
+
427
+ expect(out.effects).toEqual<EffectType[]>(
428
+ expectedBatch(
429
+ [
430
+ replayLine(seeded[0] as StoredMessage, '#foo'),
431
+ replayLine(seeded[1] as StoredMessage, '#foo'),
432
+ replayLine(seeded[2] as StoredMessage, '#foo'),
433
+ ],
434
+ 'batch-0',
435
+ '#foo',
436
+ ),
437
+ );
438
+ });
439
+
440
+ it('honours an explicit pivot over the marker when both are supplied', () => {
441
+ const store = new InMemoryMessageStore();
442
+ const seeded = seed(store, '#foo', 5);
443
+ const conn = makeConn();
444
+ // Marker points at m4, but the client explicitly asks for messages before m3.
445
+ conn.lastReadMarkers = new Map<string, string>([['#foo', 'm4']]);
446
+ const chan = makeChan();
447
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
448
+ const ctx = makeCtx(conn, store);
449
+
450
+ const out = chathistoryReducer(
451
+ chan,
452
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo', 'm3', '10'], tags: {} },
453
+ ctx,
454
+ );
455
+
456
+ expect(out.effects).toEqual<EffectType[]>(
457
+ expectedBatch(
458
+ [
459
+ replayLine(seeded[0] as StoredMessage, '#foo'),
460
+ replayLine(seeded[1] as StoredMessage, '#foo'),
461
+ ],
462
+ 'batch-0',
463
+ '#foo',
464
+ ),
465
+ );
466
+ });
467
+
468
+ it('returns 461 when BEFORE omits the pivot and no marker is recorded', () => {
469
+ const store = new InMemoryMessageStore();
470
+ seed(store, '#foo', 3);
471
+ const conn = makeConn(); // no marker
472
+ const chan = makeChan();
473
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
474
+ const ctx = makeCtx(conn, store);
475
+
476
+ const out = chathistoryReducer(
477
+ chan,
478
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
479
+ ctx,
480
+ );
481
+
482
+ expect((out.effects[0] as { lines: RawLine[] }).lines[0]?.text).toContain('461');
483
+ });
484
+
485
+ it('returns an empty batch when the marker points at an evicted msgid', () => {
486
+ const store = new InMemoryMessageStore();
487
+ seed(store, '#foo', 3);
488
+ const conn = makeConn();
489
+ // Marker references a message the store no longer retains; BEFORE must
490
+ // not surface an INVALID_PARAMS error, just an empty result.
491
+ conn.lastReadMarkers = new Map<string, string>([['#foo', 'evicted']]);
492
+ const chan = makeChan();
493
+ chan.members.set('c1', { conn: 'c1', nick: 'alice', op: false, voice: false });
494
+ const ctx = makeCtx(conn, store);
495
+
496
+ const out = chathistoryReducer(
497
+ chan,
498
+ { command: 'CHATHISTORY', params: ['BEFORE', '#foo'], tags: {} },
499
+ ctx,
500
+ );
501
+
502
+ expect(out.effects).toEqual([]);
503
+ });
504
+ });
505
+
399
506
  // ============================================================================
400
507
  // TARGETS
401
508
  // ============================================================================
@@ -994,3 +1101,43 @@ describe('chathistoryReducer — unregistered connection', () => {
994
1101
  );
995
1102
  });
996
1103
  });
1104
+
1105
+ // ===========================================================================
1106
+ // formatReplayLine — clientTags on chathistory replay
1107
+ //
1108
+ // A stored message carrying `clientTags` (e.g. `+draft/multiline` from a
1109
+ // multi-line batch) must surface those tags in the chathistory replay so
1110
+ // message-tags-aware clients can recognise the message kind.
1111
+ // ===========================================================================
1112
+
1113
+ describe('formatReplayLine — clientTags', () => {
1114
+ const baseMsg: StoredMessage = {
1115
+ msgid: 'abc123',
1116
+ time: 1_700_000_000_000,
1117
+ chan: '#foo',
1118
+ command: 'PRIVMSG',
1119
+ nick: 'alice',
1120
+ user: 'alice',
1121
+ host: 'example.com',
1122
+ text: 'line one\nline two',
1123
+ };
1124
+
1125
+ it('appends +draft/multiline to the tag section when clientTags are present', () => {
1126
+ const out = formatReplayLine({ ...baseMsg, clientTags: ['+draft/multiline'] }, '#foo');
1127
+ expect(out.text).toBe(
1128
+ '@time=2023-11-14T22:13:20.000Z;msgid=abc123;+draft/multiline :alice!alice@example.com PRIVMSG #foo :line one\nline two',
1129
+ );
1130
+ });
1131
+
1132
+ it('omits the client-tag section entirely when clientTags is undefined', () => {
1133
+ const out = formatReplayLine(baseMsg, '#foo');
1134
+ expect(out.text).toBe(
1135
+ '@time=2023-11-14T22:13:20.000Z;msgid=abc123 :alice!alice@example.com PRIVMSG #foo :line one\nline two',
1136
+ );
1137
+ });
1138
+
1139
+ it('omits the client-tag section when clientTags is empty', () => {
1140
+ const out = formatReplayLine({ ...baseMsg, clientTags: [] }, '#foo');
1141
+ expect(out.text).not.toContain('+draft/multiline');
1142
+ });
1143
+ });
@@ -73,7 +73,9 @@ describe('echo-message — channel PRIVMSG', () => {
73
73
  );
74
74
 
75
75
  expect(out.effects).toEqual<EffectType[]>([
76
- Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'),
76
+ Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [
77
+ L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),
78
+ ]),
77
79
  ]);
78
80
  });
79
81
 
@@ -90,7 +92,9 @@ describe('echo-message — channel PRIVMSG', () => {
90
92
  );
91
93
 
92
94
  expect(out.effects).toEqual<EffectType[]>([
93
- Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1'),
95
+ Effect.broadcast('#foo', [L(':alice!alice@example.com PRIVMSG #foo :hi')], 'c1', 'msgid', [
96
+ L('@msgid=nonce-0 :alice!alice@example.com PRIVMSG #foo :hi'),
97
+ ]),
94
98
  Effect.send('c1', [L(':alice!alice@example.com PRIVMSG #foo :hi')]),
95
99
  ]);
96
100
  });
@@ -108,7 +112,9 @@ describe('echo-message — channel PRIVMSG', () => {
108
112
  );
109
113
 
110
114
  expect(out.effects).toEqual<EffectType[]>([
111
- Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1'),
115
+ Effect.broadcast('#foo', [L(':alice!alice@example.com NOTICE #foo :hi')], 'c1', 'msgid', [
116
+ L('@msgid=nonce-0 :alice!alice@example.com NOTICE #foo :hi'),
117
+ ]),
112
118
  Effect.send('c1', [L(':alice!alice@example.com NOTICE #foo :hi')]),
113
119
  ]);
114
120
  });
@@ -140,6 +140,12 @@ describe('generateIsupport — token values', () => {
140
140
  const allNoCap = linesNoCap.flatMap((l) => l.text.split(' '));
141
141
  expect(allNoCap).toContain('SAFELIST');
142
142
  });
143
+
144
+ it('advertises MONITOR=<n> with the default monitor cap ceiling', () => {
145
+ const lines = generateIsupport(makeConfig(), new Set<string>());
146
+ const all = lines.flatMap((l) => l.text.split(' '));
147
+ expect(all).toContain('MONITOR=30');
148
+ });
143
149
  });
144
150
 
145
151
  // ============================================================================
@@ -207,3 +213,77 @@ describe('generateIsupport — capability-aware tokens', () => {
207
213
  expect(lines.length).toBeGreaterThan(0);
208
214
  });
209
215
  });
216
+
217
+ // ============================================================================
218
+ // generateIsupport — cap/mode-derived ISUPPORT tokens
219
+ // ============================================================================
220
+
221
+ describe('generateIsupport — MONITOR token (config-derived)', () => {
222
+ it('derives MONITOR=<n> from serverConfig.monitorLimit when set', () => {
223
+ const lines = generateIsupport(makeConfig({ monitorLimit: 50 }), new Set<string>());
224
+ const all = lines.flatMap((l) => l.text.split(' '));
225
+ expect(all).toContain('MONITOR=50');
226
+ });
227
+
228
+ it('falls back to the default monitor ceiling when monitorLimit is unset', () => {
229
+ const lines = generateIsupport(makeConfig(), new Set<string>());
230
+ const all = lines.flatMap((l) => l.text.split(' '));
231
+ expect(all).toContain('MONITOR=30');
232
+ });
233
+ });
234
+
235
+ describe('generateIsupport — STATUSMSG token', () => {
236
+ it('advertises STATUSMSG=@+ (status-msg prefixes mirror PREFIX)', () => {
237
+ const lines = generateIsupport(makeConfig(), new Set<string>());
238
+ const all = lines.flatMap((l) => l.text.split(' '));
239
+ expect(all).toContain('STATUSMSG=@+');
240
+ });
241
+ });
242
+
243
+ describe('generateIsupport — EXTBAN token (config-derived prefix)', () => {
244
+ it('advertises EXTBAN=<prefix>,q with the default extban prefix', () => {
245
+ const lines = generateIsupport(makeConfig(), new Set<string>());
246
+ const all = lines.flatMap((l) => l.text.split(' '));
247
+ expect(all).toContain('EXTBAN=~,q');
248
+ });
249
+
250
+ it('derives the EXTBAN prefix from serverConfig.extbanPrefix', () => {
251
+ const lines = generateIsupport(makeConfig({ extbanPrefix: '$' }), new Set<string>());
252
+ const all = lines.flatMap((l) => l.text.split(' '));
253
+ expect(all).toContain('EXTBAN=$,q');
254
+ });
255
+ });
256
+
257
+ describe('generateIsupport — ACCOUNTEXTBAN token (services-gated)', () => {
258
+ it('omits ACCOUNTEXTBAN when services are not enabled', () => {
259
+ const lines = generateIsupport(makeConfig(), new Set<string>());
260
+ const all = lines.flatMap((l) => l.text.split(' '));
261
+ expect(all.some((t) => t.startsWith('ACCOUNTEXTBAN'))).toBe(false);
262
+ });
263
+
264
+ it('advertises ACCOUNTEXTBAN=a when services are enabled', () => {
265
+ const lines = generateIsupport(makeConfig({ servicesEnabled: true }), new Set<string>());
266
+ const all = lines.flatMap((l) => l.text.split(' '));
267
+ expect(all).toContain('ACCOUNTEXTBAN=a');
268
+ });
269
+ });
270
+
271
+ describe('generateIsupport — draft cap advertisement tokens', () => {
272
+ it('advertises the TYPING token (draft/typing is supported)', () => {
273
+ const lines = generateIsupport(makeConfig(), new Set<string>());
274
+ const all = lines.flatMap((l) => l.text.split(' '));
275
+ expect(all).toContain('TYPING');
276
+ });
277
+
278
+ it('derives MULTILINE=<n> from serverConfig.multilineMaxBytes (draft/multiline)', () => {
279
+ const lines = generateIsupport(makeConfig({ multilineMaxBytes: 8192 }), new Set<string>());
280
+ const all = lines.flatMap((l) => l.text.split(' '));
281
+ expect(all).toContain('MULTILINE=8192');
282
+ });
283
+
284
+ it('falls back to the default multiline byte budget when multilineMaxBytes is unset', () => {
285
+ const lines = generateIsupport(makeConfig(), new Set<string>());
286
+ const all = lines.flatMap((l) => l.text.split(' '));
287
+ expect(all).toContain('MULTILINE=4096');
288
+ });
289
+ });
@@ -1103,3 +1103,75 @@ describe('userModeReducer — write', () => {
1103
1103
  expect(conn.lastSeen).toBe(11_000);
1104
1104
  });
1105
1105
  });
1106
+
1107
+ // ============================================================================
1108
+ // userModeReducer — read-only mode `S` (TLS connected)
1109
+ // ============================================================================
1110
+
1111
+ describe('userModeReducer — read-only mode `S` (TLS connected)', () => {
1112
+ it('emits 221 with +S when the connection has the tls user mode set', () => {
1113
+ const conn = makeConn();
1114
+ conn.userModes.tls = true;
1115
+ const ctx = makeCtx(conn);
1116
+
1117
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);
1118
+
1119
+ expect(out.effects).toEqual<EffectType[]>([
1120
+ Effect.send('c1', [L(':irc.example.com 221 alice +S')]),
1121
+ ]);
1122
+ });
1123
+
1124
+ it('emits 221 with +iS when invisible and tls are both set', () => {
1125
+ const conn = makeConn();
1126
+ conn.userModes.invisible = true;
1127
+ conn.userModes.tls = true;
1128
+ const ctx = makeCtx(conn);
1129
+
1130
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);
1131
+
1132
+ expect(out.effects).toEqual<EffectType[]>([
1133
+ Effect.send('c1', [L(':irc.example.com 221 alice +iS')]),
1134
+ ]);
1135
+ });
1136
+
1137
+ it('rejects MODE nick +S with 501 ERR_UMODEUNKNOWNFLAG (read-only)', () => {
1138
+ const conn = makeConn();
1139
+ const ctx = makeCtx(conn);
1140
+
1141
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+S'], tags: {} }, ctx);
1142
+
1143
+ expect(conn.userModes.tls).toBe(false);
1144
+ expect(out.effects).toEqual<EffectType[]>([
1145
+ Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),
1146
+ ]);
1147
+ });
1148
+
1149
+ it('rejects MODE nick -S with 501 ERR_UMODEUNKNOWNFLAG even when tls is set', () => {
1150
+ const conn = makeConn();
1151
+ conn.userModes.tls = true;
1152
+ const ctx = makeCtx(conn);
1153
+
1154
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-S'], tags: {} }, ctx);
1155
+
1156
+ // The mode stays set: a client cannot clear a transport-owned mode.
1157
+ expect(conn.userModes.tls).toBe(true);
1158
+ expect(out.effects).toEqual<EffectType[]>([
1159
+ Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),
1160
+ ]);
1161
+ });
1162
+
1163
+ it('does not echo a MODE line when only +S was supplied (rejected, no-op)', () => {
1164
+ const conn = makeConn();
1165
+ const ctx = makeCtx(conn);
1166
+
1167
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+iS'], tags: {} }, ctx);
1168
+
1169
+ // `i` is applied (and echoed), `S` is rejected with 501. The echo only
1170
+ // carries the applied `+i`; the 501 is emitted alongside it.
1171
+ expect(conn.userModes.invisible).toBe(true);
1172
+ expect(conn.userModes.tls).toBe(false);
1173
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
1174
+ expect(texts).toContainEqual(L(':alice!alice@example.com MODE alice +i'));
1175
+ expect(texts).toContainEqual(L(':irc.example.com 501 alice :Unknown MODE flag'));
1176
+ });
1177
+ });