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
@@ -10,7 +10,8 @@
10
10
  */
11
11
 
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
- import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
13
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
14
15
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
16
  import {
16
17
  loadDynamoAccountStore,
@@ -126,6 +127,48 @@ describe.skipIf(!available)('Accounts table round-trip', () => {
126
127
  false,
127
128
  );
128
129
  });
130
+
131
+ it('silently skips rows with missing or wrong-typed credential fields', async () => {
132
+ // The scan path must tolerate partially-malformed rows (legacy seeds,
133
+ // schema drift) without throwing — it just skips them. Each branch of
134
+ // the per-field `typeof === 'string'` guard is exercised by writing a
135
+ // row where exactly one non-key field has the wrong type. (The
136
+ // `account` field is the partition key, so DynamoDB itself enforces
137
+ // its type at write time and the false branch is not exercisable
138
+ // through the SDK; that branch is a defensive guard against
139
+ // synthetically-shaped Scan results.)
140
+ const client = requireFx().client;
141
+ const table = requireFx().tableName;
142
+ // Well-formed row — must survive.
143
+ await putAccountCredential(client, table, 'good', 's3cret');
144
+ // Row with non-string `algorithm`.
145
+ await client.send(
146
+ new PutCommand({
147
+ TableName: table,
148
+ Item: { account: 'noalgo', algorithm: 7, salt: 's', hash: 'h' },
149
+ }),
150
+ );
151
+ // Row with non-string `salt`.
152
+ await client.send(
153
+ new PutCommand({
154
+ TableName: table,
155
+ Item: { account: 'nosalt', algorithm: 'SCRYPT-SHA-256', salt: false, hash: 'h' },
156
+ }),
157
+ );
158
+ // Row with non-string `hash`.
159
+ await client.send(
160
+ new PutCommand({
161
+ TableName: table,
162
+ Item: { account: 'nohash', algorithm: 'SCRYPT-SHA-256', salt: 's', hash: null },
163
+ }),
164
+ );
165
+ const store = await loadDynamoAccountStore(client, table);
166
+ expect(store).toBeDefined();
167
+ expect(store?.size).toBe(1);
168
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'good', password: 's3cret' }).ok).toBe(
169
+ true,
170
+ );
171
+ });
129
172
  });
130
173
 
