serverless-ircd 0.3.0 → 0.5.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 (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,184 @@
1
+ /**
2
+ * CF adapter — IRCv3 WebSocket subprotocol negotiation + per-message framing.
3
+ *
4
+ * Drives the real `ConnectionDO` through `cloudflare:test` (miniflare) so
5
+ * the `Sec-WebSocket-Protocol` round-trip, hibernation tags, and frame
6
+ * framing behave exactly as in production. Each test gets its own
7
+ * isolated storage namespace.
8
+ */
9
+
10
+ import { env } from 'cloudflare:test';
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ declare global {
14
+ namespace Cloudflare {
15
+ interface Env {
16
+ CONNECTION_DO: DurableObjectNamespace;
17
+ REGISTRY_DO: DurableObjectNamespace;
18
+ CHANNEL_DO: DurableObjectNamespace;
19
+ }
20
+ }
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers — WebSocket client that records raw WS messages (not split lines).
25
+ // ---------------------------------------------------------------------------
26
+
27
+ class SpecWsClient {
28
+ /** Raw WebSocket messages exactly as delivered (one entry per WS message). */
29
+ readonly messages: string[] = [];
30
+ /** Lines split out of `messages` (tolerant of legacy `\r\n` framing). */
31
+ readonly lines: string[] = [];
32
+ readonly ws: WebSocket;
33
+ closedCode: number | undefined;
34
+
35
+ constructor(ws: WebSocket) {
36
+ this.ws = ws;
37
+ ws.addEventListener('message', (ev: MessageEvent) => {
38
+ const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
39
+ this.messages.push(data);
40
+ for (const line of data.split('\r\n')) {
41
+ if (line.length > 0) this.lines.push(line);
42
+ }
43
+ });
44
+ ws.addEventListener('close', (ev: CloseEvent) => {
45
+ this.closedCode = ev.code;
46
+ });
47
+ }
48
+
49
+ send(line: string): void {
50
+ this.ws.send(`${line}\r\n`);
51
+ }
52
+
53
+ waitForLine(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
54
+ return new Promise<string>((resolve, reject) => {
55
+ const start = Date.now();
56
+ const tick = (): void => {
57
+ const hit = this.lines.find(predicate);
58
+ if (hit !== undefined) {
59
+ resolve(hit);
60
+ return;
61
+ }
62
+ if (Date.now() - start > timeoutMs) {
63
+ reject(new Error(`timeout waiting for line; got: ${JSON.stringify(this.lines)}`));
64
+ return;
65
+ }
66
+ setTimeout(tick, 20);
67
+ };
68
+ tick();
69
+ });
70
+ }
71
+
72
+ waitForClose(timeoutMs = 2000): Promise<number | undefined> {
73
+ return new Promise<number | undefined>((resolve, reject) => {
74
+ if (this.closedCode !== undefined) {
75
+ resolve(this.closedCode);
76
+ return;
77
+ }
78
+ const start = Date.now();
79
+ const tick = (): void => {
80
+ if (this.closedCode !== undefined) {
81
+ resolve(this.closedCode);
82
+ return;
83
+ }
84
+ if (Date.now() - start > timeoutMs) {
85
+ reject(
86
+ new Error(`timeout waiting for close; messages: ${JSON.stringify(this.messages)}`),
87
+ );
88
+ return;
89
+ }
90
+ setTimeout(tick, 20);
91
+ };
92
+ tick();
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Opens a WebSocket to a fresh ConnectionDO, optionally offering a
99
+ * `Sec-WebSocket-Protocol` list. Returns the upgrade Response so the
100
+ * caller can assert on the echoed header, plus a ready-to-use client.
101
+ */
102
+ async function openUpgrade(
103
+ connId: string,
104
+ protocols?: string,
105
+ ): Promise<{ response: Response; client: SpecWsClient }> {
106
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
107
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
108
+ if (protocols !== undefined) {
109
+ headers['Sec-WebSocket-Protocol'] = protocols;
110
+ }
111
+ const response = await stub.fetch('https://do/upgrade', { headers });
112
+ const ws = response.webSocket;
113
+ if (ws === undefined || ws === null) {
114
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
115
+ }
116
+ ws.accept();
117
+ const client = await new Promise<SpecWsClient>((resolve) => {
118
+ setTimeout(() => resolve(new SpecWsClient(ws as WebSocket)), 0);
119
+ });
120
+ return { response, client };
121
+ }
122
+
123
+ async function register(client: SpecWsClient, nick: string): Promise<void> {
124
+ client.send(`NICK ${nick}`);
125
+ client.send(`USER ${nick} 0 * :${nick}`);
126
+ await client.waitForLine((l) => l.includes(' 001 '));
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Acceptance criteria
131
+ // ---------------------------------------------------------------------------
132
+
133
+ describe('ConnectionDO — IRCv3 WebSocket subprotocol negotiation', () => {
134
+ it('echoes text.ircv3.net back in the 101 response Sec-WebSocket-Protocol header', async () => {
135
+ const { response, client } = await openUpgrade('conn-subproto-text', 'text.ircv3.net');
136
+ expect(response.status).toBe(101);
137
+ expect(response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
138
+ client.ws.close();
139
+ });
140
+
141
+ it('selects the first-listed supported protocol when both are offered', async () => {
142
+ const a = await openUpgrade('conn-subproto-order-a', 'text.ircv3.net, binary.ircv3.net');
143
+ expect(a.response.headers.get('Sec-WebSocket-Protocol')).toBe('text.ircv3.net');
144
+ a.client.ws.close();
145
+
146
+ const b = await openUpgrade('conn-subproto-order-b', 'binary.ircv3.net, text.ircv3.net');
147
+ expect(b.response.headers.get('Sec-WebSocket-Protocol')).toBe('binary.ircv3.net');
148
+ b.client.ws.close();
149
+ });
150
+
151
+ it('omits Sec-WebSocket-Protocol when the client offers no supported protocol', async () => {
152
+ const { response, client } = await openUpgrade('conn-subproto-unsupported', 'bogus.proto');
153
+ expect(response.status).toBe(101);
154
+ expect(response.headers.get('Sec-WebSocket-Protocol')).toBeNull();
155
+ client.ws.close();
156
+ });
157
+
158
+ it('connects without a subprotocol offer (legacy mode) and registers', async () => {
159
+ const { client } = await openUpgrade('conn-subproto-legacy');
160
+ await register(client, 'legacy-alice');
161
+ expect(client.lines.some((l) => l.includes(' 001 '))).toBe(true);
162
+ client.ws.close();
163
+ });
164
+
165
+ it('delivers registration replies as N separate WS messages with no trailing CRLF (spec-text)', async () => {
166
+ const { client } = await openUpgrade('conn-subproto-permsg', 'text.ircv3.net');
167
+ client.send('NICK spec-bob');
168
+ client.send('USER spec-bob 0 * :spec-bob');
169
+ await client.waitForLine((l) => l.includes(' 001 '));
170
+
171
+ expect(client.messages.length).toBeGreaterThan(1);
172
+ for (const msg of client.messages) {
173
+ expect(msg.endsWith('\r\n')).toBe(false);
174
+ }
175
+ client.ws.close();
176
+ });
177
+
178
+ it('closes with code 1003 when a binary frame arrives on a spec-text connection', async () => {
179
+ const { client } = await openUpgrade('conn-subproto-bin-on-text', 'text.ircv3.net');
180
+ client.ws.send(new Uint8Array([0x4e, 0x49, 0x43, 0x4b]).buffer);
181
+ const code = await client.waitForClose();
182
+ expect(code).toBe(1003);
183
+ });
184
+ });
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 033 — `ConnectionDO` (hibernatable WS), state migration, alarms.
2
+ * `ConnectionDO` (hibernatable WS), state migration, alarms.
3
3
  *
4
- * TDD outline (from tickets.md):
4
+ * TDD outline:
5
5
  * "Red via `@cloudflare/vitest-pool-workers` on 'hibernate then wake
6
6
  * preserves nick'; green; then alarm-driven PING."
7
7
  *
@@ -406,6 +406,31 @@ describe('ConnectionDO — SASL PLAIN end-to-end', () => {
406
406
  });
407
407
  });
408
408
 
409
+ describe('ConnectionDO — OPER command (env-thread operCreds)', () => {
410
+ it('grants oper with 381 RPL_YOUREOPER for matching OPER_USER/OPER_PASSWORD', async () => {
411
+ const client = await connect('conn-oper-ok');
412
+ await register(client, 'alice');
413
+
414
+ client.send('OPER cf-oper oper-secret\r\n');
415
+ const reply = await client.waitForMessage((l) => / 381 | 491 | 464 | 461 /.test(l));
416
+
417
+ expect(reply).toContain(' 381 ');
418
+ expect(reply).toContain('You are now an IRC operator');
419
+ client.ws.close();
420
+ });
421
+
422
+ it('rejects oper with 464 ERR_PASSWDMISMATCH for a wrong password', async () => {
423
+ const client = await connect('conn-oper-bad');
424
+ await register(client, 'bob');
425
+
426
+ client.send('OPER cf-oper wrong\r\n');
427
+ const reply = await client.waitForMessage((l) => / 381 | 491 | 464 | 461 /.test(l));
428
+
429
+ expect(reply).toContain(' 464 ');
430
+ client.ws.close();
431
+ });
432
+ });
433
+
409
434
  // ---------------------------------------------------------------------------
410
435
  // End of file
411
436
  // ---------------------------------------------------------------------------
@@ -0,0 +1,226 @@
1
+ /**
2
+ * D1-backed SASL account store + precedence resolver.
3
+ *
4
+ * Exercises the real D1 round-trip via miniflare: `putAccountCredential`
5
+ * writes a hashed row, `loadD1AccountStore` reads it back, and
6
+ * `resolveAccountStore` applies the table-then-config precedence.
7
+ */
8
+
9
+ import { env } from 'cloudflare:test';
10
+ import { hashAccountCredential } from '@serverless-ircd/irc-core';
11
+ import { beforeEach, describe, expect, it } from 'vitest';
12
+ import {
13
+ buildAccountPutStatement,
14
+ loadD1AccountStore,
15
+ putAccountCredential,
16
+ resolveAccountStore,
17
+ } from '../src/d1-account-store';
18
+
19
+ declare global {
20
+ namespace Cloudflare {
21
+ interface Env {
22
+ ACCOUNTS_DB: D1Database;
23
+ }
24
+ }
25
+ }
26
+
27
+ const CREATE_TABLE =
28
+ 'CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)';
29
+
30
+ async function resetDb(): Promise<void> {
31
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
32
+ await env.ACCOUNTS_DB.exec(CREATE_TABLE);
33
+ }
34
+
35
+ describe('loadD1AccountStore', () => {
36
+ beforeEach(async () => {
37
+ await resetDb();
38
+ });
39
+
40
+ it('returns undefined when the table has no rows', async () => {
41
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
42
+ expect(store).toBeUndefined();
43
+ });
44
+
45
+ it('verifies an account after a row is written via putAccountCredential', async () => {
46
+ await putAccountCredential(env.ACCOUNTS_DB, 'alice', 's3cret');
47
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
48
+ expect(store).toBeDefined();
49
+ expect(
50
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 's3cret' }),
51
+ ).toEqual({ ok: true, account: 'alice' });
52
+ });
53
+
54
+ it('rejects a wrong password after round-trip', async () => {
55
+ await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'right');
56
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
57
+ expect(store).toBeDefined();
58
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
59
+ false,
60
+ );
61
+ });
62
+
63
+ it('loads multiple distinct accounts', async () => {
64
+ await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'a1');
65
+ await putAccountCredential(env.ACCOUNTS_DB, 'bob', 'b2');
66
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
67
+ expect(store?.size).toBe(2);
68
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'a1' }).ok).toBe(
69
+ true,
70
+ );
71
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'bob', password: 'b2' }).ok).toBe(
72
+ true,
73
+ );
74
+ });
75
+
76
+ it('overwrites an existing account on re-seed', async () => {
77
+ await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'old');
78
+ await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'new');
79
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
80
+ expect(store?.size).toBe(1);
81
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'old' }).ok).toBe(
82
+ false,
83
+ );
84
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'new' }).ok).toBe(
85
+ true,
86
+ );
87
+ });
88
+
89
+ it('skips rows missing required columns', async () => {
90
+ // Use a permissive table (no NOT NULL) to simulate a row with a missing
91
+ // salt — the production schema's NOT NULL prevents this, but the loader
92
+ // stays defensive against schema drift / manual edits.
93
+ await env.ACCOUNTS_DB.exec('DROP TABLE accounts');
94
+ await env.ACCOUNTS_DB.exec(
95
+ 'CREATE TABLE accounts (account TEXT PRIMARY KEY, algorithm TEXT, salt TEXT, hash TEXT)',
96
+ );
97
+ await env.ACCOUNTS_DB.prepare(
98
+ "INSERT INTO accounts (account, algorithm, hash) VALUES ('bad', 'scrypt', 'bbbb')",
99
+ ).run();
100
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
101
+ expect(store).toBeUndefined();
102
+ });
103
+ });
104
+
105
+ describe('putAccountCredential', () => {
106
+ beforeEach(async () => {
107
+ await resetDb();
108
+ });
109
+
110
+ it('writes a hashed row that round-trips through the store', async () => {
111
+ const entry = await putAccountCredential(env.ACCOUNTS_DB, 'carol', 'p@ss');
112
+ expect(entry.account).toBe('carol');
113
+ expect(entry.algorithm).toBe('scrypt');
114
+
115
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
116
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
117
+ ok: true,
118
+ account: 'carol',
119
+ });
120
+ });
121
+
122
+ it('never writes the plaintext password to the row', async () => {
123
+ const entry = await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'hunter2-secret');
124
+ expect(entry.account).not.toContain('hunter2');
125
+ expect(entry.salt).not.toContain('hunter2');
126
+ expect(entry.hash).not.toContain('hunter2');
127
+ });
128
+
129
+ it('matches a manually-hashed entry written via raw D1 insert', async () => {
130
+ const hashed = hashAccountCredential('dave', 'pw');
131
+ await env.ACCOUNTS_DB.prepare(
132
+ 'INSERT INTO accounts (account, algorithm, salt, hash) VALUES (?, ?, ?, ?)',
133
+ )
134
+ .bind(hashed.account, hashed.algorithm, hashed.salt, hashed.hash)
135
+ .run();
136
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
137
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'dave', password: 'pw' }).ok).toBe(
138
+ true,
139
+ );
140
+ });
141
+ });
142
+
143
+ describe('buildAccountPutStatement (seed SQL builder)', () => {
144
+ it('produces an INSERT OR REPLACE statement with scrypt-hashed values', () => {
145
+ const { entry, sql } = buildAccountPutStatement('alice', 's3cret');
146
+ expect(entry.account).toBe('alice');
147
+ expect(entry.algorithm).toBe('scrypt');
148
+ expect(sql).toContain('INSERT OR REPLACE INTO accounts');
149
+ // Plaintext must never appear in the SQL.
150
+ expect(sql).not.toContain('s3cret');
151
+ });
152
+
153
+ it('the generated SQL round-trips through loadD1AccountStore', async () => {
154
+ await resetDb();
155
+ const { sql } = buildAccountPutStatement('carol', 'p@ss');
156
+ await env.ACCOUNTS_DB.exec(sql);
157
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
158
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'carol', password: 'p@ss' })).toEqual({
159
+ ok: true,
160
+ account: 'carol',
161
+ });
162
+ });
163
+
164
+ it('escapes single quotes in the username to avoid breaking the SQL', async () => {
165
+ await resetDb();
166
+ const { sql } = buildAccountPutStatement("o'brien", 'pw');
167
+ await env.ACCOUNTS_DB.exec(sql);
168
+ const store = await loadD1AccountStore(env.ACCOUNTS_DB);
169
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: "o'brien", password: 'pw' }).ok).toBe(
170
+ true,
171
+ );
172
+ });
173
+
174
+ it('escapes single quotes in the base64 hash output', () => {
175
+ // The hash is base64 so it won't contain quotes, but the escaper is
176
+ // still exercised — verify the SQL is well-formed.
177
+ const { sql } = buildAccountPutStatement('alice', 'secret');
178
+ // A valid INSERT with four quoted values.
179
+ expect(sql.match(/'/g)?.length).toBe(8);
180
+ });
181
+ });
182
+
183
+ describe('resolveAccountStore precedence', () => {
184
+ beforeEach(async () => {
185
+ await resetDb();
186
+ });
187
+
188
+ it('returns the D1 store when the table has rows (table wins)', async () => {
189
+ await putAccountCredential(env.ACCOUNTS_DB, 'alice', 'table-secret');
190
+ const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
191
+ expect(store).toBeDefined();
192
+ expect(
193
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'table-secret' }).ok,
194
+ ).toBe(true);
195
+ // Config account does NOT verify (table is authoritative).
196
+ expect(
197
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
198
+ ).toBe(false);
199
+ });
200
+
201
+ it('falls back to the SASL_ACCOUNTS seed when the table is empty', async () => {
202
+ const store = await resolveAccountStore(env.ACCOUNTS_DB, 'envuser:env-secret');
203
+ expect(store).toBeDefined();
204
+ expect(
205
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
206
+ ).toBe(true);
207
+ });
208
+
209
+ it('returns undefined when the table is empty and no seed is set', async () => {
210
+ const store = await resolveAccountStore(env.ACCOUNTS_DB, undefined);
211
+ expect(store).toBeUndefined();
212
+ });
213
+
214
+ it('returns undefined when the table is empty and the seed is blank', async () => {
215
+ const store = await resolveAccountStore(env.ACCOUNTS_DB, '');
216
+ expect(store).toBeUndefined();
217
+ });
218
+
219
+ it('falls back to the seed when the D1 binding is undefined', async () => {
220
+ const store = await resolveAccountStore(undefined, 'envuser:env-secret');
221
+ expect(store).toBeDefined();
222
+ expect(
223
+ store?.verify('PLAIN', { kind: 'PLAIN', username: 'envuser', password: 'env-secret' }).ok,
224
+ ).toBe(true);
225
+ });
226
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Ambient declaration so `?raw` imports (Vite's built-in transform that
3
+ * inlines a file's text at bundle time) typecheck under the strict
4
+ * `tsconfig.test.json`. Used by source-level regression guards that
5
+ * assert dead code stays removed.
6
+ */
7
+ declare module '*?raw' {
8
+ const content: string;
9
+ // eslint-disable-next-line import/no-default-export
10
+ export default content;
11
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 034 — `RegistryDO` with sharding plan.
2
+ * `RegistryDO` with sharding plan.
3
3
  *
4
- * TDD outline (from tickets.md):
4
+ * TDD outline:
5
5
  * "Red on concurrent reserve from two stub ConnectionDOs; green."
6
6
  *
7
7
  * Tests run inside real `workerd` via miniflare so DO storage
@@ -12,7 +12,7 @@
12
12
  * The binding `REGISTRY_DO_REAL` (declared in `wrangler.test.toml`)
13
13
  * points at the real {@link RegistryDO} class. Tests address a
14
14
  * specific shard by name (the same `registryKeyForNick` result the
15
- * CfRuntime will compute in 036), so a single DO instance
15
+ * CfRuntime will compute), so a single DO instance
16
16
  * stands in for one shard of the production fleet.
17
17
  */
18
18
 
@@ -34,7 +34,7 @@ declare global {
34
34
 
35
35
  /**
36
36
  * Returns a stub for the registry shard that owns `nick` under the
37
- * default shard count. Mirrors what CfRuntime will do in 036.
37
+ * default shard count. Mirrors what CfRuntime will do.
38
38
  */
39
39
  function shardForNick(nick: string, shards = DEFAULT_REGISTRY_SHARDS): DurableObjectStub {
40
40
  const key = registryKeyForNick(nick, shards);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 034 — Sharding function for `RegistryDO`.
2
+ * Sharding function for `RegistryDO`.
3
3
  *
4
4
  * PLAN §6.1 / §4 — the registry is sharded by `hash(nick) % N` so no
5
5
  * single Durable Object becomes a hot spot for nick registrations. Each
@@ -0,0 +1,120 @@
1
+ import { type ConnId, type ConnectionState, createConnection } from '@serverless-ircd/irc-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { CfStats } from '../src/stats.js';
4
+
5
+ /**
6
+ * Minimal stub satisfying the structural interface CfStats needs (just
7
+ * `listChannels` + `getChannelConnections`). Keeps the unit test free of
8
+ * the workerd/miniflare harness the full CfRuntime integration suite uses.
9
+ */
10
+ interface StatsRuntimeShape {
11
+ listChannels(): Promise<{ nameLower: string }[]>;
12
+ getChannelConnections(name: string): Promise<ReadonlyMap<ConnId, ConnectionState>>;
13
+ }
14
+
15
+ class StubRuntime implements StatsRuntimeShape {
16
+ constructor(
17
+ private readonly channels: ReadonlyMap<string, ReadonlyMap<ConnId, ConnectionState>>,
18
+ ) {}
19
+ async listChannels(): Promise<{ nameLower: string }[]> {
20
+ return Array.from(this.channels.keys(), (nameLower) => ({ nameLower }));
21
+ }
22
+ async getChannelConnections(name: string): Promise<ReadonlyMap<ConnId, ConnectionState>> {
23
+ return this.channels.get(name) ?? new Map();
24
+ }
25
+ }
26
+
27
+ function makeConn(
28
+ id: ConnId,
29
+ opts: { oper?: boolean; invisible?: boolean; registered?: boolean } = {},
30
+ ): ConnectionState {
31
+ const s = createConnection({ id, connectedSince: 0 });
32
+ s.nick = id;
33
+ s.registration = opts.registered === false ? 'pre-registration' : 'registered';
34
+ if (opts.oper !== undefined) s.userModes.oper = opts.oper;
35
+ if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
36
+ return s;
37
+ }
38
+
39
+ describe('CfStats', () => {
40
+ it('returns zero counts for an empty deployment', async () => {
41
+ const rt = new StubRuntime(new Map());
42
+ const stats = new CfStats(rt, 1_000);
43
+ const snap = await stats.getStats();
44
+ expect(snap.users).toBe(0);
45
+ expect(snap.channels).toBe(0);
46
+ expect(snap.servers).toBe(1);
47
+ expect(snap.localConns).toBe(0);
48
+ });
49
+
50
+ it('counts channels and their members (cross-channel dedup)', async () => {
51
+ const alice = makeConn('c1');
52
+ const bob = makeConn('c2', { invisible: true });
53
+ const carol = makeConn('c3', { oper: true });
54
+ // Alice and Bob share #a; Carol is alone in #b. Alice is also in #b
55
+ // (dup across channels) — must be counted once.
56
+ const channels = new Map<string, ReadonlyMap<ConnId, ConnectionState>>([
57
+ [
58
+ '#a',
59
+ new Map<ConnId, ConnectionState>([
60
+ ['c1', alice],
61
+ ['c2', bob],
62
+ ]),
63
+ ],
64
+ [
65
+ '#b',
66
+ new Map<ConnId, ConnectionState>([
67
+ ['c1', alice],
68
+ ['c3', carol],
69
+ ]),
70
+ ],
71
+ ]);
72
+ const rt = new StubRuntime(channels);
73
+
74
+ const stats = new CfStats(rt, 1_000);
75
+ const snap = await stats.getStats();
76
+
77
+ expect(snap.channels).toBe(2);
78
+ // 3 unique connections (alice, bob, carol).
79
+ expect(snap.localConns).toBe(3);
80
+ // 3 registered, 1 invisible → 2 visible.
81
+ expect(snap.users).toBe(2);
82
+ expect(snap.invisible).toBe(1);
83
+ expect(snap.opers).toBe(1);
84
+ });
85
+
86
+ it('counts a pre-registration member as an unknown connection', async () => {
87
+ const a = makeConn('c1');
88
+ const b = makeConn('c2', { registered: false });
89
+ const channels = new Map<string, ReadonlyMap<ConnId, ConnectionState>>([
90
+ [
91
+ '#a',
92
+ new Map<ConnId, ConnectionState>([
93
+ ['c1', a],
94
+ ['c2', b],
95
+ ]),
96
+ ],
97
+ ]);
98
+ const rt = new StubRuntime(channels);
99
+
100
+ const stats = new CfStats(rt, 1_000);
101
+ const snap = await stats.getStats();
102
+ expect(snap.users).toBe(1);
103
+ expect(snap.unknownConnections).toBe(1);
104
+ expect(snap.localConns).toBe(2);
105
+ });
106
+
107
+ it('echoes the supplied uptimeStartedAt', async () => {
108
+ const rt = new StubRuntime(new Map());
109
+ const stats = new CfStats(rt, 9_999);
110
+ const snap = await stats.getStats();
111
+ expect(snap.uptimeStartedAt).toBe(9_999);
112
+ });
113
+
114
+ it('returns servers=1 (single-server deployment)', async () => {
115
+ const rt = new StubRuntime(new Map());
116
+ const stats = new CfStats(rt, 0);
117
+ const snap = await stats.getStats();
118
+ expect(snap.servers).toBe(1);
119
+ });
120
+ });
@@ -7,15 +7,15 @@
7
7
  * and assert cross-DO interactions.
8
8
  *
9
9
  * Bindings:
10
- * - `CONNECTION_DO` → `ConnectionDO` (real, 033).
10
+ * - `CONNECTION_DO` → `ConnectionDO` (real).
11
11
  * - `REGISTRY_DO` → `RecordingRegistryDO` stub used by the
12
- * ConnectionDO tests (033). The stub records every RPC call
12
+ * ConnectionDO tests. The stub records every RPC call
13
13
  * into a per-instance log exposed via `runInDurableObject`.
14
- * - `REGISTRY_DO_REAL` → `RegistryDO` (real, 034). Used by
15
- * the RegistryDO unit tests. 036 will swap `REGISTRY_DO`
16
- * over to this class once the full CfRuntime lands.
17
- * - `CHANNEL_DO` → `RecordingChannelDO` stub (real impl lands in
18
- * 035).
14
+ * - `REGISTRY_DO_REAL` → `RegistryDO` (real). Used by
15
+ * the RegistryDO unit tests. The CfRuntime swaps `REGISTRY_DO`
16
+ * over to this class once it lands.
17
+ * - `CHANNEL_DO` → `RecordingChannelDO` stub (real impl in
18
+ * `ChannelDO`).
19
19
  */
20
20
 
21
21
  import { ChannelDO } from '../../src/channel-do';
@@ -25,7 +25,14 @@ import { RegistryDO } from '../../src/registry-do';
25
25
  import { RecordingChannelDO } from './stubs/channel-stub';
26
26
  import { RecordingRegistryDO } from './stubs/registry-stub';
27
27
 
28
- export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
28
+ export {
29
+ ChannelDO,
30
+ ChannelRegistryDO,
31
+ ConnectionDO,
32
+ RegistryDO,
33
+ RecordingRegistryDO,
34
+ RecordingChannelDO,
35
+ };
29
36
 
30
37
  export default {
31
38
  async fetch(): Promise<Response> {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Test-only {@link DurableObject} that records every cross-DO call from
3
- * {@link ConnectionDO}. The real ChannelDO ships in 035; for
4
- * 033 we only need to verify that ConnectionDO fan-outs (broadcast,
3
+ * {@link ConnectionDO}. The real ChannelDO ships in `channel-do.ts`;
4
+ * we only need to verify that ConnectionDO fan-outs (broadcast,
5
5
  * applyChannelDelta) reach it.
6
6
  *
7
7
  * Each instance is keyed by lowercased channel name (the convention the