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
@@ -151,7 +151,12 @@ jobs:
151
151
  run: pnpm install --frozen-lockfile
152
152
 
153
153
  - name: Build workspace (irc-core mutation targets)
154
- run: pnpm build
154
+ # Scope the build to irc-core and its workspace deps only. The
155
+ # mutation spot-check mutates irc-core/protocol and irc-core/commands,
156
+ # so building the rest of the workspace (notably @serverless-ircd/web,
157
+ # which spawns `yarn` to build the Kiwi upstream SPA) is unnecessary
158
+ # and fails on runner images that don't ship yarn.
159
+ run: pnpm turbo run build --filter=@serverless-ircd/irc-core...
155
160
 
156
161
  - name: Stryker spot-check on irc-core/protocol
157
162
  # Mutation score threshold >=80% is enforced by the
package/.gitmodules CHANGED
@@ -1,3 +1,6 @@
1
1
  [submodule "docs"]
2
2
  path = docs
3
3
  url = https://gitea.com/fowlmouth/ServerlessIRCd.wiki.git
4
+ [submodule "apps/web/upstream"]
5
+ path = apps/web/upstream
6
+ url = https://github.com/kiwiirc/kiwiirc.git
package/CHANGELOG.md CHANGED
@@ -10,6 +10,220 @@ For the release process itself — versioning policy, pre-release checklist,
10
10
  cutting a tag, rolling back — see [`docs/release.md`](docs/release.md).
11
11
  Cross-reference `progress.md` / `tickets.md` for per-ticket detail.
12
12
 