131
174
  describe.skipIf(!available)('resolveAccountStore precedence', () => {
@@ -139,10 +182,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
139
182
 
140
183
  it('returns a DynamoAccountStore when the table has rows (table wins)', async () => {
141
184
  await putAccountCredential(requireFx().client, requireFx().tableName, 'alice', 'table-secret');
142
- const cfgWithSasl = {
143
- ...DEFAULT_SERVER_CONFIG,
185
+ const cfgWithSasl = makeTestServerConfig({
144
186
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
145
- };
187
+ });
146
188
  const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
147
189
  expect(store).toBeDefined();
148
190
  // Table account verifies; config account does NOT (table is authoritative).
@@ -155,10 +197,9 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
155
197
  });
156
198
 
157
199
  it('falls back to the config-seeded store when the table is empty', async () => {
158
- const cfgWithSasl = {
159
- ...DEFAULT_SERVER_CONFIG,
200
+ const cfgWithSasl = makeTestServerConfig({
160
201
  saslAccounts: [{ username: 'envuser', password: 'env-secret' }],
161
- };
202
+ });
162
203
  const store = await resolveAccountStore(requireFx().client, requireFx().tableName, cfgWithSasl);
163
204
  expect(store).toBeDefined();
164
205
  expect(
@@ -170,7 +211,7 @@ describe.skipIf(!available)('resolveAccountStore precedence', () => {
170
211
  const store = await resolveAccountStore(
171
212
  requireFx().client,
172
213
  requireFx().tableName,
173
- DEFAULT_SERVER_CONFIG,
214
+ makeTestServerConfig(),
174
215
  );
175
216
  expect(store).toBeUndefined();
176
217
  });
@@ -1,9 +1,6 @@
1
1
  import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
2
- import {
3
- DEFAULT_SERVER_CONFIG,
4
- type ParsedServerConfig,
5
- type SaslPayload,
6
- } from '@serverless-ircd/irc-core';
2
+ import type { ParsedServerConfig, SaslPayload } from '@serverless-ircd/irc-core';
3
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
7
4
  import { mockClient } from 'aws-sdk-client-mock';
8
5
  import { describe, expect, it } from 'vitest';
9
6
  import {
@@ -21,7 +18,7 @@ function plainPayload(username: string, password: string): SaslPayload {
21
18
 
22
19
  /** Builds a minimal parsed config with the supplied saslAccounts. */
23
20
  function configWith(accounts: Array<{ username: string; password: string }>): ParsedServerConfig {
24
- return { ...DEFAULT_SERVER_CONFIG, saslAccounts: accounts };
21
+ return makeTestServerConfig({ saslAccounts: accounts });
25
22
  }
26
23
 
27
24
  // ---------------------------------------------------------------------------
@@ -184,7 +181,7 @@ describe('resolveAccountStore', () => {
184
181
  'Accounts',
185
182
  fallback === undefined
186
183
  ? undefined
187
- : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
184
+ : makeTestServerConfig({ saslAccounts: [{ username: 'envuser', password: 'env-pw' }] }),
188
185
  );
189
186
  expect(store).toBeDefined();
190
187
  expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
@@ -53,8 +53,11 @@ const endpoint = process.env.DYNAMO_ENDPOINT;
53
53
 
54
54
  /**
55
55
  * Factory entry — appends to a suite's FACTORIES array to opt the scenarios
56
- * into the AWS matrix. Returns a no-op factory when DynamoDB Local is
57
- * unavailable so `describe.skipIf` short-circuits before any test runs.
56
+ * into the AWS matrix. The whole describe block is gated by
57
+ * `describe.skipIf(process.env.DDB_AVAILABLE !== '1')` at the call site
58
+ * (see `aws-integration.test.ts`), so no scenarios run when DynamoDB
59
+ * Local is unavailable. This factory itself assumes DDB is up; its
60
+ * `create()` throws if `DYNAMO_ENDPOINT` was never set.
58
61
  */
59
62
  export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
60
63
  name: 'aws+ws',
@@ -6,12 +6,16 @@
6
6
  * a fresh {@link AwsHarness} which mints uniquely-prefixed DynamoDB
7
7
  * tables against the global-setup's endpoint.
8
8
  *
9
- * Gated on `process.env.DDB_AVAILABLE`: the suite is skipped when
10
- * DynamoDB Local cannot be booted (no Docker, no Java). When available,
11
- * every scenario MUST pass — that is the acceptance criterion.
9
+ * Gated on `process.env.DDB_AVAILABLE`: the whole suite is skipped via
10
+ * `describe.skipIf` when DynamoDB Local cannot be booted (no Docker, no
11
+ * Java). When available, every scenario MUST pass — that is the
12
+ * acceptance criterion.
12
13
  */
13
14
 
14
15
  import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
16
+ import { describe } from 'vitest';
15
17
  import { awsHarnessFactory } from './aws-harness.js';
16
18
 
17
- runIrcScenarios([awsHarnessFactory]);
19
+ describe.skipIf(process.env.DDB_AVAILABLE !== '1')('aws integration scenarios', () => {
20
+ runIrcScenarios([awsHarnessFactory]);
21
+ });
@@ -399,6 +399,85 @@ describe.skipIf(!available)('AwsRuntime', () => {
399
399
  });
400
400
  });
401
401
 
402
+ // -------------------------------------------------------------------------
403
+ // broadcastWallops — Scan-based fan-out to every +w connection
404
+ // -------------------------------------------------------------------------
405
+
406
+ describe('broadcastWallops', () => {
407
+ it('delivers to every +w connection and skips the except id', async () => {
408
+ const receivedA: string[] = [];
409
+ const receivedB: string[] = [];
410
+ const stateA = createConnection({ id: 'w-a', connectedSince: 0 });
411
+ stateA.userModes.wallops = true;
412
+ const stateB = createConnection({ id: 'w-b', connectedSince: 0 });
413
+ stateB.userModes.wallops = true;
414
+
415
+ const mgmt = new LocalPostToConnection();
416
+ mgmt.register('w-a', (data) => {
417
+ for (const line of data.split('\r\n')) {
418
+ if (line.length > 0) receivedA.push(line);
419
+ }
420
+ });
421
+ mgmt.register('w-b', (data) => {
422
+ for (const line of data.split('\r\n')) {
423
+ if (line.length > 0) receivedB.push(line);
424
+ }
425
+ });
426
+
427
+ const rtA = makeRuntime('w-a', handlersSink(receivedA, stateA), undefined, mgmt);
428
+ const rtB = makeRuntime('w-b', handlersSink(receivedB, stateB), undefined, mgmt);
429
+
430
+ await rtA.persistConnectionState(stateA);
431
+ await rtB.persistConnectionState(stateB);
432
+
433
+ // Caller 'w-a' is excepted; only 'w-b' should receive the line.
434
+ await rtA.broadcastWallops([{ text: ':oper NOTICE * :hello' }], 'w-a');
435
+ expect(receivedA).toEqual([]);
436
+ expect(receivedB).toEqual([':oper NOTICE * :hello']);
437
+ });
438
+
439
+ it('delivers to the bound connection via in-process handlers when not excepted', async () => {
440
+ // The bound connection's own +w delivery goes through handlers.send,
441
+ // not APIGW. With no `except`, the bound conn is also a recipient.
442
+ const received: string[] = [];
443
+ const state = createConnection({ id: 'self', connectedSince: 0 });
444
+ state.userModes.wallops = true;
445
+ const rt = makeRuntime('self', handlersSink(received, state), undefined, null);
446
+ await rt.persistConnectionState(state);
447
+ await rt.broadcastWallops([{ text: ':oper NOTICE * :hi' }]);
448
+ expect(received).toEqual([':oper NOTICE * :hi']);
449
+ });
450
+ });
451
+
452
+ // -------------------------------------------------------------------------
453
+ // Nick registry — error propagation
454
+ // -------------------------------------------------------------------------
455
+
456
+ describe('reserveNick error propagation', () => {
457
+ it('rethrows non-conditional errors from the PutCommand', async () => {
458
+ // A non-ConditionalCheckFailedException error must propagate rather
459
+ // than be treated as a nick collision. We force a real DynamoDB
460
+ // error by sending the reserveNick PutCommand against a table that
461
+ // does not exist (deleted mid-flight), which yields a
462
+ // ResourceNotFoundException.
463
+ const rt = makeRuntime('c1', noopHandlers());
464
+ // Drop the Nicks table out from under the runtime.
465
+ await fx.client.send(new DeleteTableCommand({ TableName: fx.tables.Nicks }));
466
+ await expect(rt.reserveNick('Ghost', 'c1')).rejects.toThrow(
467
+ /ResourceNotFoundException|non-existent table/,
468
+ );
469
+ // Recreate the table so afterEach cleanup does not fail on a missing table.
470
+ await fx.client.send(
471
+ new CreateTableCommand({
472
+ TableName: fx.tables.Nicks,
473
+ AttributeDefinitions: [{ AttributeName: 'nickLower', AttributeType: 'S' }],
474
+ KeySchema: [{ AttributeName: 'nickLower', KeyType: 'HASH' }],
475
+ BillingMode: 'PAY_PER_REQUEST',
476
+ }),
477
+ );
478
+ });
479
+ });
480
+
402
481
  // -------------------------------------------------------------------------
403
482
  // listChannels + getChannelConnections
404
483
  // -------------------------------------------------------------------------
@@ -90,6 +90,15 @@ describe('buildLambdaConfigInput', () => {
90
90
  );
91
91
  });
92
92
 
93
+ it('passes an overflowing numeric CREATED_AT through verbatim (not Infinity)', () => {
94
+ // A purely-numeric string that overflows Number.MAX_VALUE renders as
95
+ // `Infinity`, which is not a finite epoch-ms. The parser must fall
96
+ // back to returning the raw string rather than emitting `Infinity`
97
+ // into the config (which would serialize as `null` in JSON).
98
+ const huge = '9'.repeat(400);
99
+ expect(buildLambdaConfigInput({ CREATED_AT: huge }).createdAt).toBe(huge);
100
+ });
101
+
93
102
  it('omits serverVersion/createdAt when their env vars are unset', () => {
94
103
  const out = buildLambdaConfigInput({});
95
104
  expect('serverVersion' in out).toBe(false);
@@ -154,8 +163,8 @@ describe('loadServerConfigFromLambdaEnv', () => {
154
163
  expect(typeof cfg.nickLen).toBe('number');
155
164
  });
156
165
 
157
- it('throws when SERVER_NAME is missing', () => {
158
- expect(() => loadServerConfigFromLambdaEnv({})).toThrow();
166
+ it('throws a readable error naming serverName when SERVER_NAME is missing', () => {
167
+ expect(() => loadServerConfigFromLambdaEnv({})).toThrowError(/serverName/u);
159
168
  });
160
169
 
161
170
  it('round-trips MOTD lines into ParsedServerConfig', () => {
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
11
- import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
11
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
12
12
  import { describe, expect, it } from 'vitest';
13
13
  import { handleConnect } from '../src/handlers/connect.js';
14
14
  import type { MarshalledConnection } from '../src/serialize.js';
@@ -66,7 +66,7 @@ describe('handleConnect admission plumbing', () => {
66
66
  tables: TABLES,
67
67
  connectionId: 'c-paged',
68
68
  now: 42,
69
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
69
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
70
70
  });
71
71
  expect(outcome).toEqual({ admitted: true, subprotocol: null });
72
72
  expect(stub.puts).toBe(1);
@@ -78,7 +78,7 @@ describe('handleConnect admission plumbing', () => {
78
78
  dynamo: stub.dynamo,
79
79
  tables: TABLES,
80
80
  connectionId: 'c-full',
81
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
81
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
82
82
  });
83
83
  expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
84
84
  expect(stub.puts).toBe(0);
@@ -94,7 +94,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
94
94
  connectionId: 'c-binary',
95
95
  now: 42,
96
96
  secWebSocketProtocol: 'binary.ircv3.net',
97
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
97
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
98
98
  });
99
99
  expect(outcome).toEqual({ admitted: true, subprotocol: 'binary.ircv3.net' });
100
100
  expect(stub.puts).toBe(1);
@@ -110,7 +110,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
110
110
  connectionId: 'c-text',
111
111
  now: 42,
112
112
  secWebSocketProtocol: 'text.ircv3.net',
113
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
113
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
114
114
  });
115
115
  expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
116
116
  const item = stub.lastPutItem as MarshalledConnection | undefined;
@@ -125,7 +125,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
125
125
  connectionId: 'c-pref',
126
126
  now: 42,
127
127
  secWebSocketProtocol: 'text.ircv3.net, binary.ircv3.net',
128
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
128
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
129
129
  });
130
130
  expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
131
131
  });
@@ -137,7 +137,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
137
137
  tables: TABLES,
138
138
  connectionId: 'c-legacy',
139
139
  now: 42,
140
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
140
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
141
141
  });
142
142
  expect(outcome).toEqual({ admitted: true, subprotocol: null });
143
143
  const item = stub.lastPutItem as MarshalledConnection | undefined;
@@ -152,7 +152,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
152
152
  connectionId: 'c-unsupported',
153
153
  now: 42,
154
154
  secWebSocketProtocol: 'chat, superchat.v2',
155
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
155
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
156
156
  });
