serverless-ircd 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/.github/workflows/ci.yml +9 -4
  2. package/.gitmodules +4 -1
  3. package/CHANGELOG.md +480 -22
  4. package/README.md +244 -57
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/origin-allowlist.ts +99 -0
  11. package/apps/cf-worker/src/worker.ts +89 -9
  12. package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
  13. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  14. package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
  15. package/apps/cf-worker/tests/smoke.test.ts +28 -2
  16. package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
  17. package/apps/cf-worker/wrangler.test.toml +23 -0
  18. package/apps/cf-worker/wrangler.toml +56 -0
  19. package/apps/local-cli/package.json +1 -1
  20. package/apps/local-cli/src/config-loader.ts +8 -0
  21. package/apps/local-cli/src/main.ts +16 -0
  22. package/apps/local-cli/src/server.ts +1 -0
  23. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  24. package/apps/web/landing/index.html +215 -0
  25. package/apps/web/package.json +28 -0
  26. package/apps/web/scripts/build.mjs +132 -0
  27. package/apps/web/src/build-env.ts +43 -0
  28. package/apps/web/src/config-schema.ts +138 -0
  29. package/apps/web/static/config.json +28 -0
  30. package/apps/web/static/config.prod.json +28 -0
  31. package/apps/web/static/config.staging.json +28 -0
  32. package/apps/web/tests/build-env.test.ts +63 -0
  33. package/apps/web/tests/build-smoke.test.ts +103 -0
  34. package/apps/web/tests/config-schema.test.ts +432 -0
  35. package/apps/web/tests/workspace.test.ts +12 -0
  36. package/apps/web/tsconfig.json +10 -0
  37. package/apps/web/upstream/.browserslistrc +3 -0
  38. package/apps/web/upstream/.editorconfig +9 -0
  39. package/apps/web/upstream/.eslintignore +3 -0
  40. package/apps/web/upstream/.eslintrc.js +90 -0
  41. package/apps/web/upstream/.github/FUNDING.yml +12 -0
  42. package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  43. package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  44. package/apps/web/upstream/.github/workflows/push.yml +26 -0
  45. package/apps/web/upstream/.github/workflows/release.yml +33 -0
  46. package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
  47. package/apps/web/upstream/.prettierrc.js +9 -0
  48. package/apps/web/upstream/.stylelintignore +5 -0
  49. package/apps/web/upstream/.stylelintrc.js +21 -0
  50. package/apps/web/upstream/LICENSE +201 -0
  51. package/apps/web/upstream/README.md +76 -0
  52. package/apps/web/upstream/babel.config.js +25 -0
  53. package/apps/web/upstream/crowdin.yml +3 -0
  54. package/apps/web/upstream/index.html +35 -0
  55. package/apps/web/upstream/jest.config.js +7 -0
  56. package/apps/web/upstream/jsdoc.json +22 -0
  57. package/apps/web/upstream/package.json +80 -0
  58. package/apps/web/upstream/scripts/pre-commit +43 -0
  59. package/apps/web/upstream/src/components/App.vue +516 -0
  60. package/apps/web/upstream/src/components/AppSettings.vue +587 -0
  61. package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
  62. package/apps/web/upstream/src/components/Avatar.vue +105 -0
  63. package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
  64. package/apps/web/upstream/src/components/BufferKey.vue +121 -0
  65. package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
  66. package/apps/web/upstream/src/components/Captcha.vue +85 -0
  67. package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
  68. package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
  69. package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
  70. package/apps/web/upstream/src/components/ChannelList.vue +345 -0
  71. package/apps/web/upstream/src/components/Container.vue +401 -0
  72. package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
  73. package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
  74. package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
  75. package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
  76. package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
  77. package/apps/web/upstream/src/components/MessageList.vue +1028 -0
  78. package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
  79. package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
  80. package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
  81. package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
  82. package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
  83. package/apps/web/upstream/src/components/Nicklist.vue +309 -0
  84. package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
  85. package/apps/web/upstream/src/components/NotConnected.vue +271 -0
  86. package/apps/web/upstream/src/components/SelfUser.vue +283 -0
  87. package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
  88. package/apps/web/upstream/src/components/ServerView.vue +126 -0
  89. package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
  90. package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
  91. package/apps/web/upstream/src/components/Sidebar.vue +429 -0
  92. package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
  93. package/apps/web/upstream/src/components/SidebarState.vue +145 -0
  94. package/apps/web/upstream/src/components/StartupError.vue +35 -0
  95. package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
  96. package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
  97. package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
  98. package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
  99. package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
  100. package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
  101. package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
  102. package/apps/web/upstream/src/components/UserBox.vue +728 -0
  103. package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
  104. package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
  105. package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
  106. package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
  107. package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
  108. package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
  109. package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
  110. package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
  111. package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
  112. package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
  113. package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
  114. package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
  115. package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
  116. package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
  117. package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
  118. package/apps/web/upstream/src/helpers/Colours.js +128 -0
  119. package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
  120. package/apps/web/upstream/src/helpers/Md5.js +193 -0
  121. package/apps/web/upstream/src/helpers/Misc.js +401 -0
  122. package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
  123. package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
  124. package/apps/web/upstream/src/libs/AudioManager.js +60 -0
  125. package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
  126. package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
  127. package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
  128. package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
  129. package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
  130. package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
  131. package/apps/web/upstream/src/libs/IPC.js +51 -0
  132. package/apps/web/upstream/src/libs/InputHandler.js +921 -0
  133. package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
  134. package/apps/web/upstream/src/libs/Logger.js +71 -0
  135. package/apps/web/upstream/src/libs/Message.js +169 -0
  136. package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
  137. package/apps/web/upstream/src/libs/MessageParser.js +249 -0
  138. package/apps/web/upstream/src/libs/Notifications.js +78 -0
  139. package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
  140. package/apps/web/upstream/src/libs/ServerSession.js +102 -0
  141. package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
  142. package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
  143. package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
  144. package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
  145. package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
  146. package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
  147. package/apps/web/upstream/src/libs/bufferTools.js +177 -0
  148. package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
  149. package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
  150. package/apps/web/upstream/src/libs/settingTools.js +31 -0
  151. package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
  152. package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
  153. package/apps/web/upstream/src/libs/state/UserState.js +89 -0
  154. package/apps/web/upstream/src/libs/state/common.js +26 -0
  155. package/apps/web/upstream/src/libs/state.js +961 -0
  156. package/apps/web/upstream/src/libs/storage/Local.js +51 -0
  157. package/apps/web/upstream/src/main.js +566 -0
  158. package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
  159. package/apps/web/upstream/src/res/configTemplates.js +373 -0
  160. package/apps/web/upstream/src/res/globalStyle.css +277 -0
  161. package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
  162. package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
  163. package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
  164. package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
  165. package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
  166. package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
  167. package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
  168. package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
  169. package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
  170. package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
  171. package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
  172. package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
  173. package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
  174. package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
  175. package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
  176. package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
  177. package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
  178. package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
  179. package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
  180. package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
  181. package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
  182. package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
  183. package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
  184. package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
  185. package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
  186. package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
  187. package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
  188. package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
  189. package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
  190. package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
  191. package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
  192. package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
  193. package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
  194. package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
  195. package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
  196. package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
  197. package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
  198. package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
  199. package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
  200. package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
  201. package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
  202. package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
  203. package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
  204. package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
  205. package/apps/web/upstream/src/res/localesList.json +27 -0
  206. package/apps/web/upstream/src/res/logo.png +0 -0
  207. package/apps/web/upstream/src/thirdparty/about.html +43 -0
  208. package/apps/web/upstream/src/thirdparty/index.js +5 -0
  209. package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
  210. package/apps/web/upstream/static/config.json +28 -0
  211. package/apps/web/upstream/static/emoticons/smile.png +0 -0
  212. package/apps/web/upstream/static/favicon.png +0 -0
  213. package/apps/web/upstream/static/highlight.mp3 +0 -0
  214. package/apps/web/upstream/static/highlight.ogg +0 -0
  215. package/apps/web/upstream/static/locales/.gitignore +2 -0
  216. package/apps/web/upstream/static/plugins/customise.html.example +10 -0
  217. package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
  218. package/apps/web/upstream/static/themes/common/base.css +1064 -0
  219. package/apps/web/upstream/static/themes/dark/theme.css +196 -0
  220. package/apps/web/upstream/static/themes/default/theme.css +92 -0
  221. package/apps/web/upstream/static/themes/elite/theme.css +248 -0
  222. package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
  223. package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
  224. package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
  225. package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
  226. package/apps/web/upstream/static/themes/sky/theme.css +107 -0
  227. package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
  228. package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
  229. package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
  230. package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
  231. package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
  232. package/apps/web/upstream/vue.config.js +150 -0
  233. package/apps/web/upstream/yarn.lock +9767 -0
  234. package/apps/web/vitest.config.ts +13 -0
  235. package/biome.json +1 -0
  236. package/docs/AWS-Deployment.md +21 -8
  237. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  238. package/docs/Home.md +1 -0
  239. package/docs/PlanExtensions.md +113 -3
  240. package/docs/Release-Process.md +23 -13
  241. package/docs/Services.md +546 -0
  242. package/docs/WebClientGuide.md +536 -0
  243. package/package.json +2 -2
  244. package/packages/aws-adapter/package.json +1 -1
  245. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  246. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  247. package/packages/aws-adapter/src/config-loader.ts +11 -0
  248. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  249. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  250. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  251. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  252. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  253. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  254. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  255. package/packages/aws-adapter/src/index.ts +4 -0
  256. package/packages/aws-adapter/src/stats.ts +6 -1
  257. package/packages/aws-adapter/src/tables.ts +34 -4
  258. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  259. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  260. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  261. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  262. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  263. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  264. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  265. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  266. package/packages/cf-adapter/package.json +1 -1
  267. package/packages/cf-adapter/src/config-loader.ts +11 -0
  268. package/packages/cf-adapter/src/connection-do.ts +112 -2
  269. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  270. package/packages/cf-adapter/src/env.ts +8 -0
  271. package/packages/cf-adapter/src/index.ts +5 -0
  272. package/packages/cf-adapter/tests/cf-harness.ts +12 -1
  273. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  274. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  275. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  276. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  277. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  278. package/packages/in-memory-runtime/package.json +1 -1
  279. package/packages/irc-core/package.json +1 -1
  280. package/packages/irc-core/reports/mutation/mutation.html +342 -0
  281. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  282. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  283. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  284. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  285. package/packages/irc-core/src/commands/index.ts +12 -0
  286. package/packages/irc-core/src/commands/join.ts +164 -8
  287. package/packages/irc-core/src/commands/markread.ts +202 -0
  288. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  289. package/packages/irc-core/src/commands/mode.ts +96 -4
  290. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  291. package/packages/irc-core/src/commands/oper.ts +18 -1
  292. package/packages/irc-core/src/commands/operserv.ts +346 -0
  293. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  294. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  295. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  296. package/packages/irc-core/src/commands/registration.ts +61 -6
  297. package/packages/irc-core/src/commands/sasl.ts +18 -49
  298. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  299. package/packages/irc-core/src/commands/topic.ts +37 -0
  300. package/packages/irc-core/src/config.ts +36 -5
  301. package/packages/irc-core/src/effects.ts +56 -1
  302. package/packages/irc-core/src/ports.ts +1653 -84
  303. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  304. package/packages/irc-core/src/state/channel.ts +21 -1
  305. package/packages/irc-core/src/state/connection.ts +25 -1
  306. package/packages/irc-core/src/types.ts +48 -12
  307. package/packages/irc-core/tests/batch.test.ts +15 -0
  308. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  309. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  310. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  311. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  312. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  313. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  314. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  315. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  316. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  317. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  318. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  319. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  320. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  321. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  322. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  323. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  324. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  325. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  326. package/packages/irc-core/tests/config.test.ts +49 -5
  327. package/packages/irc-core/tests/effects.test.ts +19 -0
  328. package/packages/irc-core/tests/message-store.test.ts +63 -0
  329. package/packages/irc-core/tests/numerics.test.ts +13 -0
  330. package/packages/irc-core/tests/parser.test.ts +109 -0
  331. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  332. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  333. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  334. package/packages/irc-server/package.json +1 -1
  335. package/packages/irc-server/src/actor.ts +71 -16
  336. package/packages/irc-server/src/dispatch.ts +94 -7
  337. package/packages/irc-server/src/routing.ts +19 -0
  338. package/packages/irc-server/tests/actor.test.ts +623 -12
  339. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  340. package/packages/irc-server/tests/routing.test.ts +6 -0
  341. package/packages/irc-test-support/package.json +1 -1
  342. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  343. package/packages/irc-test-support/src/index.ts +1 -0
  344. package/packages/irc-test-support/src/scenarios.ts +24 -2
  345. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  346. package/tools/ci-hardening/package.json +1 -1
  347. package/tools/load-test/package.json +1 -1
  348. package/tools/tcp-ws-forwarder/package.json +1 -1
  349. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  350. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -7,13 +7,14 @@
