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
@@ -169,3 +169,52 @@ describe('loadServerConfigFromCfEnv — failure modes', () => {
169
169
  }
170
170
  });
171
171
  });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // SASL_ACCOUNTS env parsing — malformed entries are skipped, valid ones kept.
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe('loadServerConfigFromCfEnv — SASL_ACCOUNTS parsing', () => {
178
+ it('skips blank lines within the seed', () => {
179
+ const cfg = loadServerConfigFromCfEnv({
180
+ ...baseEnv(),
181
+ SASL_ACCOUNTS: 'alice:secret\n\nbob:password\n \n',
182
+ });
183
+ expect(cfg.saslAccounts).toEqual([
184
+ { username: 'alice', password: 'secret' },
185
+ { username: 'bob', password: 'password' },
186
+ ]);
187
+ });
188
+
189
+ it('skips lines with no colon separator', () => {
190
+ const cfg = loadServerConfigFromCfEnv({
191
+ ...baseEnv(),
192
+ SASL_ACCOUNTS: 'alice:secret\nnocolonhere\nbob:password',
193
+ });
194
+ expect(cfg.saslAccounts).toHaveLength(2);
195
+ });
196
+
197
+ it('skips lines where the colon is at position 0 (empty username)', () => {
198
+ const cfg = loadServerConfigFromCfEnv({
199
+ ...baseEnv(),
200
+ SASL_ACCOUNTS: ':secret\nalice:valid',
201
+ });
202
+ expect(cfg.saslAccounts).toEqual([{ username: 'alice', password: 'valid' }]);
203
+ });
204
+
205
+ it('skips lines with an empty password after the colon', () => {
206
+ const cfg = loadServerConfigFromCfEnv({
207
+ ...baseEnv(),
208
+ SASL_ACCOUNTS: 'alice:\nbob:valid',
209
+ });
210
+ expect(cfg.saslAccounts).toEqual([{ username: 'bob', password: 'valid' }]);
211
+ });
212
+
213
+ it('passes CREATED_AT verbatim when it overflows Number precision', () => {
214
+ // A digit string so large that Number() produces Infinity — the
215
+ // parser must fall back to the raw string rather than emit Infinity.
216
+ const huge = '9'.repeat(400);
217
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: huge });
218
+ expect(cfg.createdAt).toBe(huge);
219
+ });
220
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Pure exported helpers from `connection-do.ts`.
3
+ *
4
+ * These functions are independent of Durable Object state, so they are
5
+ * unit-tested directly without the workerd/miniflare DO harness.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { parseSaslAccountsEnv, wsFrameModeFromTags } from '../src/connection-do';
10
+
11
+ describe('parseSaslAccountsEnv', () => {
12
+ it('returns an empty array for undefined input', () => {
13
+ expect(parseSaslAccountsEnv(undefined)).toEqual([]);
14
+ });
15
+
16
+ it('returns an empty array for an empty string', () => {
17
+ expect(parseSaslAccountsEnv('')).toEqual([]);
18
+ });
19
+
20
+ it('parses a single well-formed entry', () => {
21
+ expect(parseSaslAccountsEnv('alice:secret')).toEqual([
22
+ { username: 'alice', password: 'secret' },
23
+ ]);
24
+ });
25
+
26
+ it('parses multiple newline-delimited entries', () => {
27
+ expect(parseSaslAccountsEnv('alice:secret\nbob:pw')).toEqual([
28
+ { username: 'alice', password: 'secret' },
29
+ { username: 'bob', password: 'pw' },
30
+ ]);
31
+ });
32
+
33
+ it('skips blank lines', () => {
34
+ expect(parseSaslAccountsEnv('alice:secret\n\nbob:pw\n \n')).toEqual([
35
+ { username: 'alice', password: 'secret' },
36
+ { username: 'bob', password: 'pw' },
37
+ ]);
38
+ });
39
+
40
+ it('skips lines with no colon separator', () => {
41
+ expect(parseSaslAccountsEnv('alice:secret\nnocolonhere\nbob:pw')).toEqual([
42
+ { username: 'alice', password: 'secret' },
43
+ { username: 'bob', password: 'pw' },
44
+ ]);
45
+ });
46
+
47
+ it('skips lines where the colon is at position 0 (empty username)', () => {
48
+ expect(parseSaslAccountsEnv(':secret\nalice:valid')).toEqual([
49
+ { username: 'alice', password: 'valid' },
50
+ ]);
51
+ });
52
+
53
+ it('skips lines with an empty password after the colon', () => {
54
+ expect(parseSaslAccountsEnv('alice:\nbob:valid')).toEqual([
55
+ { username: 'bob', password: 'valid' },
56
+ ]);
57
+ });
58
+
59
+ it('returns an empty array when every line is malformed', () => {
60
+ expect(parseSaslAccountsEnv('\n \n:no-user\nuser:')).toEqual([]);
61
+ });
62
+ });
63
+
64
+ describe('wsFrameModeFromTags', () => {
65
+ it("returns 'legacy' when no tags are present", () => {
66
+ expect(wsFrameModeFromTags([])).toBe('legacy');
67
+ });
68
+
69
+ it("returns 'legacy' when no recognized tag matches", () => {
70
+ // Unknown tags (e.g. from an older deployment's tag scheme) fall
71
+ // back to the legacy-tolerant mode.
72
+ expect(wsFrameModeFromTags(['unknown:tag', 'another'])).toBe('legacy');
73
+ });
74
+
75
+ it("returns 'spec-text' when the spec-text tag is present", () => {
76
+ expect(wsFrameModeFromTags(['ws:spec-text'])).toBe('spec-text');
77
+ });
78
+
79
+ it("returns 'spec-binary' when the spec-binary tag is present", () => {
80
+ expect(wsFrameModeFromTags(['ws:spec-binary'])).toBe('spec-binary');
81
+ });
82
+
83
+ it("returns 'legacy' when the legacy tag is present", () => {
84
+ expect(wsFrameModeFromTags(['ws:legacy'])).toBe('legacy');
85
+ });
86
+
87
+ it('picks the first recognized tag when multiple are present', () => {
88
+ // The scan is order-sensitive; the first matching tag wins.
89
+ expect(wsFrameModeFromTags(['ws:spec-text', 'ws:legacy'])).toBe('spec-text');
90
+ });
91
+ });
@@ -224,3 +224,51 @@ describe('resolveAccountStore precedence', () => {
224
224
  ).toBe(true);
225
225
  });