157
157
  expect(outcome).toEqual({ admitted: true, subprotocol: null });
158
158
  const item = stub.lastPutItem as MarshalledConnection | undefined;
@@ -166,7 +166,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
166
166
  tables: TABLES,
167
167
  connectionId: 'c-full',
168
168
  secWebSocketProtocol: 'binary.ircv3.net',
169
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
169
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
170
170
  });
171
171
  expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
172
172
  expect(stub.puts).toBe(0);
@@ -12,12 +12,12 @@ import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
13
  import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
14
  import {
15
- DEFAULT_SERVER_CONFIG,
16
15
  type Nick,
17
16
  type ParsedServerConfig,
18
17
  StaticMotdProvider,
19
18
  createConnection,
20
19
  } from '@serverless-ircd/irc-core';
20
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
21
21
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
22
  import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
23
23
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -30,12 +30,11 @@ import type { TablesConfig } from '../src/tables.js';
30
30
  const available = process.env.DDB_AVAILABLE === '1';
31
31
  const endpoint = process.env.DYNAMO_ENDPOINT;
32
32
 
33
- const SERVER_CONFIG: ParsedServerConfig = {
34
- ...DEFAULT_SERVER_CONFIG,
33
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
35
34
  serverName: 'irc.example.com',
36
35
  networkName: 'ExampleNet',
37
36
  motdLines: [],
38
- };
37
+ });
39
38
 