13
+ ## [0.7.0] - 2026-08-04
14
+
15
+ The headline is the **web client (Phase 14)**: a vendored **Kiwi IRC**
16
+ SPA served at `/app/` and a static project landing page served at `/`
17
+ directly by the Cloudflare Worker, with **Cross-Site WebSocket
18
+ Hijacking (CSWSH) defense** wired into the WS upgrade path. The browser
19
+ opens a native `wss://` straight to the existing IRC-over-WebSocket
20
+ endpoint — no proxy, gateway, or transport adaptation in the hot path.
21
+ Alongside it: a round of **test hardening** (mutation-killing tests for
22
+ `irc-core`'s parser / batch / numerics, and CF-harness / scenario fixes
23
+ for slow transports).
24
+
25
+ ### Added — Web client (`@serverless-ircd/web`, `@serverless-ircd/cf-worker`)
26
+
27
+ Six Phase 14 tickets light up the project's browser surface. The Worker
28
+ becomes the single origin for the landing page, the SPA, **and** the
29
+ WebSocket. Same-origin by default — which is what makes the CSWSH
30
+ defense almost zero-config.
31
+
32
+ - **`apps/web` workspace package (`@serverless-ircd/web`)**: a new
33
+ pnpm workspace package owning the Kiwi build orchestration and config
34
+ schema. The package layout reserves `dist/app/` for the Kiwi SPA
35
+ (served at `/app/`) and `dist/index.html` for the landing page
36
+ (served at `/`). No `@serverless-ircd/*` runtime dependency —
37
+ Kiwi talks to the Worker over plain WebSocket.
38
+ - **Kiwi IRC vendored as a git submodule** (`apps/web/upstream/`,
39
+ tag-pinned): `scripts/build.mjs` patches the upstream `publicPath`
40
+ to `/app/`, runs `yarn build` inside the submodule, copies
41
+ `upstream/dist/` → `apps/web/dist/app/`, then layers our validated
42
+ `static/config.json` over the upstream default. The patch runs in
43
+ `finally` so the submodule stays clean; every hashed asset URL comes
44
+ out `/app/static/...` instead of root-relative. `.gitmodules` updated;
45
+ `biome.json` ignores `apps/web/upstream/**`. `git submodule update
46
+ --init` is now part of the contributor quickstart.
47
+ - **Per-env config matrix + Zod schema** (`apps/web/src/config-schema.ts`,
48
+ `apps/web/src/build-env.ts`): `static/config.json`,
49
+ `static/config.staging.json`, `static/config.prod.json` are validated
50
+ against a Zod schema **before** being baked into
51
+ `dist/app/static/config.json`. `pnpm --filter web build` accepts a
52
+ `--env <staging|prod>` flag (unit-tested in isolation from the slow
53
+ Kiwi build). A malformed config (missing `server`, out-of-range
54
+ `port`, non-`#` channel, `direct: false`, …) aborts the build with
55
+ an aggregated, field-listing error rather than shipping a broken SPA.
56
+ The Kiwi `startupOptions.server` uses the `{{hostname}}` template
57
+ (resolved by the browser to `window.location.hostname` at load time),
58
+ so **one build artifact targets staging, prod, and preview deploys
59
+ without a per-env hostname**.
60
+ - **Cloudflare Worker `[assets]` binding** (`apps/cf-worker/wrangler.toml`,
61
+ `apps/cf-worker/src/worker.ts`): the non-WS, non-`/health` branch of
62
+ the worker's `fetch` falls through to `env.ASSETS`, which serves
63
+ `apps/web/dist`. Per-env (`[env.staging.assets]`) blocks ship so
64
+ `wrangler deploy --env staging` deploys the Worker and the SPA in
65
+ one command. `run_worker_first = true` is set so WS upgrades aren't
66
+ intercepted by the asset platform serving `dist/index.html` at `/`
67
+ with a `200 OK` (the WS endpoint and the landing page share path
68
+ `/`; only the Worker can tell them apart).
69
+ - **Static landing page** (`apps/web/landing/index.html`): framework-
70
+ free HTML + inline CSS, no JavaScript. The project's front door —
71
+ explains what ServerlessIRCd is, links to `/app/` (the IRC client),
72
+ the docs, and the source repo. The build copies it verbatim to
73
+ `dist/index.html`. Renders without JS; responsive; the build-smoke
74
+ suite asserts the `/app/` link, the docs link, and the viewport meta
75
+ tag so they cannot regress.
76
+ - **`/health` is an explicit route**: the previous catch-all plaintext
77
+ liveness string now lives at `GET /health` so `/` is free for the
78
+ landing page. WS upgrades still route to `ConnectionDO`.
79
+
80
+ ### Added — CSWSH defense (`@serverless-ircd/cf-worker`)
81
+
82
+ WebSocket upgrades do **not** follow the same-origin policy. A
83
+ malicious page can `new WebSocket('wss://irc.example.com/')` from the
84
+ victim's browser and drive an IRC session with the victim's
85
+ credentials. The Worker now rejects upgrades whose `Origin` is not
86
+ allowed — see `apps/cf-worker/src/origin-allowlist.ts` and
87
+ `docs/WebClientGuide.md` §5.
88
+
89
+ - **Two enforcement modes, evaluated in order:**
90
+ 1. **Explicit allowlist** — when the `WEB_ORIGINS` var is set, only
91
+ listed origins proceed. Use this for cross-origin deployments
92
+ (SPA on a different domain than the Worker, e.g. Cloudflare
93
+ Pages).
94
+ 2. **Auto-derive (same-origin)** — when `WEB_ORIGINS` is unset/empty
95
+ (the default), the Worker compares the browser's `Origin` against
96
+ the request's own origin (`${protocol}//${host}`). If they match,
97
+ the page opening the WS is the same origin as the server → safe.
98
+ - **`WEB_ORIGINS` is optional.** Because mode 2 is the default and
99
+ requires zero per-env configuration, most deployments never need to
100
+ set it: the SPA and the Worker are on the same origin. It works for
101
+ `*.workers.dev`, custom domains, and preview URLs alike.
102
+ - **Non-browser clients pass through unchanged.** curl, WeeChat, the
103
+ `tcp-ws-forwarder`, and scripted test harnesses never send `Origin`;
104
+ their upgrades are allowed in both modes so existing tooling keeps
105
+ working.
106
+ - **No fail-open path.** A browser-sent `Origin` that is not allowed
107
+ → `403 Forbidden`. The extreme edge case (no allowlist AND
108
+ `Host` undeterminable, which never fires on Cloudflare) is
109
+ fail-closed `403`. The pure policy is exhaustively unit-tested in
110
+ `apps/cf-worker/tests/origin-allowlist.test.ts`; the worker
111
+ integration is covered by `tests/ws-origin.test.ts`.
112
+
113
+ ### Added — Documentation (`docs/WebClientGuide.md`)
114
+
115
+ End-to-end contributor/operator doc covering: how the SPA, landing
116
+ page, and WS endpoint fit on one origin; prerequisites (incl. `yarn`
117
+ for the Kiwi build only); the quickstart (`git submodule update --init`
118
+ → `pnpm --filter web build:staging` → `pnpm deploy:cf:staging`); a
119
+ walkthrough of the build pipeline and the per-env config matrix; the
120
+ CSWSH rationale and the optional `WEB_ORIGINS` var; local dev
121
+ (`wrangler dev` serves everything on `http://localhost:8787`); an
122
+ optional Cloudflare Pages alternative for cross-origin prod deploys;
123
+ the optional `webircgateway` SockJS fallback (strictly opt-in, not
124
+ needed for normal operation); landing-page customisation; and a
125
+ troubleshooting section. The acceptance criterion is that a new
126
+ contributor can deploy a staging SPA following only this doc.
127
+
128
+ ### Added — Test hardening (`@serverless-ircd/irc-core`, `@serverless-ircd/cf-adapter`, `@serverless-ircd/irc-test-support`)
129
+
130
+ - **Mutation-killing tests for `irc-core`**: the parser, batch, and
131
+ numerics suites were expanded to kill surviving Stryker mutants —
132
+ command-token anchors, strict source-prefix shape, malformed-input
133
+ diagnostic text, middle-param whitespace collapsing,
134
+ `unescapeTagValue` boundary conditions; empty-batch framing metadata
135
+ surviving elision; and `numericToName` round-trip parity against the
136
+ registry. A generated Stryker mutation report is committed under
137
+ `packages/irc-core/reports/mutation/`.
138
+ - **CF harness / scenario fixes for slow transports**: the CF test
139
+ harness now waits for the end of the registration burst (`376` /
140
+ `422`) instead of `001`, so late MOTD frames do not pollute
141
+ `received` after a test clears it. The NOTICE scenario now only
142
+ rejects NOTICE-relevant error numerics (rather than any numeric),
143
+ preventing delayed welcome / MOTD lines from false-positiving the
144
+ RFC no-error-replies check. No production code changed.
145
+
146
+ ### Changed
147
+
148
+ - **Cloudflare Worker routing.** `/health` is the new liveness route;
149
+ the root `/` now serves the landing page from the `[assets]`
150
+ binding. WS upgrades still route to `ConnectionDO`. Operators who
151
+ scraped the root `/` for the plaintext health string must update
152
+ to `/health`.
153
+
154
+ ### ⚠️ Migration required
155
+
156
+ - **New optional runtime var: `WEB_ORIGINS`.** Optional — same-origin
157
+ auto-derive is the default. Set it only for cross-origin SPA
158
+ deployments (SPA on Cloudflare Pages, Worker serving the WS only,
159
+ etc.):
160
+ ```toml
161
+ # apps/cf-worker/wrangler.toml
162
+ [vars]
163
+ WEB_ORIGINS = "https://irc.example.com,https://web.example.com"
164
+ ```
165
+ - **New build prerequisite for the SPA.** Building `apps/web` needs
166
+ the Kiwi submodule (`git submodule update --init apps/web/upstream`)
167
+ and `yarn` (provided by `corepack enable`). The CF deploy workflow
168
+ runs `pnpm build` (which builds the SPA + landing page) before
169
+ `wrangler deploy`. If you deploy manually and skip the SPA build,
170
+ the Worker still works but `/app/` and `/` return 404 from the asset
171
+ layer.
172
+ - **`/health` is the new liveness route.** Operators who scraped `/`
173
+ for the plaintext health string must update to `/health`.
174
+ - **New workspace package.** `apps/web` is a new private package
175
+ (`@serverless-ircd/web`); `pnpm install` picks it up automatically
176
+ from the existing `pnpm-workspace.yaml` glob.
177
+ - **No persisted-state schema change.** `PERSISTED_STATE_VERSION`
178
+ stays `1`. **Code rollback across this release is safe** — the
179
+ web-client work adds the `[assets]` binding and the `/health` route
180
+ but touches no Durable Object state.
181
+ - No toolchain change. Node ≥ 24 / pnpm 11 (from v0.4.0) still apply.
182
+
183
+ ### Known limitations
184
+
185
+ - **Phase 14 is partial.** The Playwright headless-browser e2e
186
+ against the deployed staging frontend remains pending
187
+ — the SPA is exercised via the existing WS smoke
188
+ (`scripts/smoke.mjs`) and the new `apps/web` unit / build-smoke
189
+ suite, but no browser-driven end-to-end test runs in CI yet.
190
+ - **The web client is Cloudflare-only.** The `[assets]` binding is a
191
+ Cloudflare Worker feature; AWS deployments continue to require an
192
+ external static host for the SPA (and must add the SPA's origin to
193
+ the Worker-style `WEB_ORIGINS` equivalent if/when an AWS-side
194
+ CSWSH check lands).
195
+ - Same transport limits as v0.6.0: the **CF TCP path requires
196
+ Cloudflare Spectrum (Enterprise tier)** plus a stateful Container
197
+ origin; the **AWS TCP path** uses NLB + Lambda streaming and is
198
+ subject to Lambda idle-timeout / stream-duration limits. The
199
+ WebSocket path remains the zero-extra-deps default.
200
+ - **mTLS (for SASL `EXTERNAL`) requires a custom domain + uploaded
201
+ CA/trust-store** on both platforms and is not enabled by default.
202
+ - **Client compatibility sweep** (WeeChat / HexChat / IRCCloud /
203
+ TheLounge / matrix-IRC bridge) remains pending.
204
+ - **Coverage hardening is partial.** `aws-adapter` and `aws-stack`
205
+ clear the 90% gate (v0.6.0); the remaining packages
206
+ (`cf-adapter`, `local-cli`, `load-test`, `cf-tcp-container`,
207
+ `tcp-ws-forwarder`, `irc-test-support`, and now `web`) are **above
208
+ the gate but below 100%** — follow-ups drive each to 100%.
209
+ - The IRC services Phase 13 work (NickServ / ChanServ / HostServ /
210
+ OperServ / MemoServ) is pending; `draft/read-marker` and
211
+ `draft/pre-away` ship with in-memory reference stores, so marker /
212
+ away persistence does not survive an adapter restart until a
213
+ services backend lands.
214
+
215
+ ### Security
216
+
217
+ - **CSWSH defense added.** WebSocket upgrades from browsers now
218
+ require an allowed `Origin`. Same-origin auto-derive is the default
219
+ (zero config); explicit `WEB_ORIGINS` allowlist for cross-origin
220
+ setups. Non-browser clients (no `Origin`) pass through unchanged.
221
+ There is no fail-open path. See `docs/WebClientGuide.md` §5. No
222
+ other auth path changed (SASL `PLAIN` / `EXTERNAL`, oper gate,
223
+ admission limits, and cloaking are unchanged from v0.6.0).
224
+
225
+ ---
226
+
13
227
  ## [0.6.0] - 2026-08-03