226
226
  });
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // resolveAccountStore seed parsing — malformed entries are skipped so a
230
+ // trailing newline or partial edit does not break boot.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ describe('resolveAccountStore — SASL_ACCOUNTS seed edge cases', () => {
234
+ it('skips blank lines within the seed', async () => {
235
+ const store = await resolveAccountStore(undefined, 'alice:secret\n\nbob:pw\n \n');
236
+ expect(store).toBeDefined();
237
+ expect(
238
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
239
+ ).toBe(true);
240
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'pw' }).ok).toBe(
241
+ true,
242
+ );
243
+ });
244
+
245
+ it('skips lines with no colon separator', async () => {
246
+ const store = await resolveAccountStore(undefined, 'alice:secret\nnocolon\nbob:pw');
247
+ // Only the two well-formed entries are loaded.
248
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'nocolon', password: '' }).ok).toBe(
249
+ false,
250
+ );
251
+ expect(
252
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' }).ok,
253
+ ).toBe(true);
254
+ });
255
+
256
+ it('skips lines where the colon is at position 0', async () => {
257
+ const store = await resolveAccountStore(undefined, ':secret\nalice:valid');
258
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'valid' }).ok).toBe(
259
+ true,
260
+ );
261
+ });
262
+
263
+ it('skips lines with an empty password', async () => {
264
+ const store = await resolveAccountStore(undefined, 'alice:\nbob:valid');
265
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'valid' }).ok).toBe(
266
+ true,
267
+ );
268
+ });
269
+
270
+ it('returns undefined when every line is malformed', async () => {
271
+ const store = await resolveAccountStore(undefined, '\n \n:no-user\nuser:');
272
+ expect(store).toBeUndefined();
273
+ });
274
+ });
@@ -53,6 +53,7 @@ interface RegistryRpc {
53
53
  changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
54
54
  releaseNick(nick: string): Promise<void>;
55
55
  lookupNick(nick: string): Promise<string | null>;
56
+ listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
56
57
  }
57
58
 