40
39
  interface Fixture {
41
40
  tables: TablesConfig;
@@ -2,20 +2,32 @@
2
2
  * Vitest `globalSetup` — boots a DynamoDB Local instance once per test run
3
3
  * and exposes its endpoint via `process.env.DYNAMO_ENDPOINT`.
4
4
  *
5
- * Strategy (in priority order):
5
+ * Strategy selection:
6
6
  *
7
- * 1. **testcontainers** preferred, matches CI. Spins up the canonical
8
- * `amazon/dynamodb-local` Docker image. Requires Docker.
9
- * 2. **DynamoDB Local ZIP + Java** development fallback for hosts
10
- * with Java (e.g. dev laptops without Docker Desktop). Downloads
11
- * the official AWS ZIP once into a per-user cache dir, runs it
12
- * with `-inMemory` so per-run state is isolated by port.
7
+ * - `DDB_LOCAL_STRATEGY=zip` → only the Java ZIP fallback (skips the
8
+ * testcontainers probe). Canonical CI path on the self-hosted Gitea
9
+ * runner, which has a JRE installed via `setup-java` but no
10
+ * privileged container / `docker.sock` mount for Ryuk.
11
+ * - `DDB_LOCAL_STRATEGY=docker` only testcontainers.
12
+ * - unset / `auto` (default) try testcontainers first, then ZIP.
13
+ * Matches developer laptops where either backend may be available.
14
+ *
15
+ * Strategies (in priority order under `auto`):
16
+ *
17
+ * 1. **testcontainers** — preferred. Spins up the canonical
18
+ * `amazon/dynamodb-local` Docker image. Requires Docker with a
19
+ * working Ryuk reaper (privileged runner or `docker.sock` mount).
20
+ * 2. **DynamoDB Local ZIP + Java** — development/CI fallback for hosts
21
+ * with Java (e.g. the CI runner image with a JRE, or dev laptops
22
+ * without Docker Desktop). Downloads the official AWS ZIP once
23
+ * into a per-user cache dir, runs it with `-inMemory` so per-run
24
+ * state is isolated by port.
13
25
  * 3. **Skip** — sets `process.env.DDB_AVAILABLE = '0'`; integration
14
26
  * tests gate themselves on this via `describe.skipIf`.
15
27
  *
16
28
  * The skip path keeps the suite green in environments that legitimately
17
29
  * can't run DynamoDB Local (no Docker, no Java). The acceptance criterion
18
- * is "all 31 scenarios pass when DDB is available", not "every dev box
30
+ * is "all scenarios pass when DDB is available", not "every dev box
19
31
  * runs them".
20
32
  */
21
33
 
@@ -51,34 +63,47 @@ interface DdbHandle {
51
63
  }
52
64
 
53
65
  export default async function globalSetup(): Promise<(() => Promise<void>) | undefined> {
66
+ const strategy = (process.env.DDB_LOCAL_STRATEGY ?? 'auto').toLowerCase();
67
+ // Strategy pin: skip the unused probe to avoid a multi-second timeout
68
+ // on hosts that lack the capability. CI pins `zip`; local dev defaults
69
+ // to `auto` so a Docker Desktop install still gets testcontainers.
70
+ const wantDocker = strategy === 'auto' || strategy === 'docker';
71
+ const wantZip = strategy === 'auto' || strategy === 'zip';
72
+
54
73
  // Strategy 1: testcontainers (Docker).
55
- try {
56
- const handle = await tryTestcontainers();
57
- if (handle !== null) {
58
- process.env.DDB_AVAILABLE = '1';
59
- process.env.DYNAMO_ENDPOINT = handle.endpoint;
60
- console.warn(`[aws-adapter global-setup] DynamoDB Local via Docker at ${handle.endpoint}`);
61
- return async () => {
62
- await handle.stop();
63
- };
74
+ if (wantDocker) {
75
+ try {
76
+ const handle = await tryTestcontainers();
77
+ if (handle !== null) {
78
+ process.env.DDB_AVAILABLE = '1';
79
+ process.env.DYNAMO_ENDPOINT = handle.endpoint;
80
+ console.warn(`[aws-adapter global-setup] DynamoDB Local via Docker at ${handle.endpoint}`);
81
+ return async () => {
82
+ await handle.stop();
83
+ };
84
+ }
85
+ } catch (err: unknown) {
86
+ console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
64
87
  }
65
- } catch (err: unknown) {
66
- console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
67
88
  }
68
89
 
69
90
  // Strategy 2: local ZIP + Java.
70
- try {
71
- const handle = await tryZip();
72
- if (handle !== null) {
73
- process.env.DDB_AVAILABLE = '1';
74
- process.env.DYNAMO_ENDPOINT = handle.endpoint;
75
- console.warn(`[aws-adapter global-setup] DynamoDB Local via Java ZIP at ${handle.endpoint}`);
76
- return async () => {
77
- await handle.stop();
78
- };
91
+ if (wantZip) {
92
+ try {
93
+ const handle = await tryZip();
94
+ if (handle !== null) {
95
+ process.env.DDB_AVAILABLE = '1';
96
+ process.env.DYNAMO_ENDPOINT = handle.endpoint;
97
+ console.warn(
98
+ `[aws-adapter global-setup] DynamoDB Local via Java ZIP at ${handle.endpoint}`,
99
+ );
100
+ return async () => {
101
+ await handle.stop();
102
+ };
103
+ }
104
+ } catch (err: unknown) {
105
+ console.warn('[aws-adapter global-setup] ZIP fallback unavailable:', renderErr(err));
79
106
  }
80
- } catch (err: unknown) {
81
- console.warn('[aws-adapter global-setup] ZIP fallback unavailable:', renderErr(err));
82
107
  }
83
108
 
84
109
  // Strategy 3: skip.
@@ -11,12 +11,8 @@
11
11
  import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
13
  import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
- import {
15
- DEFAULT_SERVER_CONFIG,
16
- type Nick,
17
- type ParsedServerConfig,
18
- createConnection,
19
- } from '@serverless-ircd/irc-core';
14
+ import { type Nick, type ParsedServerConfig, createConnection } from '@serverless-ircd/irc-core';
15
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
20
16
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
21
17
  import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
22
18
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -27,12 +23,11 @@ import type { TablesConfig } from '../src/tables.js';
27
23
  const available = process.env.DDB_AVAILABLE === '1';
28
24
  const endpoint = process.env.DYNAMO_ENDPOINT;
29
25
 
30
- const SERVER_CONFIG: ParsedServerConfig = {
31
- ...DEFAULT_SERVER_CONFIG,
26
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
32
27
  serverName: 'irc.example.com',
33
28
  networkName: 'ExampleNet',
34
29
  motdLines: [],
35
- };
30
+ });
36
31
 
37
32
  interface Fixture {
38
33
  tables: TablesConfig;
@@ -8,11 +8,11 @@
8
8
 
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
10
  import {
11
- DEFAULT_SERVER_CONFIG,
12
11
  InMemoryAccountStore,
13
12
  type ParsedServerConfig,
14
13
  StaticMotdProvider,
15
14
  } from '@serverless-ircd/irc-core';
15
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
16
16
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
17
  import { AwsRuntime } from '../src/aws-runtime.js';
18
18
  import type { PostToConnection } from '../src/aws-runtime.js';
@@ -30,12 +30,11 @@ import type { TablesConfig } from '../src/tables.js';
30
30
  const available = process.env.DDB_AVAILABLE === '1';
31
31
  const endpoint = process.env.DYNAMO_ENDPOINT;
32
32
 
33
- const SERVER_CONFIG: ParsedServerConfig = {
34
- ...DEFAULT_SERVER_CONFIG,
33
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
35
34
  serverName: 'irc.example.com',
36
35
  networkName: 'ExampleNet',
37
36
  motdLines: ['Welcome to the test IRC server.'],
38
- };
37
+ });
39
38
 
40
39
  const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
41
40
 
@@ -685,6 +684,93 @@ describe.skipIf(!available)('handlers', () => {
685
684
  // Never a 421 for REHASH.
686
685
  expect(sink.some((l) => l.includes(' 421 ') && l.includes('REHASH'))).toBe(false);
687
686
  });
687
+
688
+ it('refreshChannel updates cached state when a channel becomes empty (no topic)', async () => {
689
+ // After the last member PARTs, the channel snapshot returns 0
690
+ // members but the meta row persists. The next frame that
691
+ // references the channel triggers refreshChannel, which must
692
+ // take the empty-members + existing-cache path without throwing
693
+ // and leave the cache usable for subsequent joins.
694
+ await dispatch(
695
+ { requestContext: { routeKey: '$connect', connectionId: 'e1' } },
696
+ requireFx().deps,
697
+ );
698
+ requireFx().mgmt.register('e1', () => {});
699
+ await dispatch(
700
+ {
701
+ requestContext: { routeKey: '$default', connectionId: 'e1' },
702
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #empty\r\n',
703
+ },
704
+ requireFx().deps,
705
+ );
706
+ // PART drops the member count to 0 while the meta row survives.
707
+ await dispatch(
708
+ {
709
+ requestContext: { routeKey: '$default', connectionId: 'e1' },
710
+ body: 'PART #empty\r\n',
711
+ },
712
+ requireFx().deps,
713
+ );
714
+ // Re-JOIN forces prefetchChannelState → refreshChannel, hitting
715
+ // the empty-members + existing-cache branch. The JOIN must
716
+ // succeed (no exception), proving the cache is still consistent.
717
+ const res = await dispatch(
718
+ {
719
+ requestContext: { routeKey: '$default', connectionId: 'e1' },
720
+ body: 'JOIN #empty\r\n',
721
+ },
722
+ requireFx().deps,
723
+ );
724
+ expect(res.statusCode).toBe(200);
725
+ });
726
+
727
+ it('refreshChannel preserves an existing topic when the snapshot has one', async () => {
728
+ // Same shape as the previous test, but a TOPIC is set before the
729
+ // PART so the snapshot's `topic` field is defined. This exercises
730
+ // the `if (snap.topic !== undefined) existing.topic = snap.topic`
731
+ // assignment in the empty-members path. The JOIN reducer does not
732
+ // emit 332 itself, so we query the topic explicitly via `TOPIC
733
+ // #chan` (no args) after re-JOIN — a 332 RPL_TOPIC reply proves
734
+ // the cache retained the topic through the empty refresh.
735
+ await dispatch(
736
+ { requestContext: { routeKey: '$connect', connectionId: 't1' } },
737
+ requireFx().deps,
738
+ );
739
+ const sink: string[] = [];
740
+ requireFx().mgmt.register('t1', (data) => {
741
+ for (const line of data.split('\r\n')) {
742
+ if (line.length > 0) sink.push(line);
743
+ }
744
+ });
745
+ await dispatch(
746
+ {
747
+ requestContext: { routeKey: '$default', connectionId: 't1' },
748
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #topicchan\r\nTOPIC #topicchan :hello\r\n',
749
+ },
750
+ requireFx().deps,
751
+ );
752
+ const sinkAfterFirstJoin = sink.length;
753
+ await dispatch(
754
+ {
755
+ requestContext: { routeKey: '$default', connectionId: 't1' },
756
+ body: 'PART #topicchan\r\n',
757
+ },
758
+ requireFx().deps,
759
+ );
760
+ // Re-JOIN triggers refreshChannel with snap.topic === 'hello',
761
+ // assigning existing.topic. The subsequent TOPIC query reads from
762
+ // the cache and emits 332 RPL_TOPIC.
763
+ const res = await dispatch(
764
+ {
765
+ requestContext: { routeKey: '$default', connectionId: 't1' },
766
+ body: 'JOIN #topicchan\r\nTOPIC #topicchan\r\n',
767
+ },
768
+ requireFx().deps,
769
+ );
770
+ expect(res.statusCode).toBe(200);
771
+ const newReplies = sink.slice(sinkAfterFirstJoin);
772
+ expect(newReplies.some((l) => l.includes(' 332 ') && l.includes('hello'))).toBe(true);
773
+ });
688
774
  });
689
775
 
690
776
  // -------------------------------------------------------------------------