14
228
 
15
229
  The headline is the **IRCv3 extension sweep (Phase 12)**: ten negotiated
package/README.md CHANGED
@@ -6,7 +6,7 @@ platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
6
6
 
7
7
  One TypeScript codebase. Two serverless substrates.
8
8
 
9
- > **Status:** **v0.6.0 (preview).** The pure protocol core, the
9
+ > **Status:** **v0.7.0 (preview).** The pure protocol core, the
10
10
  > `IrcRuntime` port + in-memory runtime, a runnable local CLI server,
11
11
  > the **Cloudflare Workers** adapter, and the **AWS** (API Gateway
12
12
  > WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and
@@ -19,21 +19,24 @@ One TypeScript codebase. Two serverless substrates.
19
19
  > deferred IRC verbs (`KILL`, `REHASH`, `LUSERS` + `STATS`, `TRACE`,
20
20
  > `WALLOPS`, `SETNAME`) all land, and the S2S (`CONNECT` / `SQUIT` /
21
21
  > `LINKS`) and obsolete RFC 2812 (`SERVICE` / `SUMMON` / `USERS`) verbs
22
- > are formally dropped. **v0.6.0 lands the IRCv3 extension sweep
22
+ > are formally dropped. v0.6.0 landed the **IRCv3 extension sweep
23
23
  > (Phase 12)** — ten caps (`account-notify`, `msgid`,
24
24
  > `standard-replies`, `MONITOR`, `labeled-response`, `sts`,
25
25
  > `draft/typing`, `draft/multiline`, `draft/read-marker`,
26
26
  > `draft/pre-away`), the ISUPPORT tokens for the new caps, and the
27
- > read-only user mode `S` (TLS connected) closes the
28
- > required-`serverName` config gate, ships a `tools/load-test`
29
- > 10k-connection harness, and lifts `aws-adapter` / `aws-stack` over
30
- > the 90% coverage gate (CF packages moved to istanbul coverage,
31
- > clearing the v0.5.0 workerd-incompatibility caveat). Remaining 0.x
32
- > work: the client compatibility sweep (WeeChat / HexChat / IRCCloud /
33
- > TheLounge), IRC services (NickServ / ChanServ / HostServ /
34
- > OperServ / MemoServ), a web client (vendored Kiwi IRC), and driving
35
- > the remaining sub-100% packages to full coverage. See
36
- > `CHANGELOG.md` for the per-release manifests.
27
+ > read-only user mode `S` (TLS connected). **v0.7.0 lands the web
28
+ > client (Phase 14)**: a vendored **Kiwi IRC** SPA served at `/app/`
29
+ > and a static landing page at `/` directly by the Cloudflare Worker,
30
+ > with **Cross-Site WebSocket Hijacking (CSWSH) defense** wired into
31
+ > the WS upgrade path (same-origin auto-derive by default, optional
32
+ > `WEB_ORIGINS` allowlist for cross-origin deploys) the browser
33
+ > opens a native `wss://` straight to the existing IRC-over-WebSocket
34
+ > endpoint. Remaining 0.x work: the client compatibility sweep
35
+ > (WeeChat / HexChat / IRCCloud / TheLounge), the Playwright
36
+ > browser-driven e2e for the SPA, IRC services (NickServ / ChanServ /
37
+ > HostServ / OperServ / MemoServ), and driving the remaining
38
+ > sub-100% packages to full coverage. See `CHANGELOG.md` for the
39
+ > per-release manifests.
37
40
 
38
41
  ---
39
42
 
@@ -134,9 +137,10 @@ ServerlessIRCd/
134
137
  │ ├── cf-adapter/ Durable Objects + CfRuntime
135
138
  │ └── aws-adapter/ Lambda + DynamoDB + AwsRuntime
136
139
  ├── apps/
137
- │ ├── cf-worker/ worker entry, DO migrations, bindings
140
+ │ ├── cf-worker/ worker entry, DO migrations, bindings, [assets] + CSWSH gate
138
141
  │ ├── cf-tcp-container/ Spectrum + Container origin for irc+tls :6697
139
142
  │ ├── aws-stack/ CDK stack (APIGW WS + NLB + Lambda streaming + DynamoDB)
143
+ │ ├── web/ vendored Kiwi IRC SPA (→ /app/) + static landing page (→ /)
140
144
  │ └── local-cli/ runnable WS + TCP server using in-memory-runtime
141
145
  ├── tools/
142
146
  │ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
@@ -174,6 +178,7 @@ pin is resolved automatically:
174
178
  ```bash
175
179
  corepack enable # lets the pinned pnpm@11 run on any Node ≥ 24
176
180
 
181
+ git submodule update --init # docs + apps/web Kiwi upstream sources
177
182
  pnpm install # install workspace deps
178
183
 
179
184
  pnpm build # build all packages (turbo)
@@ -191,6 +196,14 @@ pnpm mutation # Stryker spot-check on irc-core (protocol + commands)
191
196
  pnpm clean # remove dist/coverage/.turbo + node_modules
192
197
  ```
193
198
 
199
+ The web client (`apps/web`) vendors Kiwi IRC as a git submodule. Building it
200
+ needs the upstream sources (`git submodule update --init apps/web/upstream`)
201
+ and `yarn` (Kiwi ships a `yarn.lock`; `corepack enable` provides it):
202
+
203
+ ```bash
204
+ pnpm --filter web build # builds the Kiwi SPA into apps/web/dist/app/ (/app/)
205
+ ```
206
+
194
207
  Coverage reports are written to `packages/*/coverage/`. CI (`.github/workflows/ci.yml`)
195
208
  runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
196
209
  Stryker mutation spot-check on every push and pull request. Coverage thresholds
@@ -364,6 +377,66 @@ report).
364
377
 
