serverless-ircd 0.6.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 (247) hide show
  1. package/.github/workflows/ci.yml +6 -1
  2. package/.gitmodules +3 -0
  3. package/CHANGELOG.md +214 -0
  4. package/README.md +110 -23
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/cf-tcp-container/package.json +1 -1
  7. package/apps/cf-worker/package.json +1 -1
  8. package/apps/cf-worker/src/origin-allowlist.ts +99 -0
  9. package/apps/cf-worker/src/worker.ts +89 -9
  10. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +18 -0
  11. package/apps/cf-worker/tests/fixtures/web-dist/index.html +18 -0
  12. package/apps/cf-worker/tests/origin-allowlist.test.ts +101 -0
  13. package/apps/cf-worker/tests/smoke.test.ts +28 -2
  14. package/apps/cf-worker/tests/ws-origin.test.ts +89 -0
  15. package/apps/cf-worker/wrangler.test.toml +23 -0
  16. package/apps/cf-worker/wrangler.toml +54 -0
  17. package/apps/local-cli/package.json +1 -1
  18. package/apps/web/landing/index.html +217 -0
  19. package/apps/web/package.json +28 -0
  20. package/apps/web/scripts/build.mjs +125 -0
  21. package/apps/web/src/build-env.ts +43 -0
  22. package/apps/web/src/config-schema.ts +138 -0
  23. package/apps/web/static/config.json +28 -0
  24. package/apps/web/static/config.prod.json +28 -0
  25. package/apps/web/static/config.staging.json +28 -0
  26. package/apps/web/tests/build-env.test.ts +63 -0
  27. package/apps/web/tests/build-smoke.test.ts +103 -0
  28. package/apps/web/tests/config-schema.test.ts +432 -0
  29. package/apps/web/tests/workspace.test.ts +12 -0
  30. package/apps/web/tsconfig.json +10 -0
  31. package/apps/web/upstream/.browserslistrc +3 -0
  32. package/apps/web/upstream/.editorconfig +9 -0
  33. package/apps/web/upstream/.eslintignore +3 -0
  34. package/apps/web/upstream/.eslintrc.js +90 -0
  35. package/apps/web/upstream/.github/FUNDING.yml +12 -0
  36. package/apps/web/upstream/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  37. package/apps/web/upstream/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  38. package/apps/web/upstream/.github/workflows/push.yml +26 -0
  39. package/apps/web/upstream/.github/workflows/release.yml +33 -0
  40. package/apps/web/upstream/.github/workflows/release_manual.yml +55 -0
  41. package/apps/web/upstream/.prettierrc.js +9 -0
  42. package/apps/web/upstream/.stylelintignore +5 -0
  43. package/apps/web/upstream/.stylelintrc.js +21 -0
  44. package/apps/web/upstream/LICENSE +201 -0
  45. package/apps/web/upstream/README.md +76 -0
  46. package/apps/web/upstream/babel.config.js +25 -0
  47. package/apps/web/upstream/crowdin.yml +3 -0
  48. package/apps/web/upstream/index.html +35 -0
  49. package/apps/web/upstream/jest.config.js +7 -0
  50. package/apps/web/upstream/jsdoc.json +22 -0
  51. package/apps/web/upstream/package.json +80 -0
  52. package/apps/web/upstream/scripts/pre-commit +43 -0
  53. package/apps/web/upstream/src/components/App.vue +516 -0
  54. package/apps/web/upstream/src/components/AppSettings.vue +587 -0
  55. package/apps/web/upstream/src/components/AutoComplete.vue +284 -0
  56. package/apps/web/upstream/src/components/Avatar.vue +105 -0
  57. package/apps/web/upstream/src/components/AwayStatusIndicator.vue +77 -0
  58. package/apps/web/upstream/src/components/BufferKey.vue +121 -0
  59. package/apps/web/upstream/src/components/BufferSettings.vue +97 -0
  60. package/apps/web/upstream/src/components/Captcha.vue +85 -0
  61. package/apps/web/upstream/src/components/ChannelBanlist.vue +96 -0
  62. package/apps/web/upstream/src/components/ChannelInfo.vue +159 -0
  63. package/apps/web/upstream/src/components/ChannelInvitelist.vue +276 -0
  64. package/apps/web/upstream/src/components/ChannelList.vue +345 -0
  65. package/apps/web/upstream/src/components/Container.vue +401 -0
  66. package/apps/web/upstream/src/components/ContainerHeader.vue +522 -0
  67. package/apps/web/upstream/src/components/ControlInput.vue +1016 -0
  68. package/apps/web/upstream/src/components/LoadingAnimation.vue +104 -0
  69. package/apps/web/upstream/src/components/MediaViewer.vue +199 -0
  70. package/apps/web/upstream/src/components/MessageInfo.vue +192 -0
  71. package/apps/web/upstream/src/components/MessageList.vue +1028 -0
  72. package/apps/web/upstream/src/components/MessageListAvatar.vue +20 -0
  73. package/apps/web/upstream/src/components/MessageListMessageCompact.vue +361 -0
  74. package/apps/web/upstream/src/components/MessageListMessageInline.vue +257 -0
  75. package/apps/web/upstream/src/components/MessageListMessageModern.vue +430 -0
  76. package/apps/web/upstream/src/components/NetworkSettings.vue +528 -0
  77. package/apps/web/upstream/src/components/Nicklist.vue +309 -0
  78. package/apps/web/upstream/src/components/NicklistUser.vue +181 -0
  79. package/apps/web/upstream/src/components/NotConnected.vue +271 -0
  80. package/apps/web/upstream/src/components/SelfUser.vue +283 -0
  81. package/apps/web/upstream/src/components/ServerSelector.vue +162 -0
  82. package/apps/web/upstream/src/components/ServerView.vue +126 -0
  83. package/apps/web/upstream/src/components/SettingsAdvanced.vue +232 -0
  84. package/apps/web/upstream/src/components/SettingsAliases.vue +121 -0
  85. package/apps/web/upstream/src/components/Sidebar.vue +429 -0
  86. package/apps/web/upstream/src/components/SidebarAboutBuffer.vue +288 -0
  87. package/apps/web/upstream/src/components/SidebarState.vue +145 -0
  88. package/apps/web/upstream/src/components/StartupError.vue +35 -0
  89. package/apps/web/upstream/src/components/StateBrowser.vue +420 -0
  90. package/apps/web/upstream/src/components/StateBrowserBuffer.vue +124 -0
  91. package/apps/web/upstream/src/components/StateBrowserNetwork.vue +823 -0
  92. package/apps/web/upstream/src/components/StateBrowserUsermenu.vue +162 -0
  93. package/apps/web/upstream/src/components/TypingStatusIndicator.vue +34 -0
  94. package/apps/web/upstream/src/components/TypingUsersList.vue +56 -0
  95. package/apps/web/upstream/src/components/UrlEmbed.vue +149 -0
  96. package/apps/web/upstream/src/components/UserBox.vue +728 -0
  97. package/apps/web/upstream/src/components/inputtools/Emoji.vue +67 -0
  98. package/apps/web/upstream/src/components/inputtools/TextStyle.vue +142 -0
  99. package/apps/web/upstream/src/components/startups/CommonLayout.vue +208 -0
  100. package/apps/web/upstream/src/components/startups/CustomServer.vue +495 -0
  101. package/apps/web/upstream/src/components/startups/KiwiBnc.vue +361 -0
  102. package/apps/web/upstream/src/components/startups/Personal.vue +317 -0
  103. package/apps/web/upstream/src/components/startups/Welcome.vue +544 -0
  104. package/apps/web/upstream/src/components/startups/ZncLogin.vue +229 -0
  105. package/apps/web/upstream/src/components/utils/InputConfirm.vue +68 -0
  106. package/apps/web/upstream/src/components/utils/InputPrompt.vue +118 -0
  107. package/apps/web/upstream/src/components/utils/InputText.vue +172 -0
  108. package/apps/web/upstream/src/components/utils/IrcInput.vue +579 -0
  109. package/apps/web/upstream/src/components/utils/PluginWrapper.vue +26 -0
  110. package/apps/web/upstream/src/components/utils/TabbedView.vue +149 -0
  111. package/apps/web/upstream/src/components/utils/TransitionExpand.vue +97 -0
  112. package/apps/web/upstream/src/helpers/Colours.js +128 -0
  113. package/apps/web/upstream/src/helpers/IrcdDiffs.js +26 -0
  114. package/apps/web/upstream/src/helpers/Md5.js +193 -0
  115. package/apps/web/upstream/src/helpers/Misc.js +401 -0
  116. package/apps/web/upstream/src/helpers/TextFormatting.js +237 -0
  117. package/apps/web/upstream/src/libs/AliasRewriter.js +157 -0
  118. package/apps/web/upstream/src/libs/AudioManager.js +60 -0
  119. package/apps/web/upstream/src/libs/BouncerMiddleware.js +247 -0
  120. package/apps/web/upstream/src/libs/BouncerProvider.js +551 -0
  121. package/apps/web/upstream/src/libs/ChathistoryMiddleware.js +153 -0
  122. package/apps/web/upstream/src/libs/ConfigLoader.js +94 -0
  123. package/apps/web/upstream/src/libs/EmojiProvider.js +46 -0
  124. package/apps/web/upstream/src/libs/GlobalApi.js +346 -0
  125. package/apps/web/upstream/src/libs/IPC.js +51 -0
  126. package/apps/web/upstream/src/libs/InputHandler.js +921 -0
  127. package/apps/web/upstream/src/libs/IrcClient.js +1506 -0
  128. package/apps/web/upstream/src/libs/Logger.js +71 -0
  129. package/apps/web/upstream/src/libs/Message.js +169 -0
  130. package/apps/web/upstream/src/libs/MessageFormatter.js +364 -0
  131. package/apps/web/upstream/src/libs/MessageParser.js +249 -0
  132. package/apps/web/upstream/src/libs/Notifications.js +78 -0
  133. package/apps/web/upstream/src/libs/ServerConnection.js +270 -0
  134. package/apps/web/upstream/src/libs/ServerSession.js +102 -0
  135. package/apps/web/upstream/src/libs/SoundBleep.js +20 -0
  136. package/apps/web/upstream/src/libs/StatePersistence.js +90 -0
  137. package/apps/web/upstream/src/libs/ThemeManager.js +128 -0
  138. package/apps/web/upstream/src/libs/TypingMiddleware.js +105 -0
  139. package/apps/web/upstream/src/libs/WindowTitle.js +63 -0
  140. package/apps/web/upstream/src/libs/batchedAdd.js +74 -0
  141. package/apps/web/upstream/src/libs/bufferTools.js +177 -0
  142. package/apps/web/upstream/src/libs/polyfill/Element.closest.js +18 -0
  143. package/apps/web/upstream/src/libs/renderers/Html.js +113 -0
  144. package/apps/web/upstream/src/libs/settingTools.js +31 -0
  145. package/apps/web/upstream/src/libs/state/BufferState.js +703 -0
  146. package/apps/web/upstream/src/libs/state/NetworkState.js +158 -0
  147. package/apps/web/upstream/src/libs/state/UserState.js +89 -0
  148. package/apps/web/upstream/src/libs/state/common.js +26 -0
  149. package/apps/web/upstream/src/libs/state.js +961 -0
  150. package/apps/web/upstream/src/libs/storage/Local.js +51 -0
  151. package/apps/web/upstream/src/main.js +566 -0
  152. package/apps/web/upstream/src/res/autocompleteCommands.js +31 -0
  153. package/apps/web/upstream/src/res/configTemplates.js +373 -0
  154. package/apps/web/upstream/src/res/globalStyle.css +277 -0
  155. package/apps/web/upstream/src/res/kiwiLoadingLogo.png +0 -0
  156. package/apps/web/upstream/src/res/locales/app.af-ZA.po +1145 -0
  157. package/apps/web/upstream/src/res/locales/app.ar-SA.po +1145 -0
  158. package/apps/web/upstream/src/res/locales/app.bg-BG.po +1145 -0
  159. package/apps/web/upstream/src/res/locales/app.bs-BA.po +1145 -0
  160. package/apps/web/upstream/src/res/locales/app.ca-ES.po +1145 -0
  161. package/apps/web/upstream/src/res/locales/app.cs-CZ.po +1145 -0
  162. package/apps/web/upstream/src/res/locales/app.da-DK.po +1145 -0
  163. package/apps/web/upstream/src/res/locales/app.de-DE.po +1145 -0
  164. package/apps/web/upstream/src/res/locales/app.dev.po +1246 -0
  165. package/apps/web/upstream/src/res/locales/app.el-GR.po +1145 -0
  166. package/apps/web/upstream/src/res/locales/app.en-US.po +1145 -0
  167. package/apps/web/upstream/src/res/locales/app.es-419.po +1145 -0
  168. package/apps/web/upstream/src/res/locales/app.es-AR.po +1145 -0
  169. package/apps/web/upstream/src/res/locales/app.es-EM.po +536 -0
  170. package/apps/web/upstream/src/res/locales/app.es-ES.po +1145 -0
  171. package/apps/web/upstream/src/res/locales/app.es-US.po +1145 -0
  172. package/apps/web/upstream/src/res/locales/app.eu-ES.po +1145 -0
  173. package/apps/web/upstream/src/res/locales/app.fi-FI.po +1145 -0
  174. package/apps/web/upstream/src/res/locales/app.fr-FR.po +1145 -0
  175. package/apps/web/upstream/src/res/locales/app.gl-ES.po +1145 -0
  176. package/apps/web/upstream/src/res/locales/app.he-IL.po +1145 -0
  177. package/apps/web/upstream/src/res/locales/app.hi-IN.po +1145 -0
  178. package/apps/web/upstream/src/res/locales/app.hu-HU.po +1145 -0
  179. package/apps/web/upstream/src/res/locales/app.id-ID.po +1145 -0
  180. package/apps/web/upstream/src/res/locales/app.it-IT.po +1145 -0
  181. package/apps/web/upstream/src/res/locales/app.ja-JP.po +1145 -0
  182. package/apps/web/upstream/src/res/locales/app.ko-KR.po +1145 -0
  183. package/apps/web/upstream/src/res/locales/app.nl-NL.po +1145 -0
  184. package/apps/web/upstream/src/res/locales/app.no-NO.po +1145 -0
  185. package/apps/web/upstream/src/res/locales/app.pl-PL.po +1145 -0
  186. package/apps/web/upstream/src/res/locales/app.pt-BR.po +1145 -0
  187. package/apps/web/upstream/src/res/locales/app.pt-PT.po +1145 -0
  188. package/apps/web/upstream/src/res/locales/app.ro-RO.po +1145 -0
  189. package/apps/web/upstream/src/res/locales/app.ru-RU.po +1145 -0
  190. package/apps/web/upstream/src/res/locales/app.sl-SI.po +1145 -0
  191. package/apps/web/upstream/src/res/locales/app.sq-AL.po +1145 -0
  192. package/apps/web/upstream/src/res/locales/app.sr-SP.po +1145 -0
  193. package/apps/web/upstream/src/res/locales/app.sv-SE.po +1145 -0
  194. package/apps/web/upstream/src/res/locales/app.tr-TR.po +1145 -0
  195. package/apps/web/upstream/src/res/locales/app.uk-UA.po +1145 -0
  196. package/apps/web/upstream/src/res/locales/app.vi-VN.po +1145 -0
  197. package/apps/web/upstream/src/res/locales/app.zh-CN.po +1145 -0
  198. package/apps/web/upstream/src/res/locales/app.zh-TW.po +1145 -0
  199. package/apps/web/upstream/src/res/localesList.json +27 -0
  200. package/apps/web/upstream/src/res/logo.png +0 -0
  201. package/apps/web/upstream/src/thirdparty/about.html +43 -0
  202. package/apps/web/upstream/src/thirdparty/index.js +5 -0
  203. package/apps/web/upstream/src/thirdparty/kiwiirccom.vue +185 -0
  204. package/apps/web/upstream/static/config.json +28 -0
  205. package/apps/web/upstream/static/emoticons/smile.png +0 -0
  206. package/apps/web/upstream/static/favicon.png +0 -0
  207. package/apps/web/upstream/static/highlight.mp3 +0 -0
  208. package/apps/web/upstream/static/highlight.ogg +0 -0
  209. package/apps/web/upstream/static/locales/.gitignore +2 -0
  210. package/apps/web/upstream/static/plugins/customise.html.example +10 -0
  211. package/apps/web/upstream/static/themes/coffee/theme.css +145 -0
  212. package/apps/web/upstream/static/themes/common/base.css +1064 -0
  213. package/apps/web/upstream/static/themes/dark/theme.css +196 -0
  214. package/apps/web/upstream/static/themes/default/theme.css +92 -0
  215. package/apps/web/upstream/static/themes/elite/theme.css +248 -0
  216. package/apps/web/upstream/static/themes/grayfox/theme.css +143 -0
  217. package/apps/web/upstream/static/themes/nightswatch/theme.css +367 -0
  218. package/apps/web/upstream/static/themes/osprey/theme.css +115 -0
  219. package/apps/web/upstream/static/themes/radioactive/theme.css +1211 -0
  220. package/apps/web/upstream/static/themes/sky/theme.css +107 -0
  221. package/apps/web/upstream/tests/unit/BatchAdd.spec.js +174 -0
  222. package/apps/web/upstream/tests/unit/MessageParser.spec.js +125 -0
  223. package/apps/web/upstream/tests/unit/Misc.spec.js +24 -0
  224. package/apps/web/upstream/tests/unit/NetworkState.spec.js +58 -0
  225. package/apps/web/upstream/tests/unit/StartupError.spec.js +20 -0
  226. package/apps/web/upstream/vue.config.js +150 -0
  227. package/apps/web/upstream/yarn.lock +9767 -0
  228. package/apps/web/vitest.config.ts +13 -0
  229. package/biome.json +1 -0
  230. package/docs/Home.md +1 -0
  231. package/docs/WebClientGuide.md +535 -0
  232. package/package.json +2 -2
  233. package/packages/aws-adapter/package.json +1 -1
  234. package/packages/cf-adapter/package.json +1 -1
  235. package/packages/cf-adapter/tests/cf-harness.ts +12 -1
  236. package/packages/in-memory-runtime/package.json +1 -1
  237. package/packages/irc-core/package.json +1 -1
  238. package/packages/irc-core/reports/mutation/mutation.html +342 -0
  239. package/packages/irc-core/tests/batch.test.ts +15 -0
  240. package/packages/irc-core/tests/numerics.test.ts +13 -0
  241. package/packages/irc-core/tests/parser.test.ts +109 -0
  242. package/packages/irc-server/package.json +1 -1
  243. package/packages/irc-test-support/package.json +1 -1
  244. package/packages/irc-test-support/src/scenarios.ts +24 -2
  245. package/tools/ci-hardening/package.json +1 -1
  246. package/tools/load-test/package.json +1 -1
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -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 `/app/` 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
+ * `/app/` SPA + `/` landing page). The cf-adapter's internal types stay
51
+ * 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 `/app/`, 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 mirroring the real `apps/web/dist/app/index.html` produced
4
+ by the Kiwi SPA build (`pnpm --filter web build`). The cf-worker smoke suite
5
+ points its `[assets]` binding here so the worker's fall-through routing
6
+ (WS → ConnectionDO, /health → plaintext, else → ASSETS) is exercised without
7
+ depending on the (slow, submodule-heavy) Kiwi build. Production points at
8
+ `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,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,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 `app/index.html` + `index.html` layout so the
36
+ // worker's fall-through routing is exercised without depending on the
37
+ // 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 /app/ serves the SPA index from the [assets] binding', async () => {
65
+ const response = await worker.fetch(new Request('https://irc.example.com/app/'), 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 `app/index.html` + `index.html` layout; the worker's
18
+ # fall-through routing (`/health` → plaintext, else → ASSETS) is what's under
19
+ # 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 `/app/` and the
24
+ # landing page at `/`, both with real `index.html` files, so no SPA fallback
25
+ # 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,41 @@ 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
+ ## • `/app/` → the Kiwi IRC SPA (`dist/app/index.html` + its
76
+ ## hashed assets under `/app/assets/...`).
77
+ ## • `/` → the landing page (`dist/index.html`, shipped by
78
+ ## the `apps/web` build).
79
+ ## • `/health` → plaintext liveness (handled by the worker, not
80
+ ## assets).
81
+ ## The deploy workflow (`deploy-cf.yml`) runs `pnpm build` before `wrangler
82
+ ## deploy` so `apps/web/dist` is freshly populated. `wrangler dev` serves the
83
+ ## same asset layer against the locally-built `dist`.
84
+ [assets]
85
+ ## Relative to this file (`apps/cf-worker/`), so `../../apps/web/dist`.
86
+ directory = "../../apps/web/dist"
87
+ ## Expose the asset `Fetcher` on `env.ASSETS` (see `WorkerEnv` in `worker.ts`).
88
+ binding = "ASSETS"
89
+ ## Return 404 for unmatched paths. The SPA lives at `/app/` and the landing
90
+ ## page at `/`; both are concrete `index.html` files, so no SPA fallback is
91
+ ## required. Kiwi uses query/hash-based client routing, not path routing.
92
+ not_found_handling = "none"
93
+ ## Route EVERY request through the Worker before the asset platform serves a
94
+ ## matching static file. Without this, the default (`run_worker_first = false`)
95
+ ## causes the asset platform to serve `dist/index.html` at `/` with 200 OK for
96
+ ## WebSocket upgrade requests — the Worker's `Upgrade: websocket` check never
97
+ ## fires and the client sees "HTTP error: 200 OK" instead of a 101 upgrade.
98
+ ## The WS endpoint and the landing page share path `/`, so a path-pattern
99
+ ## exclusion can't tell them apart; the Worker must run first for everything.
100
+ ## The Worker falls through to `env.ASSETS.fetch(request)` for non-WS,
101
+ ## non-`/health` requests, so static content is unaffected.
102
+ run_worker_first = true
103
+
60
104
  ## ---------------------------------------------------------------------------
61
105
  ## Staging environment.
62
106
  ## ---------------------------------------------------------------------------
@@ -79,6 +123,16 @@ This is a deployed Cloudflare Worker.
79
123
  This Worker was deployed by CI from the main branch.
80
124
  """
81
125
 
126
+ # Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
127
+ # per-env assets block so `--env staging` deploys both the Worker and its
128
+ # assets in a single `wrangler deploy --env staging` (the same `pnpm build`
129
+ # populates `dist/` ahead of the deploy step).
130
+ [env.staging.assets]
131
+ directory = "../../apps/web/dist"
132
+ binding = "ASSETS"
133
+ not_found_handling = "none"
134
+ run_worker_first = true
135
+
82
136
  ## ---------------------------------------------------------------------------
83
137
  ## D1 database — SASL account persistence.
84
138
  ## ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",