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
@@ -0,0 +1,99 @@
1
+ /**
2
+ * WebSocket `Origin` policy — Cross-Site WebSocket Hijacking (CSWSH) defense.
3
+ *
4
+ * Browsers send an `Origin` header on every cross-origin WebSocket upgrade
5
+ * (RFC 6454); WebSocket upgrades do not otherwise enforce same-origin.
6
+ * Without a check, a malicious page can open a WS to the IRC server from
7
+ * the victim's browser and drive the session with the victim's credentials.
8
+ *
9
+ * Two enforcement modes, evaluated in order:
10
+ *
11
+ * 1. **Explicit allowlist** — when `WEB_ORIGINS` is set, only listed
12
+ * origins proceed. Use this for cross-origin deployments (SPA on a
13
+ * different domain than the Worker).
14
+ *
15
+ * 2. **Auto-derive (same-origin)** — when `WEB_ORIGINS` is unset/empty,
16
+ * the Worker compares the browser's `Origin` against the request's own
17
+ * origin (`${protocol}//${host}`). If they match, the page that opened
18
+ * the WebSocket is the same origin as the server → safe. This is the
19
+ * default and requires zero per-env configuration; it works for any
20
+ * deployment hostname (`*.workers.dev`, custom domains, preview URLs).
21
+ *
22
+ * Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`, scripted test
23
+ * harnesses) never send `Origin`; their upgrades are allowed through unchanged
24
+ * so existing tooling keeps working.
25
+ *
26
+ * The policy is split into two pure functions so the logic is exhaustively
27
+ * unit-testable; the worker's `fetch` handler is the only integration site
28
+ * (see `tests/ws-origin.test.ts`).
29
+ */
30
+
31
+ /**
32
+ * Outcome of evaluating an upgrade's `Origin` header.
33
+ *
34
+ * • `'allow'` — proceed: the origin is allowlisted or same-origin,
35
+ * or the header is absent (non-browser client).
36
+ * • `'deny'` — reject (`403`): a browser sent an `Origin` that is
37
+ * not allowlisted and not same-origin.
38
+ * • `'fail-closed'` — reject (`403`): neither the allowlist nor the
39
+ * request origin could be determined (extreme edge
40
+ * case — Cloudflare always sets `Host`).
41
+ */
42
+ export type OriginDecision = 'allow' | 'deny' | 'fail-closed';
43
+
44
+ /**
45
+ * Parse the comma-separated `WEB_ORIGINS` var into a normalized set of
46
+ * lowercased origins. Whitespace-only and empty entries are dropped so
47
+ * trailing commas / stray spaces don't pollute the set. An empty/undefined
48
+ * var yields an empty set, which {@link decideOrigin} treats as "fall back
49
+ * to auto-derive" (NOT fail-closed).
50
+ *
51
+ * Origins are lowercased wholesale for case-insensitive matching; the
52
+ * scheme and host are case-insensitive per RFC 3986, and lowercasing the
53
+ * whole string is the simplest normalization that handles the real-world
54
+ * browser-emitted form (`https://Example.com` → `https://example.com`).
55
+ */
56
+ export function parseWebOrigins(raw: string | undefined): Set<string> {
57
+ return new Set(
58
+ (raw ?? '')
59
+ .split(',')
60
+ .map((entry) => entry.trim().toLowerCase())
61
+ .filter((entry) => entry.length > 0),
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Decide whether a WebSocket upgrade's `Origin` header is permitted.
67
+ *
68
+ * @param originHeader the raw `Origin` header value (`null` when absent).
69
+ * @param allowlist the parsed {@link parseWebOrigins} set. When non-empty,
70
+ * it takes precedence over auto-derive (explicit operator
71
+ * override for cross-origin setups).
72
+ * @param requestOrigin the request's own origin (`${protocol}//${host}`),
73
+ * used for same-origin auto-derive when the allowlist is
74
+ * empty. `null` if the origin cannot be determined.
75
+ * @returns the {@link OriginDecision}; see the type docstring.
76
+ */
77
+ export function decideOrigin(
78
+ originHeader: string | null,
79
+ allowlist: Set<string>,
80
+ requestOrigin: string | null,
81
+ ): OriginDecision {
82
+ // Missing Origin = non-browser client (curl, WeeChat, tcp-ws-forwarder).
83
+ if (originHeader === null || originHeader === '') return 'allow';
84
+
85
+ const origin = originHeader.trim().toLowerCase();
86
+
87
+ // Mode 1: explicit allowlist takes precedence.
88
+ if (allowlist.size > 0) {
89
+ return allowlist.has(origin) ? 'allow' : 'deny';
90
+ }
91
+
92
+ // Mode 2: auto-derive — compare Origin against the request's own origin.
93
+ if (requestOrigin !== null && requestOrigin !== '') {
94
+ return origin === requestOrigin.trim().toLowerCase() ? 'allow' : 'deny';
95
+ }
96
+
97
+ // Neither allowlist nor request origin available — fail-closed.
98
+ return 'fail-closed';
99
+ }
@@ -13,6 +13,14 @@
13
13
  * • Cross-DO coordination (registry + channel) is owned by
14
14
  * `ConnectionDO` and `CfRuntime`; the worker never touches it.
15
15
  *
16
+ * Request routing (non-WS):
17
+ * • `/health` — plaintext human-readable liveness string (the previous
18
+ * catch-all response, now scoped to an explicit path so the root URL
19
+ * is free for the landing page).
20
+ * • everything else — falls through to the `[assets]` binding, which
21
+ * serves the Kiwi SPA at `/webclient/` and the landing page at `/` from
22
+ * `apps/web/dist`. The asset layer is configured in `wrangler.toml`.
23
+ *
16
24
  * The DO classes are re-exported so `wrangler.toml`'s
17
25
  * `durable_objects.bindings.class_name` can resolve them in this
18
26
  * module's scope (wrangler requires the class to be exported from the
@@ -29,6 +37,7 @@ import {
29
37
  type Env,
30
38
  RegistryDO,
31
39
  } from '@serverless-ircd/cf-adapter';
40
+ import { decideOrigin, parseWebOrigins } from './origin-allowlist';
32
41
 
33
42
  // Re-exported for wrangler DO binding resolution. The class names below
34
43
  // MUST match `class_name` in `wrangler.toml`.
@@ -36,9 +45,68 @@ export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO };
36
45
  export type { Env };
37
46
 
38
47
  /**
39
- * Default edge handler. Non-WebSocket requests get a human-readable
40
- * health response; WebSocket upgrades are routed to a per-connection
41
- * `ConnectionDO`.
48
+ * Production {@link Env} for the worker entry. Extends the cf-adapter
49
+ * {@link Env} with the `ASSETS` binding (the static-asset `Fetcher` for
50
+ * `/webclient/` SPA + `/` landing page). The cf-adapter's internal types
51
+ * stay asset-agnostic; only the edge entry knows about the asset layer.
52
+ */
53
+ export interface WorkerEnv extends Env {
54
+ /**
55
+ * Static-asset `Fetcher` backed by `apps/web/dist` (production) or
56
+ * `tests/fixtures/web-dist` (test). Non-WS, non-`/health` requests are
57
+ * forwarded here so the asset layer serves the SPA and landing page
58
+ * without the worker inspecting asset paths.
59
+ */
60
+ ASSETS: Fetcher;
61
+ /**
62
+ * Optional comma-separated allowlist of web origins permitted to open
63
+ * WebSocket upgrades (CSWSH defense — browsers send `Origin` on every
64
+ * cross-origin upgrade; WebSocket upgrades do not otherwise enforce
65
+ * same-origin).
66
+ *
67
+ * When set, only listed origins proceed. When unset/empty (the default),
68
+ * the Worker auto-derives the expected origin from the request's own
69
+ * `Host` header (same-origin enforcement) — no per-env configuration
70
+ * needed. Set this only for cross-origin deployments where the SPA is
71
+ * served from a different domain than the Worker.
72
+ *
73
+ * Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`) omit
74
+ * `Origin` and pass through unchanged regardless of mode.
75
+ */
76
+ WEB_ORIGINS: string;
77
+ }
78
+
79
+ /**
80
+ * Human-readable liveness string surfaced at `GET /health`. Previously the
81
+ * catch-all response for every non-WS request; now scoped to an explicit
82
+ * path so `/` can serve the landing page from the `[assets]` binding.
83
+ */
84
+ const HEALTH_BODY =
85
+ 'ServerlessIRCd (Cloudflare Worker). Connect via WebSocket to begin an IRC session.';
86
+
87
+ /**
88
+ * Response body sent when a browser-sent `Origin` is not allowed.
89
+ * Mentioned in `docs/web-client.md` as the canonical CSWSH rejection.
90
+ */
91
+ const ORIGIN_DENIED_BODY = 'WebSocket upgrade refused: the request Origin is not allowed.';
92
+
93
+ /**
94
+ * Response body sent when neither the allowlist nor the request origin could
95
+ * be determined. In practice this never fires on Cloudflare (the platform
96
+ * always sets `Host`), but the pure policy function keeps the case for
97
+ * defensive completeness.
98
+ */
99
+ const ORIGIN_UNCONFIGURED_BODY =
100
+ 'WebSocket upgrades refused: could not determine the request origin and WEB_ORIGINS is not configured.';
101
+
102
+ /**
103
+ * Default edge handler.
104
+ *
105
+ * Routing order:
106
+ * 1. WebSocket upgrade → CSWSH `Origin` check, then per-connection
107
+ * `ConnectionDO`.
108
+ * 2. `GET /health` → plaintext liveness response.
109
+ * 3. everything else → `env.ASSETS` (Kiwi SPA at `/webclient/`, landing at `/`).
42
110
  *
43
111
  * The connection id is allocated here via `crypto.randomUUID()` (the
44
112
  * Web Crypto API is sync-available in Workers for RFC-4122 v4 UUIDs).
@@ -47,19 +115,31 @@ export type { Env };
47
115
  * it before falling back to a fresh uuid.
48
116
  */
49
117
  export default {
50
- async fetch(request: Request, env: Env): Promise<Response> {
118
+ async fetch(request: Request, env: WorkerEnv): Promise<Response> {
51
119
  if (request.headers.get('Upgrade') === 'websocket') {
120
+ const allowlist = parseWebOrigins(env.WEB_ORIGINS);
121
+ const url = new URL(request.url);
122
+ const requestOrigin = `${url.protocol}//${url.host}`;
123
+ const decision = decideOrigin(request.headers.get('Origin'), allowlist, requestOrigin);
124
+ if (decision !== 'allow') {
125
+ const body = decision === 'fail-closed' ? ORIGIN_UNCONFIGURED_BODY : ORIGIN_DENIED_BODY;
126
+ return new Response(body, {
127
+ status: 403,
128
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
129
+ });
130
+ }
52
131
  const connId = crypto.randomUUID();
53
132
  const id = env.CONNECTION_DO.idFromName(connId);
54
133
  const stub = env.CONNECTION_DO.get(id);
55
134
  return stub.fetch(request);
56
135
  }
57
- return new Response(
58
- 'ServerlessIRCd (Cloudflare Worker). Connect via WebSocket to begin an IRC session.',
59
- {
136
+ const url = new URL(request.url);
137
+ if (url.pathname === '/health') {
138
+ return new Response(HEALTH_BODY, {
60
139
  status: 200,
61
140
  headers: { 'content-type': 'text/plain; charset=utf-8' },
62
- },
63
- );
141
+ });
142
+ }
143
+ return env.ASSETS.fetch(request);
64
144
  },
65
145
  };
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <!--
3
+ Test-only fixture standing in for the landing page shipped by the
4
+ `apps/web` build (`apps/web/dist/index.html`). Production will serve a
5
+ real landing page at `/`; the cf-worker smoke suite asserts that `GET /`
6
+ is served by the `[assets]` binding rather than the legacy plaintext
7
+ health response (now exclusive to `/health`).
8
+ -->
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8" />
12
+ <title>ServerlessIRCd</title>
13
+ </head>
14
+ <body>
15
+ <h1>ServerlessIRCd</h1>
16
+ <p>A serverless IRC daemon.</p>
17
+ </body>
18
+ </html>
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <!--
3
+ Test-only fixture mirroring the real `apps/web/dist/webclient/index.html`
4
+ produced by the Kiwi SPA build (`pnpm --filter web build`). The cf-worker
5
+ smoke suite points its `[assets]` binding here so the worker's fall-through
6
+ routing (WS → ConnectionDO, /health → plaintext, else → ASSETS) is exercised
7
+ without depending on the (slow, submodule-heavy) Kiwi build. Production
8
+ points at `apps/web/dist`; this fixture only stands in for the test run.
9
+ -->
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <title>KiwiIRC</title>
14
+ </head>
15
+ <body>
16
+ <div id="app"></div>
17
+ </body>
18
+ </html>
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Unit tests for the WebSocket `Origin` allowlist helpers.
3
+ *
4
+ * The worker's CSWSH defense is split into two pure functions
5
+ * (`parseWebOrigins`, `decideOrigin`) so the policy logic is exhaustively
6
+ * coverable without driving a Durable Object through `worker.fetch`. The
7
+ * integration-side assertions (status codes against the real handler) live
8
+ * in `ws-origin.test.ts`.
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+ import { decideOrigin, parseWebOrigins } from '../src/origin-allowlist';
13
+
14
+ describe('parseWebOrigins', () => {
15
+ it('splits a comma-separated list into a set of normalized origins', () => {
16
+ expect(parseWebOrigins('https://example.com, https://app.example.com')).toEqual(
17
+ new Set(['https://example.com', 'https://app.example.com']),
18
+ );
19
+ });
20
+
21
+ it('lowercases origins for case-insensitive comparison', () => {
22
+ expect(parseWebOrigins('https://Example.COM/Path').has('https://example.com/path')).toBe(true);
23
+ });
24
+
25
+ it('drops whitespace-only and empty entries', () => {
26
+ expect(parseWebOrigins('https://example.com, , ,https://b.example.com')).toEqual(
27
+ new Set(['https://example.com', 'https://b.example.com']),
28
+ );
29
+ });
30
+
31
+ it('returns an empty set for an empty/undefined var (fail-closed sentinel)', () => {
32
+ expect(parseWebOrigins('').size).toBe(0);
33
+ expect(parseWebOrigins(' ').size).toBe(0);
34
+ expect(parseWebOrigins(undefined).size).toBe(0);
35
+ });
36
+ });
37
+
38
+ describe('decideOrigin', () => {
39
+ const allowlist = parseWebOrigins('https://example.com, https://app.example.com');
40
+
41
+ // ---- missing Origin = non-browser client (curl, WeeChat, tcp-ws-forwarder)
42
+
43
+ it('allows a missing Origin header (non-browser client)', () => {
44
+ expect(decideOrigin(null, allowlist, 'https://example.com')).toBe('allow');
45
+ });
46
+
47
+ it('allows an empty Origin header (treated like a missing header)', () => {
48
+ expect(decideOrigin('', allowlist, 'https://example.com')).toBe('allow');
49
+ });
50
+
51
+ // ---- explicit allowlist (WEB_ORIGINS is set)
52
+
53
+ it('allows an Origin that is in the allowlist', () => {
54
+ expect(decideOrigin('https://example.com', allowlist, 'https://example.com')).toBe('allow');
55
+ });
56
+
57
+ it('allows an Origin that differs only by case', () => {
58
+ expect(decideOrigin('https://EXAMPLE.com', allowlist, 'https://example.com')).toBe('allow');
59
+ });
60
+
61
+ it('denies an Origin that is not in the allowlist', () => {
62
+ expect(decideOrigin('https://evil.example.net', allowlist, 'https://example.com')).toBe('deny');
63
+ });
64
+
65
+ it('denies an Origin that matches requestOrigin but is NOT in the allowlist (allowlist wins)', () => {
66
+ // WEB_ORIGINS takes precedence: if the operator pinned an explicit list,
67
+ // same-origin is NOT automatically allowed unless the origin is listed.
68
+ const strict = parseWebOrigins('https://only-this.com');
69
+ expect(decideOrigin('https://example.com', strict, 'https://example.com')).toBe('deny');
70
+ });
71
+
72
+ // ---- auto-derive (WEB_ORIGINS is empty — same-origin enforcement)
73
+
74
+ it('auto-allows a same-origin Origin when the allowlist is empty', () => {
75
+ expect(decideOrigin('https://example.com', new Set(), 'https://example.com')).toBe('allow');
76
+ });
77
+
78
+ it('auto-denies a cross-origin Origin when the allowlist is empty', () => {
79
+ expect(decideOrigin('https://evil.example.net', new Set(), 'https://example.com')).toBe('deny');
80
+ });
81
+
82
+ it('auto-derive is case-insensitive', () => {
83
+ expect(decideOrigin('https://EXAMPLE.com', new Set(), 'https://example.com')).toBe('allow');
84
+ });
85
+
86
+ it('auto-allows a missing Origin even when the allowlist is empty (non-browser client)', () => {
87
+ expect(decideOrigin(null, new Set(), 'https://example.com')).toBe('allow');
88
+ });
89
+
90
+ // ---- fail-closed (cannot determine same-origin AND no allowlist)
91
+
92
+ it('fail-closes when a browser sends an Origin but neither allowlist nor requestOrigin exist', () => {
93
+ expect(decideOrigin('https://example.com', new Set(), null)).toBe('fail-closed');
94
+ });
95
+
96
+ it('still allows a missing Origin even when neither allowlist nor requestOrigin exist (non-browser client)', () => {
97
+ // Non-browser clients never send Origin; they pass through regardless of
98
+ // configuration state. Only browser-sent Origins can be fail-closed.
99
+ expect(decideOrigin(null, new Set(), null)).toBe('allow');
100
+ });
101
+ });
@@ -30,6 +30,12 @@ declare global {
30
30
  SERVER_NAME: string;
31
31
  NETWORK_NAME: string;
32
32
  MOTD_LINES: string;
33
+ // Static-assets binding (`[assets]` in `wrangler.test.toml`, pointed at
34
+ // `tests/fixtures/web-dist`). Production points at `apps/web/dist`; the
35
+ // fixture mirrors the `webclient/index.html` + `index.html` layout so
36
+ // the worker's fall-through routing is exercised without depending on
37
+ // the Kiwi SPA build.
38
+ ASSETS: Fetcher;
33
39
  }
34
40
  }
35
41
  }