365
378
  ---
366
379
 
380
+ ## Web client (`apps/web`)
381
+
382
+ A vendored **Kiwi IRC** SPA served at `/app/` and a static project
383
+ landing page served at `/`, both baked out of `apps/web/dist/` and
384
+ served directly by the Cloudflare Worker via its `[assets]` binding.
385
+ The browser opens a native `wss://` straight to the Worker's existing
386
+ IRC-over-WebSocket endpoint — no proxy, gateway, or transport
387
+ adaptation in the hot path. See `docs/WebClientGuide.md` for the
388
+ end-to-end contributor/operator guide.
389
+
390
+ Build the SPA + landing page (needs the submodule + `yarn`, provided by
391
+ `corepack enable`):
392
+
393
+ ```bash
394
+ git submodule update --init apps/web/upstream # one-time per clone
395
+ pnpm --filter web build:staging # or :prod / default
396
+ # → apps/web/dist/index.html (landing page, served at /)
397
+ # → apps/web/dist/app/index.html (Kiwi SPA, served at /app/)
398
+ # → apps/web/dist/app/static/config.json (baked, env-specific)
399
+ ```
400
+
401
+ Then run everything locally (Worker serves SPA + landing page + WS on
402
+ one origin):
403
+
404
+ ```bash
405
+ pnpm build # workspace packages
406
+ pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
407
+ # / → landing page
408
+ # /app/ → Kiwi SPA (opens ws://localhost:8787/)
409
+ # /health → plaintext liveness
410
+ ```
411
+
412
+ Deploy staging (Worker + assets in one command):
413
+
414
+ ```bash
415
+ pnpm deploy:cf:staging # wrangler deploy --env staging
416
+ ```
417
+
418
+ ### WebSocket Origin policy (CSWSH defense)
419
+
420
+ WebSocket upgrades do **not** follow the same-origin policy, so the
421
+ Worker rejects browser upgrades whose `Origin` is not allowed — see
422
+ `apps/cf-worker/src/origin-allowlist.ts` and the SPA guide §5. Two
423
+ modes, evaluated in order:
424
+
425
+ 1. **Explicit allowlist** — set `WEB_ORIGINS` (comma-separated) for
426
+ cross-origin deploys (SPA on a different domain than the Worker,
427
+ e.g. Cloudflare Pages). Only listed origins proceed.
428
+ 2. **Same-origin auto-derive (the default)** — when `WEB_ORIGINS` is
429
+ unset/empty, the Worker compares the browser's `Origin` against the
430
+ request's own origin. Match → proceed; mismatch → `403 Forbidden`.
431
+
432
+ `WEB_ORIGINS` is **optional** — same-origin auto-derive needs zero
433
+ per-env config and works for `*.workers.dev`, custom domains, and
434
+ preview URLs alike. Non-browser clients (curl, WeeChat, the
435
+ `tcp-ws-forwarder`, scripted harnesses) never send `Origin` and pass
436
+ through unchanged in both modes.
437
+
438
+ ---
439
+
367
440
  ## WebSocket transport (IRCv3 subprotocols)
