serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,177 @@
1
+ /**
2
+ * CF adapter — per-connection inbound frame-rate window at the adapter
3
+ * boundary (`ConnectionDO.webSocketMessage`).
4
+ *
5
+ * The gate fires BEFORE the actor / storage write: a connection that
6
+ * delivers more than `adapter.maxFramesPerWindow` frames inside the
7
+ * `adapter.frameWindowSeconds` sliding window is closed with RFC 6455
8
+ * close code 1008 (Policy Violation) after receiving the canonical
9
+ * `ERROR :Closing link (flooding)` line.
10
+ *
11
+ * The default window (50 frames / 5 s) exercises the under/at/over
12
+ * thresholds; the sliding-window decay uses the `__setFrameWindowForTest`
13
+ * seam to install a tight window so the test does not sleep for real
14
+ * seconds.
15
+ */
16
+
17
+ import { env, runInDurableObject } from 'cloudflare:test';
18
+ import { describe, expect, it } from 'vitest';
19
+
20
+ declare global {
21
+ namespace Cloudflare {
22
+ interface Env {
23
+ CONNECTION_DO: DurableObjectNamespace;
24
+ REGISTRY_DO: DurableObjectNamespace;
25
+ CHANNEL_DO: DurableObjectNamespace;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Poll interval (ms) for the waitFor* helpers. */
31
+ const POLL_MS = 10;
32
+ /** Default timeout (ms) for the waitFor* helpers. */
33
+ const DEFAULT_TIMEOUT_MS = 4000;
34
+
35
+ class FrameWsClient {
36
+ readonly lines: string[] = [];
37
+ readonly ws: WebSocket;
38
+ closedCode: number | undefined;
39
+
40
+ constructor(ws: WebSocket) {
41
+ this.ws = ws;
42
+ ws.addEventListener('message', (ev: MessageEvent) => {
43
+ const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
44
+ for (const line of data.split('\r\n')) {
45
+ if (line.length > 0) this.lines.push(line);
46
+ }
47
+ });
48
+ ws.addEventListener('close', (ev: CloseEvent) => {
49
+ this.closedCode = ev.code;
50
+ });
51
+ }
52
+
53
+ send(line: string): void {
54
+ this.ws.send(`${line}\r\n`);
55
+ }
56
+
57
+ countPongs(): number {
58
+ return this.lines.filter((l) => l.startsWith('PONG')).length;
59
+ }
60
+
61
+ async waitForPongs(n: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ if (this.countPongs() >= n) return;
65
+ await new Promise((r) => setTimeout(r, POLL_MS));
66
+ }
67
+ throw new Error(`waitForPongs(${n}) timed out; lines: ${JSON.stringify(this.lines)}`);
68
+ }
69
+
70
+ async waitForClose(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<number> {
71
+ const deadline = Date.now() + timeoutMs;
72
+ while (Date.now() < deadline) {
73
+ if (this.closedCode !== undefined) return this.closedCode;
74
+ await new Promise((r) => setTimeout(r, POLL_MS));
75
+ }
76
+ throw new Error(`waitForClose timed out; lines: ${JSON.stringify(this.lines)}`);
77
+ }
78
+ }
79
+
80
+ async function openConnection(
81
+ connId: string,
82
+ ): Promise<{ client: FrameWsClient; stub: DurableObjectStub }> {
83
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
84
+ const response = await stub.fetch('https://do/upgrade', { headers: { Upgrade: 'websocket' } });
85
+ const ws = response.webSocket;
86
+ if (ws === undefined || ws === null) {
87
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
88
+ }
89
+ ws.accept();
90
+ const client = await new Promise<FrameWsClient>((resolve) => {
91
+ setTimeout(() => resolve(new FrameWsClient(ws as WebSocket)), 0);
92
+ });
93
+ return { client, stub };
94
+ }
95
+
96
+ /** RPC shape of the `ConnectionDO` test seam used for the decay test. */
97
+ interface FrameWindowSeam {
98
+ __setFrameWindowForTest(cfg: { maxFrames: number; windowMs: number } | undefined): void;
99
+ }
100
+
101
+ describe('ConnectionDO — inbound frame-rate window (default 50 / 5 s)', () => {
102
+ it('admits frames while the window is under the ceiling and at the ceiling (no close)', async () => {
103
+ const { client } = await openConnection('conn-frame-under');
104
+ // 50 frames = exactly the default ceiling; every one is admitted and
105
+ // answered (each PING is one WebSocket frame through the actor).
106
+ for (let i = 1; i <= 50; i++) {
107
+ client.send(`PING :u${i}`);
108
+ }
109
+ await client.waitForPongs(50);
110
+ // Grace window: a close would have landed by now if the 50th frame
111
+ // had tripped the gate.
112
+ await new Promise((r) => setTimeout(r, 150));
113
+ expect(client.closedCode).toBeUndefined();
114
+ client.ws.close();
115
+ });
116
+
117
+ it('closes with RFC 6455 code 1008 after ERROR :Closing link (flooding) on frame 51', async () => {
118
+ const { client } = await openConnection('conn-frame-over');
119
+ for (let i = 1; i <= 50; i++) {
120
+ client.send(`PING :o${i}`);
121
+ }
122
+ await client.waitForPongs(50);
123
+
124
+ // Frame 51 exceeds the window: no actor run (no PONG), the ERROR
125
+ // line is delivered, then the socket is closed with 1008.
126
+ client.send('PING :o51');
127
+ const code = await client.waitForClose();
128
+ expect(code).toBe(1008);
129
+ expect(client.lines).toContain('ERROR :Closing link (flooding)');
130
+ expect(client.lines.some((l) => l.startsWith('PONG :o51'))).toBe(false);
131
+ });
132
+
133
+ it('admits a fresh burst after the window decays (sliding window)', async () => {
134
+ const { client, stub } = await openConnection('conn-frame-decay');
135
+ // Tight window (5 frames / 400 ms) via the test seam so the decay
136
+ // wait is fast instead of a real 5-second sleep.
137
+ await runInDurableObject(stub, (instance: unknown) => {
138
+ (instance as FrameWindowSeam).__setFrameWindowForTest({
139
+ maxFrames: 5,
140
+ windowMs: 400,
141
+ });
142
+ });
143
+
144
+ for (let i = 1; i <= 5; i++) {
145
+ client.send(`PING :d1-${i}`);
146
+ }
147
+ await client.waitForPongs(5);
148
+
149
+ // Wait past the window: the first burst decays out entirely.
150
+ await new Promise((r) => setTimeout(r, 500));
151
+ for (let i = 1; i <= 5; i++) {
152
+ client.send(`PING :d2-${i}`);
153
+ }
154
+ await client.waitForPongs(10);
155
+ await new Promise((r) => setTimeout(r, 150));
156
+ expect(client.closedCode).toBeUndefined();
157
+ expect(client.countPongs()).toBe(10);
158
+ client.ws.close();
159
+ });
160
+
161
+ it('still floods out mid-burst once the tight window is full (seam sanity)', async () => {
162
+ const { client, stub } = await openConnection('conn-frame-tight');
163
+ await runInDurableObject(stub, (instance: unknown) => {
164
+ (instance as FrameWindowSeam).__setFrameWindowForTest({
165
+ maxFrames: 2,
166
+ windowMs: 60_000,
167
+ });
168
+ });
169
+ client.send('PING :t1');
170
+ client.send('PING :t2');
171
+ client.send('PING :t3');
172
+ const code = await client.waitForClose();
173
+ expect(code).toBe(1008);
174
+ expect(client.lines).toContain('ERROR :Closing link (flooding)');
175
+ expect(client.countPongs()).toBe(2);
176
+ });
177
+ });
@@ -6,60 +6,14 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
9
- import { parseSaslAccountsEnv, wsFrameModeFromTags } from '../src/connection-do';
10
-
11
- describe('parseSaslAccountsEnv', () => {
12
- it('returns an empty array for undefined input', () => {
13
- expect(parseSaslAccountsEnv(undefined)).toEqual([]);
14
- });
15
-
16
- it('returns an empty array for an empty string', () => {
17
- expect(parseSaslAccountsEnv('')).toEqual([]);
18
- });
19
-
20
- it('parses a single well-formed entry', () => {
21
- expect(parseSaslAccountsEnv('alice:secret')).toEqual([
22
- { username: 'alice', password: 'secret' },
23
- ]);
24
- });
25
-
26
- it('parses multiple newline-delimited entries', () => {
27
- expect(parseSaslAccountsEnv('alice:secret\nbob:pw')).toEqual([
28
- { username: 'alice', password: 'secret' },
29
- { username: 'bob', password: 'pw' },
30
- ]);
31
- });
32
-
33
- it('skips blank lines', () => {
34
- expect(parseSaslAccountsEnv('alice:secret\n\nbob:pw\n \n')).toEqual([
35
- { username: 'alice', password: 'secret' },
36
- { username: 'bob', password: 'pw' },
37
- ]);
38
- });
39
-
40
- it('skips lines with no colon separator', () => {
41
- expect(parseSaslAccountsEnv('alice:secret\nnocolonhere\nbob:pw')).toEqual([
42
- { username: 'alice', password: 'secret' },
43
- { username: 'bob', password: 'pw' },
44
- ]);
45
- });
46
-
47
- it('skips lines where the colon is at position 0 (empty username)', () => {
48
- expect(parseSaslAccountsEnv(':secret\nalice:valid')).toEqual([
49
- { username: 'alice', password: 'valid' },
50
- ]);
51
- });
52
-
53
- it('skips lines with an empty password after the colon', () => {
54
- expect(parseSaslAccountsEnv('alice:\nbob:valid')).toEqual([
55
- { username: 'bob', password: 'valid' },
56
- ]);
57
- });
58
-
59
- it('returns an empty array when every line is malformed', () => {
60
- expect(parseSaslAccountsEnv('\n \n:no-user\nuser:')).toEqual([]);
61
- });
62
- });
9
+ import {
10
+ NO_OP_DISCONNECT,
11
+ NO_OP_SEND,
12
+ buildOptionalActorFields,
13
+ chooseMotdProvider,
14
+ extractMtlsSubject,
15
+ wsFrameModeFromTags,
16
+ } from '../src/connection-do';
63
17
 
64
18
  describe('wsFrameModeFromTags', () => {
65
19
  it("returns 'legacy' when no tags are present", () => {
@@ -219,3 +173,159 @@ describe('PassthroughChannelAccess.refreshChannel', () => {
219
173
  expect(chan.topic?.text).toBe('keep');
220
174
  });
221
175
  });
176
+
177
+ // ============================================================================
178
+ // chooseMotdProvider — default vs. populated MOTD_LINES branch
179
+ // ============================================================================
180
+
181
+ describe('chooseMotdProvider', () => {
182
+ it('returns the default MOTD when the env value is undefined', () => {
183
+ const provider = chooseMotdProvider(undefined);
184
+ expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
185
+ });
186
+
187
+ it('returns the default MOTD when the env value is an empty string', () => {
188
+ const provider = chooseMotdProvider('');
189
+ expect(provider.lines()).toEqual(['Welcome to the ServerlessIRCd Cloudflare adapter.']);
190
+ });
191
+
192
+ it('splits a populated MOTD_LINES on newlines', () => {
193
+ const provider = chooseMotdProvider('Line one\nLine two\nLine three');
194
+ expect(provider.lines()).toEqual(['Line one', 'Line two', 'Line three']);
195
+ });
196
+ });
197
+
198
+ // ============================================================================
199
+ // buildOptionalActorFields — conditional spread branches
200
+ // ============================================================================
201
+
202
+ describe('buildOptionalActorFields', () => {
203
+ it('returns an empty object when every optional is undefined', () => {
204
+ expect(buildOptionalActorFields(undefined, undefined)).toEqual({});
205
+ });
206
+
207
+ it('includes `services` when defined', () => {
208
+ const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[0];
209
+ expect(buildOptionalActorFields(fake, undefined).services).toBe(fake);
210
+ });
211
+
212
+ it('includes `mtlsIdentity` when defined', () => {
213
+ const fake = {} as unknown as Parameters<typeof buildOptionalActorFields>[1];
214
+ expect(buildOptionalActorFields(undefined, fake).mtlsIdentity).toBe(fake);
215
+ });
216
+ });
217
+
218
+ // ============================================================================
219
+ // NO_OP_SEND / NO_OP_DISCONNECT — invoked once to cover the function bodies
220
+ // (the no-socket teardown path constructs these handlers but the actor never
221
+ // invokes send/disconnect during QUIT processing — there is no peer to
222
+ // deliver to and the socket is already gone).
223
+ // ============================================================================
224
+
225
+ describe('NO_OP_SEND / NO_OP_DISCONNECT', () => {
226
+ it('NO_OP_SEND runs without throwing and returns undefined', () => {
227
+ expect(NO_OP_SEND([{ text: 'ignored' }])).toBeUndefined();
228
+ });
229
+
230
+ it('NO_OP_DISCONNECT runs without throwing and returns undefined', () => {
231
+ expect(NO_OP_DISCONNECT('any reason')).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ // ============================================================================
236
+ // extractMtlsSubject — request.cf mTLS identity capture
237
+ // (cf.tlsClientAuthCertSubject + cf.tlsClientAuth.certFingerprintSHA256)
238
+ // ============================================================================
239
+
240
+ describe('extractMtlsSubject', () => {
241
+ it('returns the subject identity when cf.tlsClientAuthCertSubject is a string', () => {
242
+ // The CF edge injects request.cf on every proxied request. We can't
243
+ // mutate Request.cf in workerd tests (it's read-only), so the
244
+ // upgrade-time capture is exercised via this pure seam.
245
+ const fake = { cf: { tlsClientAuthCertSubject: 'CN=alice' } } as unknown as Request;
246
+ expect(extractMtlsSubject(fake)).toEqual({
247
+ subject: 'CN=alice',
248
+ canonicalDn: 'cn=alice',
249
+ });
250
+ });
251
+
252
+ it('includes the fingerprint from cf.tlsClientAuth.certFingerprintSHA256 when present', () => {
253
+ const fake = {
254
+ cf: {
255
+ tlsClientAuthCertSubject: 'CN=alice',
256
+ tlsClientAuth: { certFingerprintSHA256: 'af363dc85bc942a8' },
257
+ },
258
+ } as unknown as Request;
259
+ expect(extractMtlsSubject(fake)).toEqual({
260
+ subject: 'CN=alice',
261
+ canonicalDn: 'cn=alice',
262
+ fingerprint: 'af363dc85bc942a8',
263
+ });
264
+ });
265
+
266
+ it('omits the fingerprint when certFingerprintSHA256 is the documented empty default', () => {
267
+ // Cloudflare documents certFingerprintSHA256 as `""` when the
268
+ // connection is not mTLS — treat that as "no fingerprint".
269
+ const fake = {
270
+ cf: {
271
+ tlsClientAuthCertSubject: 'CN=alice',
272
+ tlsClientAuth: { certFingerprintSHA256: '' },
273
+ },
274
+ } as unknown as Request;
275
+ expect(extractMtlsSubject(fake)?.fingerprint).toBeUndefined();
276
+ });
277
+
278
+ it('ignores a non-string certFingerprintSHA256', () => {
279
+ const fake = {
280
+ cf: {
281
+ tlsClientAuthCertSubject: 'CN=alice',
282
+ tlsClientAuth: { certFingerprintSHA256: 42 },
283
+ },
284
+ } as unknown as Request;
285
+ expect(extractMtlsSubject(fake)?.fingerprint).toBeUndefined();
286
+ });
287
+
288
+ it('falls back to cf.tlsClientAuth.certSubjectDN when the legacy subject field is absent', () => {
289
+ const fake = {
290
+ cf: {
291
+ tlsClientAuth: { certSubjectDN: 'CN=alice,O=Example', certFingerprintSHA256: 'aa11' },
292
+ },
293
+ } as unknown as Request;
294
+ const identity = extractMtlsSubject(fake);
295
+ expect(identity?.subject).toBe('CN=alice,O=Example');
296
+ expect(identity?.canonicalDn).toBe('cn=alice,o=Example');
297
+ expect(identity?.fingerprint).toBe('aa11');
298
+ });
299
+
300
+ it('prefers the legacy subject field over tlsClientAuth.certSubjectDN', () => {
301
+ const fake = {
302
+ cf: {
303
+ tlsClientAuthCertSubject: 'CN=legacy',
304
+ tlsClientAuth: { certSubjectDN: 'CN=newer' },
305
+ },
306
+ } as unknown as Request;
307
+ expect(extractMtlsSubject(fake)?.subject).toBe('CN=legacy');
308
+ });
309
+
310
+ it('returns undefined when cf is absent', () => {
311
+ const fake = { cf: undefined } as unknown as Request;
312
+ expect(extractMtlsSubject(fake)).toBeUndefined();
313
+ });
314
+
315
+ it('returns undefined when cf is present but carries no cert subject', () => {
316
+ const fake = {
317
+ cf: { country: 'US', tlsClientAuth: { certVerified: 'NONE' } },
318
+ } as unknown as Request;
319
+ expect(extractMtlsSubject(fake)).toBeUndefined();
320
+ });
321
+
322
+ it('returns undefined when tlsClientAuthCertSubject is not a string', () => {
323
+ const fake = { cf: { tlsClientAuthCertSubject: 42 } } as unknown as Request;
324
+ expect(extractMtlsSubject(fake)).toBeUndefined();
325
+ });
326
+
327
+ it('returns undefined when tlsClientAuth is not an object', () => {
328
+ const fake = { cf: { tlsClientAuth: 'nope' } } as unknown as Request;
329
+ expect(extractMtlsSubject(fake)).toBeUndefined();
330
+ });
331
+ });
@@ -1,10 +1,9 @@
1
1
  /**
2
- * End-to-end SASL PLAIN through ConnectionDO with D1-backed accounts.
2
+ * End-to-end SASL PLAIN through ConnectionDO with D1-backed services.
3
3
  *
4
- * Verifies the full precedence matrix:
5
- * - D1 has rows → table is authoritative (900/903 for seeded account,
6
- * 904 for the env-seed account which the table shadows).
7
- * - D1 empty → the `SASL_ACCOUNTS` env-var seed still works (fallback).
4
+ * Verifies the services-store credential path: an account seeded into the
5
+ * `nickserv_accounts` table (via `ServicesStore.registerNick`) verifies
6
+ * end-to-end through `AUTHENTICATE PLAIN`.
8
7
  *
9
8
  * Each test file gets its own isolated D1 database from miniflare, so the
10
9
  * table state set up here does not leak into `connection-do.test.ts`.
@@ -12,7 +11,7 @@
12
11
 
13
12
  import { env } from 'cloudflare:test';
14
13
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
- import { CREATE_ACCOUNTS_TABLE_SQL, putAccountCredential } from '../src/d1-account-store';
14
+ import { CREATE_SERVICES_TABLES_SQL } from '../src/d1-services-store';
16
15
 
17
16
  declare global {
18
17
  namespace Cloudflare {
@@ -107,16 +106,56 @@ async function authenticatePlain(
107
106
  client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
108
107
  }
109
108
 
110
- describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
109
+ /**
110
+ * Seeds a NickServ registration directly into the D1 services table by
111
+ * driving a first connection through `PRIVMSG NickServ :REGISTER`. Returns
112
+ * once the registration NOTICE is observed.
113
+ */
114
+ async function seedViaNickservRegister(
115
+ connId: string,
116
+ nick: string,
117
+ password: string,
118
+ email: string,
119
+ ): Promise<IrcWsClient> {
120
+ const client = await connect(connId);
121
+ client.send(`NICK ${nick}\r\n`);
122
+ client.send(`USER ${nick} 0 * :${nick}\r\n`);
123
+ await client.waitForMessage((l) => l.includes(' 001 '));
124
+ client.send(`PRIVMSG NickServ :REGISTER ${password} ${email}\r\n`);
125
+ await client.waitForMessage((l) => l.includes('NickServ') && l.includes('registered'));
126
+ return client;
127
+ }
128
+
129
+ describe('ConnectionDO — SASL PLAIN with D1-backed services', () => {
111
130
  beforeEach(async () => {
112
- await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
131
+ for (const sql of CREATE_SERVICES_TABLES_SQL) {
132
+ await env.ACCOUNTS_DB.exec(sql);
133
+ }
113
134
  });
114
135
  afterEach(async () => {
115
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
136
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
137
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_channels');
138
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_access');
139
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS chanserv_levels');
140
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_vhosts');
141
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS hostserv_pending_vhosts');
142
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS memoserv_memos');
143
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS read_markers');
144
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_akills');
145
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS operserv_jupes');
116
146
  });
117
147
 
118
- it('completes SASL PLAIN with 900/903 for a D1-seeded account', async () => {
119
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
148
+ it('completes SASL PLAIN with 900/903 for a NickServ-registered account', async () => {
149
+ // Seed alice via NickServ REGISTER on a first connection.
150
+ const seeder = await seedViaNickservRegister(
151
+ 'conn-d1-sasl-seed',
152
+ 'd1-alice',
153
+ 'd1-s3cret',
154
+ 'd1@example.com',
155
+ );
156
+ await seeder.close();
157
+
158
+ // A second connection speaking SASL PLAIN against the registered nick.
120
159
  const client = await connect('conn-d1-sasl-1');
121
160
  await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
122
161
  await client.waitForMessage((l) => l.includes(' 900 '));
@@ -125,42 +164,27 @@ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
125
164
  await client.close();
126
165
  });
127
166
 
128
- it('rejects SASL PLAIN with 904 for a wrong password (D1 account)', async () => {
129
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
167
+ it('rejects SASL PLAIN with 904 for a wrong password', async () => {
168
+ const seeder = await seedViaNickservRegister(
169
+ 'conn-d1-sasl-seed2',
170
+ 'd1-bob',
171
+ 'd1-s3cret',
172
+ 'd1@example.com',
173
+ );
174
+ await seeder.close();
175
+
130
176
  const client = await connect('conn-d1-sasl-2');
131
- await authenticatePlain(client, 'd1-alice', 'wrong');
177
+ await authenticatePlain(client, 'd1-bob', 'wrong');
132
178
  const fail = await client.waitForMessage((l) => l.includes(' 904 '));
133
179
  expect(fail).toContain(' 904 ');
134
180
  await client.close();
135
181
  });
136
182
 
137
- it('table shadows the env-seed account (table wins)', async () => {
138
- // SASL_ACCOUNTS env seeds "cf-sasl:s3cret" but D1 has no such row.
139
- await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
183
+ it('rejects SASL PLAIN with 904 for an unknown nick', async () => {
140
184
  const client = await connect('conn-d1-sasl-3');
141
- // env-seed account does NOT verify (table is authoritative).
142
- await authenticatePlain(client, 'cf-sasl', 's3cret');
185
+ await authenticatePlain(client, 'nobody', 'whatever');
143
186
  const fail = await client.waitForMessage((l) => l.includes(' 904 '));
144
187
  expect(fail).toContain(' 904 ');
145
188
  await client.close();
146
189
  });
147
190
  });
148
-
149
- describe('ConnectionDO — SASL PLAIN in-memory fallback (D1 empty)', () => {
150
- // The table exists but has zero rows → resolver falls back to SASL_ACCOUNTS.
151
- beforeEach(async () => {
152
- await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
153
- });
154
- afterEach(async () => {
155
- await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
156
- });
157
-
158
- it('falls back to SASL_ACCOUNTS seed when D1 is empty', async () => {
159
- const client = await connect('conn-d1-sasl-fallback');
160
- await authenticatePlain(client, 'cf-sasl', 's3cret');
161
- await client.waitForMessage((l) => l.includes(' 900 '));
162
- const success = await client.waitForMessage((l) => l.includes(' 903 '));
163
- expect(success).toContain(' 903 ');
164
- await client.close();
165
- });
166
- });
@@ -223,12 +223,15 @@ describe('ConnectionDO — IRCv3 WebSocket spec contract (end-to-end)', () => {
223
223
  client.messages.length = 0;
224
224
 
225
225
  // One WS message that LOOKS like two PING lines. In spec mode the frame
226
- // is a single IRC message, so exactly ONE PONG is emitted.
226
+ // is a single IRC message, so it is never split into two commands: the
227
+ // embedded CR makes the line unparseable (bare-CR rejection) and the
228
+ // actor answers with exactly ONE 421 — never a PONG, let alone two.
227
229
  client.sendRaw('PING one\r\nPING two');
228
- await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 1);
229
- // Give a grace window for a (incorrect) second PONG to arrive.
230
+ await client.waitForLine((l) => l.includes(' 421 '));
231
+ // Give a grace window for a (incorrect) PONG or second reply to arrive.
230
232
  await new Promise((r) => setTimeout(r, 100));
231
- expect(countCommand(client.messages, 'PONG')).toBe(1);
233
+ expect(countCommand(client.messages, 'PONG')).toBe(0);
234
+ expect(client.messages.filter((m) => m.includes(' 421 '))).toHaveLength(1);
232
235
  client.ws.close();
233
236
  });
234
237