@@ -47,13 +53,33 @@ function numericEquals(line: string, code: string): boolean {
47
53
  }
48
54
 
49
55
  describe('cf-worker deploy glue', () => {
50
- it('non-WebSocket GET returns a human-readable health response', async () => {
51
- const response = await worker.fetch(new Request('https://irc.example.com/'), env);
56
+ it('GET /health returns a human-readable plaintext health response', async () => {
57
+ const response = await worker.fetch(new Request('https://irc.example.com/health'), env);
52
58
  expect(response.status).toBe(200);
59
+ expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8');
53
60
  const body = await response.text();
54
61
  expect(body.toLowerCase()).toContain('serverlessircd');
55
62
  });
56
63
 
64
+ it('GET /webclient/ serves the SPA index from the [assets] binding', async () => {
65
+ const response = await worker.fetch(new Request('https://irc.example.com/webclient/'), env);
66
+ expect(response.status).toBe(200);
67
+ const body = await response.text();
68
+ // The fixture SPA HTML carries this marker so the test asserts the
69
+ // assets binding (not the /health plaintext branch) produced the body.
70
+ expect(body).toContain('<title>KiwiIRC</title>');
71
+ });
72
+
73
+ it('GET / (landing page) is served by the [assets] binding, not the health plaintext', async () => {
74
+ const response = await worker.fetch(new Request('https://irc.example.com/'), env);
75
+ expect(response.status).toBe(200);
76
+ const body = await response.text();
77
+ // The fixture landing page carries this marker; the old health
78
+ // plaintext response is now exclusively at /health.
79
+ expect(body).toContain('<title>ServerlessIRCd</title>');
80
+ expect(body).not.toContain('Connect via WebSocket');
81
+ });
82
+
57
83
  it('completes CONNECT → REGISTER → JOIN → PRIVMSG → QUIT', async () => {
58
84
  // ----- CONNECT: WebSocket upgrade through the worker's fetch handler.
59
85
  const upgrade = new Request('https://irc.example.com/', {
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Integration tests for the WebSocket `Origin` policy (CSWSH defense).
3
+ *
4
+ * Drives the production worker entry (`src/worker.ts`) end-to-end through
5
+ * `worker.fetch` to assert the `Origin` header policy is enforced at the
6
+ * edge, before any upgrade reaches `ConnectionDO`. The pure policy logic
7
+ * is covered separately in `origin-allowlist.test.ts`; these tests assert
8
+ * the wiring (status codes + that the DO is never reached on a rejection).
9
+ *
10
+ * Two modes are exercised:
11
+ *
12
+ * • Explicit allowlist — `WEB_ORIGINS` is set in `wrangler.test.toml`
13
+ * `[vars]` as `https://example.com`. Only listed origins proceed.
14
+ *
15
+ * • Auto-derive (same-origin) — per-test override blanks `WEB_ORIGINS`;
16
+ * the Worker compares the browser's `Origin` against the request's own
17
+ * host. Same-origin upgrades proceed; cross-origin are rejected.
18
+ */
19
+
20
+ import { env } from 'cloudflare:test';
21
+ import { describe, expect, it } from 'vitest';
22
+ import worker from '../src/worker';
23
+
24
+ declare global {
25
+ namespace Cloudflare {
26
+ interface Env {
27
+ WEB_ORIGINS: string;
28
+ }
29
+ }
30
+ }
31
+
32
+ const UPGRADE_URL = 'https://irc.example.com/';
33
+ /** The request's own origin — what auto-derive compares Origin against. */
34
+ const SELF_ORIGIN = 'https://irc.example.com';
35
+
36
+ function upgradeRequest(origin?: string): Request {
37
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
38
+ if (origin !== undefined) headers.Origin = origin;
39
+ return new Request(UPGRADE_URL, { headers });
40
+ }
41
+
42
+ describe('WS Origin policy — explicit allowlist (WEB_ORIGINS set)', () => {
43
+ it('proceeds (101) when the Origin is in WEB_ORIGINS', async () => {
44
+ const response = await worker.fetch(upgradeRequest('https://example.com'), env);
45
+ expect(response.status).toBe(101);
46
+ response.webSocket?.accept();
47
+ response.webSocket?.close();
48
+ });
49
+
50
+ it('rejects with 403 when the Origin is not in WEB_ORIGINS', async () => {
51
+ const response = await worker.fetch(upgradeRequest('https://evil.example.net'), env);
52
+ expect(response.status).toBe(403);
53
+ expect(response.webSocket).toBeNull();
54
+ const body = await response.text();
55
+ expect(body.toLowerCase()).toContain('origin');
56
+ });
57
+
58
+ it('proceeds (101) when the Origin header is missing (non-browser client)', async () => {
59
+ const response = await worker.fetch(upgradeRequest(), env);
60
+ expect(response.status).toBe(101);
61
+ response.webSocket?.accept();
62
+ response.webSocket?.close();
63
+ });
64
+ });
65
+
66
+ describe('WS Origin policy — auto-derive (WEB_ORIGINS unset → same-origin)', () => {
67
+ /** Env with WEB_ORIGINS blanked so the Worker falls back to auto-derive. */
68
+ const autoEnv = { ...env, WEB_ORIGINS: '' };
69
+
70
+ it('proceeds (101) for a same-origin upgrade', async () => {
71
+ const response = await worker.fetch(upgradeRequest(SELF_ORIGIN), autoEnv);
72
+ expect(response.status).toBe(101);
73
+ response.webSocket?.accept();
74
+ response.webSocket?.close();
75
+ });
76
+
77
+ it('rejects with 403 for a cross-origin upgrade', async () => {
78
+ const response = await worker.fetch(upgradeRequest('https://evil.example.net'), autoEnv);
79
+ expect(response.status).toBe(403);
80
+ expect(response.webSocket).toBeNull();
81
+ });
82
+
83
+ it('proceeds (101) when the Origin header is missing (non-browser client)', async () => {
84
+ const response = await worker.fetch(upgradeRequest(), autoEnv);
85
+ expect(response.status).toBe(101);
86
+ response.webSocket?.accept();
87
+ response.webSocket?.close();
88
+ });
89
+ });
@@ -11,10 +11,33 @@ main = "src/worker.ts"
11
11
  compatibility_date = "2024-11-01"
12
12
  compatibility_flags = ["nodejs_compat"]
13
13
 
14
+ # Static-assets binding. Production points at `../../apps/web/dist` (the Kiwi
15
+ # SPA + landing page built by `apps/web`); the test config points at a fixture
16
+ # so the smoke suite does not depend on the (slow) Kiwi upstream build. The
17
+ # fixture mirrors the `webclient/index.html` + `index.html` layout; the
18
+ # worker's fall-through routing (`/health` → plaintext, else → ASSETS) is
19
+ # what's under test, not the SPA contents.
20
+ [assets]
21
+ directory = "./tests/fixtures/web-dist"
22
+ binding = "ASSETS"
23
+ # `none` returns 404 for unmatched paths — the SPA lives at `/webclient/` and
24
+ # the landing page at `/`, both with real `index.html` files, so no SPA
25
+ # fallback is required.
26
+ not_found_handling = "none"
27
+ # Mirror production: route every request through the Worker first so WS
28
+ # upgrades at `/` aren't intercepted by the asset platform (which would
29
+ # serve `index.html` with 200 instead of a 101 upgrade).
30
+ run_worker_first = true
31
+
14
32
  [vars]
15
33
  SERVER_NAME = "irc.example.com"
16
34
  NETWORK_NAME = "ServerlessIRCd"
17
35
  MOTD_LINES = "Welcome to the cf-worker smoke test."
36
+ # CSWSH allowlist. The ws-origin integration suite asserts an upgrade from
37
+ # `https://example.com` proceeds and `https://evil.example.net` is rejected
38
+ # (the fail-closed branch overrides this per request via `{ ...env,
39
+ # WEB_ORIGINS: '' }`).
40
+ WEB_ORIGINS = "https://example.com"
18
41
 
19
42
  [[durable_objects.bindings]]
20
43
  name = "CONNECTION_DO"
@@ -49,6 +49,15 @@ invocation_logs = true
49
49
  ## (`loadServerConfigFromCfEnv`) fails fast at boot when it is unset.
50
50
  ## Production deployments MUST override it to the public hostname
51
51
  ## clients see in numerics (001, 005, etc.).
52
+ ##
53
+ ## `WEB_ORIGINS` is the OPTIONAL comma-separated allowlist of web origins
54
+ ## permitted to open WebSocket upgrades (CSWSH defense). When set, only
55
+ ## listed origins proceed. When unset/empty (the default), the Worker
56
+ ## auto-derives the expected origin from the request's own `Host` header
57
+ ## (same-origin enforcement) — no per-env config needed. Set this only for
58
+ ## cross-origin deployments where the SPA is served from a different domain
59
+ ## than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
60
+ ## omit `Origin` and pass through unchanged regardless.
52
61
  [vars]
53
62
  SERVER_NAME = "irc.your-domain.invalid"
54
63
  NETWORK_NAME = "ServerlessIRCd"
@@ -57,6 +66,43 @@ Welcome to ServerlessIRCd.
57
66
  This is a deployed Cloudflare Worker.
58
67
  """
59
68
 
69
+ ## ---------------------------------------------------------------------------
70
+ ## Static assets — Kiwi SPA + landing page.
71
+ ## ---------------------------------------------------------------------------
72
+ ## The `[assets]` binding serves `apps/web/dist` (built by `pnpm --filter web
73
+ ## build`). The non-WS, non-`/health` branch of `worker.ts`'s `fetch` falls
74
+ ## through to this binding, so:
75
+ ## • `/webclient/` → the Kiwi IRC SPA (`dist/webclient/index.html`
76
+ ## + its hashed assets under
77
+ ## `/webclient/assets/...`).
78
+ ## • `/` → the landing page (`dist/index.html`, shipped by
79
+ ## the `apps/web` build).
80
+ ## • `/health` → plaintext liveness (handled by the worker, not
81
+ ## assets).
82
+ ## The deploy workflow (`deploy-cf.yml`) runs `pnpm build` before `wrangler
83
+ ## deploy` so `apps/web/dist` is freshly populated. `wrangler dev` serves the
84
+ ## same asset layer against the locally-built `dist`.
85
+ [assets]
86
+ ## Relative to this file (`apps/cf-worker/`), so `../../apps/web/dist`.
87
+ directory = "../../apps/web/dist"
88
+ ## Expose the asset `Fetcher` on `env.ASSETS` (see `WorkerEnv` in `worker.ts`).
89
+ binding = "ASSETS"
90
+ ## Return 404 for unmatched paths. The SPA lives at `/webclient/` and the
91
+ ## landing page at `/`; both are concrete `index.html` files, so no SPA
92
+ ## fallback is required. Kiwi uses query/hash-based client routing, not path
93
+ ## routing.
94
+ not_found_handling = "none"
95
+ ## Route EVERY request through the Worker before the asset platform serves a
96
+ ## matching static file. Without this, the default (`run_worker_first = false`)
97
+ ## causes the asset platform to serve `dist/index.html` at `/` with 200 OK for
98
+ ## WebSocket upgrade requests — the Worker's `Upgrade: websocket` check never
99
+ ## fires and the client sees "HTTP error: 200 OK" instead of a 101 upgrade.
100
+ ## The WS endpoint and the landing page share path `/`, so a path-pattern
101
+ ## exclusion can't tell them apart; the Worker must run first for everything.
102
+ ## The Worker falls through to `env.ASSETS.fetch(request)` for non-WS,
103
+ ## non-`/health` requests, so static content is unaffected.
104
+ run_worker_first = true
105
+
60
106
  ## ---------------------------------------------------------------------------
61
107
  ## Staging environment.
62
108
  ## ---------------------------------------------------------------------------
@@ -79,6 +125,16 @@ This is a deployed Cloudflare Worker.
79
125
  This Worker was deployed by CI from the main branch.
80
126
  """
81
127
 
128
+ # Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
129
+ # per-env assets block so `--env staging` deploys both the Worker and its
130
+ # assets in a single `wrangler deploy --env staging` (the same `pnpm build`
131
+ # populates `dist/` ahead of the deploy step).
132
+ [env.staging.assets]
133
+ directory = "../../apps/web/dist"
134
+ binding = "ASSETS"
135
+ not_found_handling = "none"
136
+ run_worker_first = true
137
+
82
138
  ## ---------------------------------------------------------------------------
83
139
  ## D1 database — SASL account persistence.
84
140
  ## ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -35,6 +35,14 @@ export interface CliConfigInput {
35
35
  channelPrefixes?: string;
36
36
  maxClients?: number;
37
37
  operCreds?: Array<{ user: string; password: string }>;
38
+ /**
39
+ * Server-password gate. Treat as a secret; the loader threads it
40
+ * straight through (via `stripUndefined`) to `ServerConfig.serverPassword`
41
+ * so the registration reducer requires `PASS <value>` (or a SASL-
42
+ * identified connection) before `001 RPL_WELCOME`. Undefined disables
43
+ * the gate.
44
+ */
45
+ serverPassword?: string | undefined;
38
46
  maxChannelsPerUser?: number;
39
47
  maxTargetsPerCommand?: number;
40
48
  maxListEntries?: number;