368
441
 
369
442
  The WebSocket entry points (the local CLI, the Cloudflare Worker, and the
@@ -484,14 +557,19 @@ cleanly. Per-release manifests live in `CHANGELOG.md`.
484
557
  backends (D1 / DynamoDB) ship alongside; until then
485
558
  `draft/read-marker` and `draft/pre-away` use in-memory reference
486
559
  stores and do not survive an adapter restart.
487
- - **Phase 14 — Web client.** pending: vendored Kiwi IRC SPA served
488
- from the Worker, WS Origin allowlist (CSWSH defense), landing page,
489
- headless browser e2e.
490
- - **Coverage hardening.** 🔄 partial as of **v0.6.0**: `aws-adapter`
491
- and `aws-stack` now clear the 90% gate (CI boots DynamoDB Local via
492
- a JRE; the CDK synth no longer re-bundles under coverage), and the
493
- CF packages moved to istanbul to clear the v0.5.0 workerd-incompat.
494
- Follow-ups drive each remaining sub-100% package to 100%.
560
+ - **Phase 14 — Web client.** 🔄 partial as of **v0.7.0**: a vendored
561
+ Kiwi IRC SPA is served at `/app/` and a static landing page at `/`
562
+ by the Cloudflare Worker via an `[assets]` binding, with a Zod-
563
+ validated per-env config matrix and **CSWSH defense** (same-origin
564
+ auto-derive + optional `WEB_ORIGINS` allowlist). The Playwright
565
+ headless-browser e2e is the remaining open ticket.
566
+ See `docs/WebClientGuide.md` for the contributor/operator guide.
567
+ - **Coverage hardening.** 🔄 partial as of **v0.7.0**: `aws-adapter`
568
+ and `aws-stack` clear the 90% gate (v0.6.0); the CF packages run
569
+ under istanbul (v0.6.0). The remaining packages (`cf-adapter`,
570
+ `local-cli`, `load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
571
+ `irc-test-support`, `web`) are **above the gate but below 100%** —
572
+ follow-ups drive each to 100%.
495
573
 
496
574
  ---
497
575
 
@@ -563,13 +641,22 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
563
641
  `irc+tls :6697`, SASL `EXTERNAL`/mTLS, WHOWAS + remaining verbs, D1 account
564
642
  store, Node 24/pnpm 11), v0.5.0 (deferred IRC verbs `KILL` / `REHASH`
565
643
  / `LUSERS` + `STATS` / `TRACE` / `WALLOPS` / `SETNAME`, plus the formal
566
- drop of the S2S and obsolete RFC 2812 verbs), and **v0.6.0** (the IRCv3
644
+ drop of the S2S and obsolete RFC 2812 verbs), v0.6.0 (the IRCv3
567
645
  extension sweep — `account-notify`, `msgid`, `standard-replies`,
568
646
  `MONITOR`, `labeled-response`, `sts`, `draft/typing`, `draft/multiline`,
569
647
  `draft/read-marker`, `draft/pre-away`, plus user mode `S`; the
570
648
  required-`serverName` gate; `tools/load-test`; aws-adapter / aws-stack
571
649
  coverage unblocks; CF packages moved to istanbul; deploys now
572
- manual-only).
650
+ manual-only), and **v0.7.0** (the web client — vendored Kiwi IRC SPA
651
+ at `/app/`, static landing page at `/`, Cloudflare Worker `[assets]`
652
+ binding, per-env Zod-validated config matrix, and CSWSH defense via
653
+ same-origin auto-derive + optional `WEB_ORIGINS`; `/health` becomes
654
+ an explicit route; mutation-killing tests for `irc-core`'s parser /
655
+ batch / numerics).
656
+ - `docs/WebClientGuide.md` — end-to-end contributor/operator doc for the
657
+ web client: build pipeline, per-env config matrix, CSWSH rationale and
658
+ the optional `WEB_ORIGINS` var, local dev, optional Cloudflare Pages
659
+ alternative, and troubleshooting.
573
660
  - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md` —
574
661
  end-to-end guides for the `:6697` TCP+TLS variants (Spectrum/Container on
575
662
  CF, NLB + Lambda streaming on AWS), including mTLS trust-store setup.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-tcp-container",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Container TCP origin for the irc+tls :6697 transport — Spectrum terminates TLS at the edge, this container runs the IRC core over plaintext TCP",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
@@ -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
+ }