7
7
  */
8
8
 
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
+ import { GetCommand } from '@aws-sdk/lib-dynamodb';
10
11
  import {
11
12
  InMemoryAccountStore,
12
13
  type ParsedServerConfig,
13
14
  StaticMotdProvider,
14
15
  } from '@serverless-ircd/irc-core';
15
16
  import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
16
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
18
  import { AwsRuntime } from '../src/aws-runtime.js';
18
19
  import type { PostToConnection } from '../src/aws-runtime.js';
19
20
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -156,6 +157,23 @@ describe.skipIf(!available)('handlers', () => {
156
157
  expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
157
158
  });
158
159
 
160
+ it('echoes text.ircv3.net (never binary) when binary is offered first', async () => {
161
+ // Reproduces the production 1003 close: the relay offers binary first,
162
+ // but API Gateway cannot transport binary frames, so the handler must
163
+ // agree to text.ircv3.net. Asserting the echoed subprotocol at the
164
+ // dispatch boundary guards against regressing to the generic selector
165
+ // which would negotiate binary and trigger an immediate 1003.
166
+ const res = await dispatch(
167
+ {
168
+ requestContext: { routeKey: '$connect', connectionId: 'c-sub-both' },
169
+ headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net, text.ircv3.net' },
170
+ },
171
+ requireFx().deps,
172
+ );
173
+ expect(res.statusCode).toBe(200);
174
+ expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
175
+ });
176
+
159
177
  it('omits the Sec-WebSocket-Protocol header on a legacy connect', async () => {
160
178
  const res = await dispatch(
161
179
  { requestContext: { routeKey: '$connect', connectionId: 'c-nosub' } },
@@ -329,6 +347,54 @@ describe.skipIf(!available)('handlers', () => {
329
347
  expect(res.statusCode).toBe(410);
330
348
  });
331
349
 
350
+ it('warns when the connection row is missing so the 410 path is visible in logs', async () => {
351
+ // Previously the 410 early-return logged nothing, which made a
352
+ // missing-row $default invisible to operators. The handler must now
353
+ // emit a warning naming the connection id.
354
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
355
+ await dispatch(
356
+ {
357
+ requestContext: { routeKey: '$default', connectionId: 'ghost-row' },
358
+ body: 'PING :tok\r\n',
359
+ },
360
+ requireFx().deps,
361
+ );
362
+ expect(warnSpy).toHaveBeenCalledOnce();
363
+ const msg = String(warnSpy.mock.calls[0]?.[0]);
364
+ expect(msg).toContain('[irc-handler]');
365
+ expect(msg).toContain('ghost-row');
366
+ warnSpy.mockRestore();
367
+ });
368
+
369
+ it('reads the Connections row strongly consistently (avoids read-after-write 410)', async () => {
370
+ // $connect writes the row and $default reads it on the very next
371
+ // frame. An eventually-consistent Get can miss the just-written row
372
+ // (replication lag) and silently drop the client's first frame as a
373
+ // 410. The Get MUST be strongly consistent.
374
+ await dispatch(
375
+ { requestContext: { routeKey: '$connect', connectionId: 'cr1' } },
376
+ requireFx().deps,
377
+ );
378
+ requireFx().mgmt.register('cr1', () => {});
379
+ const sendSpy = vi.spyOn(requireFx().client, 'send');
380
+ await dispatch(
381
+ {
382
+ requestContext: { routeKey: '$default', connectionId: 'cr1' },
383
+ body: 'PING :tok\r\n',
384
+ },
385
+ requireFx().deps,
386
+ );
387
+ const conGets = sendSpy.mock.calls
388
+ .map((call) => call[0] as unknown)
389
+ .filter(
390
+ (cmd): cmd is GetCommand =>
391
+ cmd instanceof GetCommand && (cmd.input.TableName?.endsWith('Connections') ?? false),
392
+ );
393
+ expect(conGets.length).toBeGreaterThan(0);
394
+ expect((conGets[0]?.input as { ConsistentRead?: boolean }).ConsistentRead).toBe(true);
395
+ sendSpy.mockRestore();
396
+ });
397
+
332
398
  it('returns 400 when body is missing', async () => {
333
399
  // Cast: APIGW always sends a body for $default; tests verify the guard.
334
400
  const event = {
@@ -420,11 +486,15 @@ describe.skipIf(!available)('handlers', () => {
420
486
  expect(calls.some((d) => d.includes(' 001 '))).toBe(true);
421
487
  });
422
488
 
423
- it('delivers one postToConnection per line for a binary.ircv3.net connection', async () => {
489
+ it('delivers one postToConnection per line when binary is offered first (selects text.ircv3.net)', async () => {
490
+ // API Gateway cannot transport binary frames (1003 close), so a
491
+ // `binary.ircv3.net, text.ircv3.net` offer resolves to text.ircv3.net
492
+ // and the connection is spec-text: one postToConnection per outbound
493
+ // IRC line, no trailing CRLF.
424
494
  await dispatch(
425
495
  {
426
496
  requestContext: { routeKey: '$connect', connectionId: 'spec2' },
427
- headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
497
+ headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net, text.ircv3.net' },
428
498
  },
429
499
  requireFx().deps,
430
500
  );
@@ -473,6 +543,38 @@ describe.skipIf(!available)('handlers', () => {
473
543
  expect(calls[0]).toContain(' 001 ');
474
544
  });
475
545
 
546
+ it('logs and still returns 200 when postToConnection fails (silent reply loss)', async () => {
547
+ // Reproduces the production failure mode where the management API
548
+ // rejects every post (gone connection, stale MANAGEMENT_URL, …).
549
+ // Previously the catch was empty → the operator saw zero logs and
550
+ // the client saw nothing. The handler must now surface the error.
551
+ await dispatch(
552
+ { requestContext: { routeKey: '$connect', connectionId: 'dead1' } },
553
+ requireFx().deps,
554
+ );
555
+ const failingMgmt: PostToConnection = {
556
+ async postToConnection(): Promise<unknown> {
557
+ throw new Error('GoneException: 410');
558
+ },
559
+ };
560
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
561
+ const res = await dispatch(
562
+ {
563
+ requestContext: { routeKey: '$default', connectionId: 'dead1' },
564
+ body: 'NICK dead\r\nUSER dead 0 * :Dead\r\n',
565
+ },
566
+ { ...requireFx().deps, managementApi: failingMgmt as never },
567
+ );
568
+ // Graceful degradation: mutations are persisted, status stays 200.
569
+ expect(res.statusCode).toBe(200);
570
+ // The reply failure is surfaced to CloudWatch (was previously silent).
571
+ expect(errorSpy).toHaveBeenCalledOnce();
572
+ const firstArg = String(errorSpy.mock.calls[0]?.[0]);
573
+ expect(firstArg).toContain('[irc-handler]');
574
+ expect(firstArg).toContain('postToConnection');
575
+ errorSpy.mockRestore();
576
+ });
577
+
476
578
  it('persists joined channels across $default invocations', async () => {
477
579
  await dispatch(
478
580
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
@@ -13,15 +13,20 @@ import {
13
13
  } from '../src/tables.js';
14
14
 
15
15
  describe('TABLE_NAMES', () => {
16
- it('contains exactly the five canonical logical names', () => {
16
+ it('contains exactly the six canonical logical names', () => {
17
17
  expect(TABLE_NAMES).toEqual([
18
18
  'Connections',
19
19
  'ChannelMeta',
20
20
  'ChannelMembers',
21
21
  'Nicks',
22
22
  'Accounts',
23
+ 'Services',
23
24
  ]);
24
25
  });
26
+
27
+ it('TABLE_KEYS omits Services (the runtime reads SERVICES_TABLE directly)', () => {
28
+ expect(TABLE_KEYS).not.toHaveProperty('Services');
29
+ });
25
30
  });
26
31
 
27
32
  describe('TABLE_KEYS', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -38,6 +38,14 @@ export interface CfConfigEnv {
38
38
  CHANNEL_PREFIXES?: string;
39
39
  OPER_USER?: string;
40
40
  OPER_PASSWORD?: string;
41
+ /**
42
+ * Server-password gate. Treat as a wrangler secret in production
43
+ * (never a plaintext `[vars]` entry); the loader threads it through
44
+ * to `ServerConfig.serverPassword` so the registration reducer can
45
+ * enforce `PASS <value>` before `001 RPL_WELCOME`. Undefined / empty
46
+ * disables the gate.
47
+ */
48
+ SERVER_PASSWORD?: string;
41
49
  MAX_CHANNELS_PER_USER?: string;
42
50
  MAX_TARGETS_PER_COMMAND?: string;
43
51
  NICK_LEN?: string;
@@ -92,6 +100,9 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
92
100
  },
93
101
  ];
94
102
  }
103
+ if (env.SERVER_PASSWORD !== undefined) {
104
+ input.serverPassword = env.SERVER_PASSWORD;
105
+ }
95
106
  if (env.MAX_CHANNELS_PER_USER !== undefined) {
96
107
  input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
97
108
  }
@@ -67,6 +67,7 @@ import { makeCfRuntime } from './cf-runtime.js';
67
67
  import type { CfConnectionHandlers } from './cf-runtime.js';
68
68
  import { loadServerConfigFromCfEnv } from './config-loader.js';
69
69
  import { resolveAccountStore } from './d1-account-store.js';
70
+ import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
70
71
  import type { Env } from './env.js';
71
72
  import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
72
73
  import type { PersistedConnectionState } from './serialize.js';
@@ -114,6 +115,16 @@ export class ConnectionDO extends DurableObject<Env> {
114
115
  * should seed `createdAt` with its receive time.
115
116
  */
116
117
  private channelAccess: PassthroughChannelAccess | undefined;
118
+ /**
119
+ * Test-only: when set, `webSocketClose`/`webSocketError` skip the
120
+ * `tearDown` cleanup. Simulates the production failure mode where the
121
+ * platform drops a hibernated WebSocket without ever delivering the
122
+ * close event to the DO — the exact condition the idle-sweep alarm
123
+ * exists as a backstop for. Lets the alarm test exercise the no-socket
124
+ * teardown path as the sole cleanup, without the close handler racing
125
+ * in to do the work first.
126
+ */
127
+ private __suppressCloseTeardown = false;
117
128
  /**
118
129
  * Per-connection chat-history store backing `draft/chathistory`
119
130
  * playback. The minimum-viable in-worker ring buffer is scoped to this
@@ -143,6 +154,17 @@ export class ConnectionDO extends DurableObject<Env> {
143
154
  private accounts: AccountStore | undefined;
144
155
  /** Guards {@link loadAccountStore} so the D1 query runs at most once. */
145
156
  private accountsResolved = false;
157
+ /**
158
+ * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
159
+ * OperServ + read-marker), hydrated once via {@link loadServicesStore}
160
+ * before the first frame dispatches. The store mutates the in-memory
161
+ * cache synchronously and enqueues write-behind ops; {@link flushServices}
162
+ * drains them into D1 after each frame / at teardown. `undefined` when
163
+ * D1 is not configured (services unbound → no NickServ/ChanServ routing).
164
+ */
165
+ private services: D1ServicesStore | undefined;
166
+ /** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
167
+ private servicesResolved = false;
146
168
  /**
147
169
  * Verified client-cert subject captured at WebSocket upgrade time when
148
170
  * CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
@@ -157,6 +179,17 @@ export class ConnectionDO extends DurableObject<Env> {
157
179
  * When unset (no mTLS configured), EXTERNAL auth fails with `904`.
158
180
  */
159
181
  private mtlsCertSubject: string | undefined;
182
+ /**
183
+ * Client source IP captured at WebSocket upgrade time, surfaced onto the
184
+ * {@link ConnectionState.host} field so WHOIS / hostmasks resolve instead
185
+ * of falling back to `?`. Read from the Cloudflare edge header
186
+ * `CF-Connecting-IP` (set unconditionally by the CF proxy on every
187
+ * request), with `X-Real-IP` as a fallback for non-CF reverse-proxy
188
+ * deployments. When neither is present the host stays `undefined`
189
+ * (hostmasks omit the `@host` segment). Cloaking, if enabled, replaces
190
+ * this raw IP with a deterministic cloak at registration time.
191
+ */
192
+ private sourceHost: string | undefined;
160
193
  private readonly clock: Clock = SystemClock;
161
194
  private readonly ids: IdFactory = new UuidIdFactory();
162
195
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
@@ -193,6 +226,13 @@ export class ConnectionDO extends DurableObject<Env> {
193
226
  if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
194
227
  this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
195
228
  }
229
+ // Capture the client source IP for ConnectionState.host. CF-Connecting-IP
230
+ // is set by the Cloudflare edge on every proxied request; X-Real-IP is
231
+ // the conventional fallback for non-CF reverse proxies. Stashed on the
232
+ // instance because loadState runs lazily on the first event (and again
233
+ // after hibernation), not here at upgrade time.
234
+ this.sourceHost =
235
+ request.headers.get('CF-Connecting-IP') ?? request.headers.get('X-Real-IP') ?? undefined;
196
236
  // IRCv3 WebSocket subprotocol negotiation: read the offered
197
237
  // `Sec-WebSocket-Protocol` list, select the first supported entry, and
198
238
  // echo it back when one was agreed. The negotiated frame mode is tagged
@@ -244,12 +284,14 @@ export class ConnectionDO extends DurableObject<Env> {
244
284
  const beforeNick = state.nick;
245
285
 
246
286
  await this.loadAccountStore();
287
+ await this.loadServicesStore();
247
288
  const actor = this.buildActor(ws, state, mode);
248
289
  try {
249
290
  await actor.receiveTextFrame(text);
250
291
  } finally {
251
292
  // Persist any mutations even if dispatch threw partway through.
252
293
  await this.persistState(state);
294
+ await this.flushServices();
253
295
  }
254
296
 
255
297
  // If registration completed this frame, schedule a PING alarm.
@@ -270,11 +312,13 @@ export class ConnectionDO extends DurableObject<Env> {
270
312
  _reason: string,
271
313
  _wasClean: boolean,
272
314
  ): Promise<void> {
315
+ if (this.__suppressCloseTeardown) return;
273
316
  await this.tearDown(ws);
274
317
  }
275
318
 
276
319
  /** Same teardown as close. */
277
320
  override async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
321
+ if (this.__suppressCloseTeardown) return;
278
322
  await this.tearDown(ws);
279
323
  }
280
324
 
@@ -293,12 +337,20 @@ export class ConnectionDO extends DurableObject<Env> {
293
337
  const state = await this.loadState();
294
338
  const now = this.clock.now();
295
339
  if (state.lastSeen + this.pongTimeoutMs < now) {
296
- // Idle past the PONG threshold: tear down.
340
+ // Idle past the PONG threshold: tear down. Both branches must drive
341
+ // a synthetic QUIT through the actor so the channel rosters prune
342
+ // the member and shared-channel peers receive the QUIT line. When a
343
+ // live socket is present `tearDown` closes it; when the socket is
344
+ // already gone (lost close event, DO woke from hibernation with no
345
+ // socket) `tearDownNoSocket` runs the same effect pipeline against
346
+ // no-op transport handlers so the cross-DO fanout still fires.
347
+ // Using `releaseAndPersist` here would leak the member into every
348
+ // shared channel's roster indefinitely (ghost users in `/NAMES`).
297
349
  const sockets = this.ctx.getWebSockets();
298
350
  if (sockets.length > 0) {
299
351
  await this.tearDown(sockets[0] as WebSocket);
300
352
  } else {
301
- await this.releaseAndPersist(state);
353
+ await this.tearDownNoSocket(state);
302
354
  }
303
355
  return;
304
356
  }
@@ -398,6 +450,13 @@ export class ConnectionDO extends DurableObject<Env> {
398
450
  id: this.ctx.id.toString(),
399
451
  connectedSince: this.clock.now(),
400
452
  });
453
+ // Seed the visible host from the upgrade-time source IP capture so
454
+ // WHOIS / hostmasks carry a real `@host` segment instead of the `?`
455
+ // fallback. Mirrors the local-cli / cf-tcp-container pattern. undefined
456
+ // when no source-IP header was present (hostmask omits @host entirely).
457
+ if (this.sourceHost !== undefined) {
458
+ this.cached.host = this.sourceHost;
459
+ }
401
460
  } else {
402
461
  this.cached = deserialize(record);
403
462
  }
@@ -445,6 +504,7 @@ export class ConnectionDO extends DurableObject<Env> {
445
504
  // per-connection in the Workers dashboard.
446
505
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
447
506
  const accounts = this.accountStore();
507
+ const services = this.servicesStore();
448
508
  const mtlsIdentity: MtlsIdentityProvider | undefined =
449
509
  this.mtlsCertSubject !== undefined
450
510
  ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
@@ -470,6 +530,7 @@ export class ConnectionDO extends DurableObject<Env> {
470
530
  // the DO, so every WS connection is secure → user mode `S`.
471
531
  secure: true,
472
532
  ...(accounts !== undefined ? { accounts } : {}),
533
+ ...(services !== undefined ? { services } : {}),
473
534
  ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
474
535
  logger,
475
536
  });
@@ -479,11 +540,13 @@ export class ConnectionDO extends DurableObject<Env> {
479
540
  private async tearDown(ws: WebSocket): Promise<void> {
480
541
  const state = await this.loadState();
481
542
  await this.loadAccountStore();
543
+ await this.loadServicesStore();
482
544
  const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
483
545
  // Drive QUIT through the actor so the same effect pipeline handles
484
546
  // fanout as during normal operation.
485
547
  await actor.receiveTextFrame('QUIT\r\n');
486
548
  await this.releaseAndPersist(state);
549
+ await this.flushServices();
487
550
  if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
488
551
  ws.close();
489
552
  }
@@ -577,6 +640,40 @@ export class ConnectionDO extends DurableObject<Env> {
577
640
  return this.accounts;
578
641
  }
579
642
 
643
+ /**
644
+ * Resolves the persistent services store once (D1 → write-behind cache),
645
+ * caching the result. Safe to call on every frame; the D1 scan runs at
646
+ * most once per ConnectionDO instance. Must complete before the first
647
+ * reducer dispatches so the synchronous `ServicesStore` port has its
648
+ * snapshot ready. Leaves `services` `undefined` when D1 is absent so the
649
+ * actor's `ctx.services` stays unset (no NickServ/ChanServ routing).
650
+ */
651
+ private async loadServicesStore(): Promise<void> {
652
+ if (this.servicesResolved) return;
653
+ this.servicesResolved = true;
654
+ this.services = await loadD1ServicesStore(this.env.ACCOUNTS_DB, this.clock);
655
+ }
656
+
657
+ /**
658
+ * Returns the resolved services store, or `undefined` when services are
659
+ * not configured. Populated by {@link loadServicesStore}; callers MUST
660
+ * `await loadServicesStore()` before reading this.
661
+ */
662
+ private servicesStore(): D1ServicesStore | undefined {
663
+ return this.services;
664
+ }
665
+
666
+ /**
667
+ * Drains any queued write-behind ops into D1. No-op when services are
668
+ * unbound or when no mutations occurred this turn. Called after every
669
+ * frame (the `webSocketMessage` finally block) and at teardown so a
670
+ * deploy / DO eviction does not lose services state.
671
+ */
672
+ private async flushServices(): Promise<void> {
673
+ if (this.services === undefined) return;
674
+ await this.services.flush();
675
+ }
676
+
580
677
  // -------------------------------------------------------------------------
581
678
  // Test hooks (only invoked from `runInDurableObject` in tests)
582
679
  // -------------------------------------------------------------------------
@@ -603,6 +700,8 @@ export class ConnectionDO extends DurableObject<Env> {
603
700
  // Emit QUIT effects without dispatching transport calls (the socket
604
701
  // is already gone). The cross-DO fanout still runs.
605
702
  await this.loadAccountStore();
703
+ await this.loadServicesStore();
704
+ const services = this.servicesStore();
606
705
  const noOpHandlers: CfConnectionHandlers = {
607
706
  send: (): void => {},
608
707
  disconnect: (): void => {},
@@ -624,9 +723,11 @@ export class ConnectionDO extends DurableObject<Env> {
624
723
  motd: this.motdProvider(),
625
724
  messages: this.messageStore(),
626
725
  history: this.historyStore(),
726
+ ...(services !== undefined ? { services } : {}),
627
727
  });
628
728
  await actor.receiveTextFrame('QUIT\r\n');
629
729
  await this.releaseAndPersist(state);
730
+ await this.flushServices();
630
731
  try {
631
732
  await this.ctx.storage.deleteAlarm();
632
733
  } catch {
@@ -651,6 +752,15 @@ export class ConnectionDO extends DurableObject<Env> {
651
752
  await this.persistState(state);
652
753
  }
653
754
 
755
+ /**
756
+ * Test-only: suppresses the `webSocketClose`/`webSocketError` teardown
757
+ * so the idle-sweep alarm is the sole cleanup path — mirroring a lost
758
+ * close event in production. See {@link __suppressCloseTeardown}.
759
+ */
760
+ public __suppressCloseTeardownHook(): void {
761
+ this.__suppressCloseTeardown = true;
762
+ }
763
+
654
764
  /** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
655
765
  public __peekHexId(): string {
656
766
  return this.ctx.id.toString();