58
59
  function rpc(stub: DurableObjectStub): RegistryRpc {
@@ -333,3 +334,106 @@ describe('RegistryDO — shard isolation', () => {
333
334
  expect(await rpc(i).lookupNick('in-shard-31')).toBeNull();
334
335
  });
335
336
  });
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // changeNick — direct contract tests (sharding is a CfRuntime concern; the
340
+ // DO method itself operates on whatever keys it is given, so we address a
341
+ // single DO instance directly to exercise every branch without depending on
342
+ // the FNV-1a hash landing two nicks in the same shard).
343
+ // ---------------------------------------------------------------------------
344
+
345
+ describe('RegistryDO — changeNick branch coverage (direct on one shard)', () => {
346
+ // Pin every test to a single, stable shard instance. The DO method is
347
+ // agnostic to which shard it lives on; what matters is the storage
348
+ // state we put in place before the call.
349
+ const shard = () => shardByIndex(1);
350
+
351
+ it('returns false when the new nick is held by a different conn', async () => {
352
+ const s = shard();
353
+ // Seed storage so both `taken` and `current` are owned by different
354
+ // conns on this same DO.
355
+ expect(await rpc(s).reserveNick('taken-new', 'conn-owner')).toEqual({ ok: true });
356
+ expect(await rpc(s).reserveNick('current-old', 'conn-changer')).toEqual({ ok: true });
357
+
358
+ const ok = await rpc(s).changeNick('conn-changer', 'current-old', 'taken-new');
359
+ expect(ok).toBe(false);
360
+ // State untouched: owners stay as they were.
361
+ expect(await rpc(s).lookupNick('taken-new')).toBe('conn-owner');
362
+ expect(await rpc(s).lookupNick('current-old')).toBe('conn-changer');
363
+ });
364
+
365
+ it('clears the old nick when it still points at the caller', async () => {
366
+ const s = shard();
367
+ expect(await rpc(s).reserveNick('cn-clear-old', 'conn-self')).toEqual({ ok: true });
368
+
369
+ const ok = await rpc(s).changeNick('conn-self', 'cn-clear-old', 'cn-clear-new');
370
+ expect(ok).toBe(true);
371
+ expect(await rpc(s).lookupNick('cn-clear-old')).toBeNull();
372
+ expect(await rpc(s).lookupNick('cn-clear-new')).toBe('conn-self');
373
+ });
374
+
375
+ it('leaves a stale old nick alone when it no longer points at the caller', async () => {
376
+ // Crash-recovery / race defence: another conn grabbed `old` between
377
+ // our reserve and our changeNick. The conditional delete must not
378
+ // clobber that third party.
379
+ const s = shard();
380
+ // `old` is currently held by `conn-other`, not by `conn-caller`.
381
+ expect(await rpc(s).reserveNick('cn-stale-old', 'conn-other')).toEqual({ ok: true });
382
+
383
+ const ok = await rpc(s).changeNick('conn-caller', 'cn-stale-old', 'cn-stale-new');
384
+ expect(ok).toBe(true);
385
+ // New nick reserved for the caller...
386
+ expect(await rpc(s).lookupNick('cn-stale-new')).toBe('conn-caller');
387
+ // ...but `old` is untouched because it never pointed at the caller.
388
+ expect(await rpc(s).lookupNick('cn-stale-old')).toBe('conn-other');
389
+ });
390
+ });
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // listEntries — backs the global WALLOPS fan-out (CfRuntime.broadcastWallops
394
+ // walks every shard via this method).
395
+ // ---------------------------------------------------------------------------
396
+
397
+ describe('RegistryDO — listEntries', () => {
398
+ it('returns an empty array for a fresh shard', async () => {
399
+ expect(await rpc(shardByIndex(2)).listEntries()).toEqual([]);
400
+ });
401
+
402
+ it('enumerates every (nickLower, connId) pair in this shard', async () => {
403
+ const s = shardByIndex(3);
404
+ await rpc(s).reserveNick('Alice', 'conn-a');
405
+ await rpc(s).reserveNick('Bob', 'conn-b');
406
+ await rpc(s).reserveNick('Carol', 'conn-c');
407
+
408
+ const entries = await rpc(s).listEntries();
409
+ // Keys are lowercased in storage; the strip recovers the lowercased
410
+ // nick (matches what CfRuntime expects when deduping conn ids).
411
+ const sorted = [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
412
+ expect(sorted).toEqual([
413
+ ['alice', 'conn-a'],
414
+ ['bob', 'conn-b'],
415
+ ['carol', 'conn-c'],
416
+ ]);
417
+ });
418
+
419
+ it('reflects releases: a freed nick drops out of the listing', async () => {
420
+ const s = shardByIndex(4);
421
+ await rpc(s).reserveNick('keep', 'conn-k');
422
+ await rpc(s).reserveNick('drop', 'conn-d');
423
+ await rpc(s).releaseNick('drop');
424
+
425
+ const entries = await rpc(s).listEntries();
426
+ expect(entries).toEqual([['keep', 'conn-k']]);
427
+ });
428
+
429
+ it('strips the storage prefix so callers see a bare nickLower', async () => {
430
+ const s = shardByIndex(5);
431
+ await rpc(s).reserveNick('prefixed', 'conn-p');
432
+ const entries = await rpc(s).listEntries();
433
+ const nickLower = entries[0]?.[0];
434
+ // The internal key is `nick:prefixed`; the public API must not leak
435
+ // that implementation detail.
436
+ expect(nickLower).toBe('prefixed');
437
+ expect(nickLower?.startsWith('nick:')).toBe(false);
438
+ });
439
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `serialize` / `deserialize` / `migrate`.
3
+ *
4
+ * These helpers bridge {@link ConnectionState} (which carries `Set` and
5
+ * `Map` fields that degrade under `JSON.stringify`) and the JSON-safe
6
+ * {@link PersistedConnectionState} shape stored in DO transactional KV.
7
+ *
8
+ * Coverage focus: every optional-field branch in both directions, the
9
+ * `secure` / `userModes.tls` backfill paths in `migrate`, and the
10
+ * rejection paths (non-object record, too-new version).
11
+ */
12
+
13
+ import { type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
14
+ import { describe, expect, it } from 'vitest';
15
+ import { PERSISTED_STATE_VERSION, deserialize, migrate, serialize } from '../src/serialize';
16
+
17
+ /** Builds a fully-populated connection state for round-trip assertions. */
18
+ function fullState(): ConnectionState {
19
+ const s = createConnection({ id: 'conn-1', connectedSince: 100 });
20
+ s.registration = 'registered';
21
+ s.capNegotiating = false;
22
+ s.secure = true;
23
+ s.nick = 'Alice';
24
+ s.user = 'alice';
25
+ s.host = 'example.com';
26
+ s.realname = 'Alice Example';
27
+ s.passAttempt = 'hunter2';
28
+ s.account = 'alice-account';
29
+ s.away = 'brb';
30
+ s.caps = new Set(['server-time', 'message-tags']);
31
+ s.joinedChannels = new Set(['#one', '#two']);
32
+ s.userModes = {
33
+ invisible: true,
34
+ oper: false,
35
+ wallops: true,
36
+ serverNotices: false,
37
+ tls: true,
38
+ };
39
+ s.lastSeen = 200;
40
+ return s;
41
+ }
42
+
43
+ describe('serialize — round trip', () => {
44
+ it('survives serialize → deserialize for a fully-populated state', () => {
45
+ const original = fullState();
46
+ const round = deserialize(serialize(original));
47
+ // Sets are rebuilt with the same members.
48
+ expect(round.caps).toEqual(new Set(['server-time', 'message-tags']));
49
+ expect(round.joinedChannels).toEqual(new Set(['#one', '#two']));
50
+ // Optional fields preserved.
51
+ expect(round.nick).toBe('Alice');
52
+ expect(round.user).toBe('alice');
53
+ expect(round.host).toBe('example.com');
54
+ expect(round.realname).toBe('Alice Example');
55
+ expect(round.passAttempt).toBe('hunter2');
56
+ expect(round.account).toBe('alice-account');
57
+ expect(round.away).toBe('brb');
58
+ // userModes cloned (mutating the round trip must not leak back).
59
+ expect(round.userModes).toEqual(original.userModes);
60
+ expect(round.userModes).not.toBe(original.userModes);
61
+ expect(round.secure).toBe(true);
62
+ });
63
+
64
+ it('is JSON-stable: serialize → JSON.stringify → JSON.parse → deserialize', () => {
65
+ // This is the actual production path through DO storage: the runtime
66
+ // structured-clones the value, but the persisted shape must also be
67
+ // JSON-tolerant for older storage rows and snapshots written to R2.
68
+ const original = fullState();
69
+ const json = JSON.stringify(serialize(original));
70
+ const round = deserialize(JSON.parse(json));
71
+ expect(round.caps).toEqual(original.caps);
72
+ expect(round.nick).toBe(original.nick);
73
+ expect(round.away).toBe(original.away);
74
+ });
75
+
76
+ it('stamps the current persisted schema version', () => {
77
+ expect(serialize(fullState()).version).toBe(PERSISTED_STATE_VERSION);
78
+ });
79
+ });
80
+
81
+ describe('serialize — optional-field branches', () => {
82
+ it('omits host when undefined', () => {
83
+ const s = createConnection({ id: 'c', connectedSince: 0 });
84
+ s.nick = 'n';
85
+ expect(serialize(s).host).toBeUndefined();
86
+ });
87
+
88
+ it('omits passAttempt when undefined', () => {
89
+ const s = createConnection({ id: 'c', connectedSince: 0 });
90
+ expect(serialize(s).passAttempt).toBeUndefined();
91
+ });
92
+
93
+ it('omits away when undefined', () => {
94
+ const s = createConnection({ id: 'c', connectedSince: 0 });
95
+ expect(serialize(s).away).toBeUndefined();
96
+ });
97
+
98
+ it('clones caps and joinedChannels arrays away from the live Set', () => {
99
+ const s = createConnection({ id: 'c', connectedSince: 0 });
100
+ s.caps = new Set(['a']);
101
+ s.joinedChannels = new Set(['#x']);
102
+ const out = serialize(s);
103
+ expect(out.caps).toEqual(['a']);
104
+ expect(out.joinedChannels).toEqual(['#x']);
105
+ });
106
+ });
107
+
108
+ describe('deserialize — optional-field branches', () => {
109
+ it('populates host when the persisted record carries it', () => {
110
+ const s = createConnection({ id: 'c', connectedSince: 0 });
111
+ s.host = 'host.example';
112
+ expect(deserialize(serialize(s)).host).toBe('host.example');
113
+ });
114
+
115
+ it('populates passAttempt when the persisted record carries it', () => {
116
+ const s = createConnection({ id: 'c', connectedSince: 0 });
117
+ s.passAttempt = 'pw';
118
+ expect(deserialize(serialize(s)).passAttempt).toBe('pw');
119
+ });
120
+
121
+ it('populates account when the persisted record carries it', () => {
122
+ const s = createConnection({ id: 'c', connectedSince: 0 });
123
+ s.account = 'acc';
124
+ expect(deserialize(serialize(s)).account).toBe('acc');
125
+ });
126
+
127
+ it('populates away when the persisted record carries it', () => {
128
+ const s = createConnection({ id: 'c', connectedSince: 0 });
129
+ s.away = 'afk';
130
+ expect(deserialize(serialize(s)).away).toBe('afk');
131
+ });
132
+
133
+ it('leaves optional fields absent on a minimal pre-registration state', () => {
134
+ const s = createConnection({ id: 'c', connectedSince: 0 });
135
+ const round = deserialize(serialize(s));
136
+ expect(round.nick).toBeUndefined();
137
+ expect(round.host).toBeUndefined();
138
+ expect(round.away).toBeUndefined();
139
+ expect(round.account).toBeUndefined();
140
+ expect(round.passAttempt).toBeUndefined();
141
+ });
142
+ });
143
+
144
+ describe('migrate — backfills additive fields', () => {
145
+ it('defaults `secure` to false when the field is absent', () => {
146
+ // Pre-feature record: no `secure` key at all.
147
+ const legacy = {
148
+ version: 1,
149
+ id: 'c',
150
+ registration: 'registered' as const,
151
+ capNegotiating: false,
152
+ caps: [],
153
+ joinedChannels: [],
154
+ userModes: {
155
+ invisible: false,
156
+ oper: false,
157
+ wallops: false,
158
+ serverNotices: false,
159
+ },
160
+ lastSeen: 0,
161
+ connectedSince: 0,
162
+ };
163
+ const migrated = migrate(legacy);
164
+ expect(migrated.secure).toBe(false);
165
+ // The mutation landed on the original object reference (defensive
166
+ // backfill writes through to the record).
167
+ expect((legacy as Record<string, unknown>).secure).toBe(false);
168
+ });
169
+
170
+ it('defaults `userModes.tls` to false when the field is absent', () => {
171
+ const legacy = {
172
+ version: 1,
173
+ id: 'c',
174
+ registration: 'registered' as const,
175
+ capNegotiating: false,
176
+ caps: [],
177
+ joinedChannels: [],
178
+ userModes: {
179
+ invisible: false,
180
+ oper: false,
181
+ wallops: false,
182
+ serverNotices: false,
183
+ },
184
+ lastSeen: 0,
185
+ connectedSince: 0,
186
+ };
187
+ const migrated = migrate(legacy);
188
+ expect(migrated.userModes.tls).toBe(false);
189
+ });
190
+
191
+ it('leaves an explicit `secure: true` untouched', () => {
192
+ const rec = {
193
+ version: 1,
194
+ id: 'c',
195
+ registration: 'registered' as const,
196
+ capNegotiating: false,
197
+ secure: true,
198
+ caps: [],
199
+ joinedChannels: [],
200
+ userModes: {
201
+ invisible: false,
202
+ oper: false,
203
+ wallops: false,
204
+ serverNotices: false,
205
+ tls: true,
206
+ },
207
+ lastSeen: 0,
208
+ connectedSince: 0,
209
+ };
210
+ expect(migrate(rec).secure).toBe(true);
211
+ expect(migrate(rec).userModes.tls).toBe(true);
212
+ });
213
+
214
+ it('tolerates a missing `userModes` object without throwing', () => {
215
+ // A record corrupted in storage (or hand-built by an older code
216
+ // path) might not carry userModes at all. Migrate must not crash.
217
+ const rec = {
218
+ version: 1,
219
+ id: 'c',
220
+ registration: 'registered' as const,
221
+ capNegotiating: false,
222
+ caps: [],
223
+ joinedChannels: [],
224
+ lastSeen: 0,
225
+ connectedSince: 0,
226
+ };
227
+ expect(() => migrate(rec)).not.toThrow();
228
+ });
229
+ });
230
+
231
+ describe('migrate — rejection paths', () => {
232
+ it('rejects a non-object record', () => {
233
+ for (const bad of [null, 'string', 42, true, undefined]) {
234
+ expect(() => migrate(bad)).toThrow(/not an object/u);
235
+ }
236
+ });
237
+
238
+ it('rejects an array record (arrays are not plain objects)', () => {
239
+ expect(() => migrate([])).toThrow(/not an object/u);
240
+ });
241
+
242
+ it('rejects a record persisted with a newer schema version', () => {
243
+ const future = {
244
+ version: PERSISTED_STATE_VERSION + 1,
245
+ id: 'c',
246
+ registration: 'registered' as const,
247
+ capNegotiating: false,
248
+ caps: [],
249
+ joinedChannels: [],
250
+ userModes: {
251
+ invisible: false,
252
+ oper: false,
253
+ wallops: false,
254
+ serverNotices: false,
255
+ tls: false,
256
+ },
257
+ lastSeen: 0,
258
+ connectedSince: 0,
259
+ };
260
+ expect(() => migrate(future)).toThrow(/newer than/u);
261
+ });
262
+
263
+ it('treats a missing version as the legacy default (0)', () => {
264
+ // No `version` field — old shape. Migrate must accept it rather
265
+ // than fail the boot path; the backfill covers the additive keys.
266
+ const legacy = {
267
+ id: 'c',
268
+ registration: 'registered' as const,
269
+ capNegotiating: false,
270
+ caps: [],
271
+ joinedChannels: [],
272
+ userModes: {
273
+ invisible: false,
274
+ oper: false,
275
+ wallops: false,
276
+ serverNotices: false,
277
+ },
278
+ lastSeen: 0,
279
+ connectedSince: 0,
280
+ };
281
+ expect(() => migrate(legacy)).not.toThrow();
282
+ });
283
+ });
284
+
285
+ describe('deserialize — migrate integration', () => {
286
+ it('backfills `secure` and `userModes.tls` on a legacy record, then materializes', () => {
287
+ const legacy = {
288
+ id: 'c',
289
+ registration: 'registered' as const,
290
+ capNegotiating: false,
291
+ caps: ['server-time'],
292
+ joinedChannels: ['#x'],
293
+ userModes: {
294
+ invisible: false,
295
+ oper: false,
296
+ wallops: true,
297
+ serverNotices: false,
298
+ },
299
+ lastSeen: 5,
300
+ connectedSince: 1,
301
+ };
302
+ const state = deserialize(legacy);
303
+ expect(state.secure).toBe(false);
304
+ expect(state.userModes.tls).toBe(false);
305
+ expect(state.userModes.wallops).toBe(true);
306
+ expect(state.caps).toEqual(new Set(['server-time']));
307
+ });
308
+ });
@@ -5,7 +5,7 @@ export default defineConfig({
5
5
  test: {
6
6
  include: ['tests/**/*.test.ts'],
7
7
  coverage: {
8
- provider: 'v8',
8
+ provider: 'istanbul',
9
9
  all: false,
10
10
  include: ['src/**/*.ts'],
11
11
  exclude: ['src/**/index.ts'],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",