serverless-ircd 0.2.0 → 0.4.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 (221) 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 +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  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 +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -2,14 +2,9 @@
2
2
  * @serverless-ircd/cf-adapter
3
3
  *
4
4
  * Cloudflare Workers adapter for ServerlessIRCd. Implements the three
5
- * Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and a
6
- * CfRuntime that bridges them behind the platform-agnostic
7
- * {@link IrcRuntime} port.
8
- *
9
- * 033 shipped the {@link ConnectionDO} + a stub CfRuntime.
10
- * 034 ships the {@link RegistryDO} (real nick registry with
11
- * sharding). The {@link ChannelDO} (roster + modes + fanout) ships
12
- * here. The full CfRuntime lands in 036.
5
+ * Durable Objects (ConnectionDO, RegistryDO, ChannelDO), the
6
+ * ChannelRegistryDO index, and a CfRuntime that bridges them behind the
7
+ * platform-agnostic {@link IrcRuntime} port.
13
8
  */
14
9
 
15
10
  export {
@@ -19,6 +14,7 @@ export {
19
14
  } from './connection-do.js';
20
15
  export { RegistryDO } from './registry-do.js';
21
16
  export { ChannelDO } from './channel-do.js';
17
+ export { ChannelRegistryDO } from './channel-registry-do.js';
22
18
  export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
23
19
  export {
24
20
  DEFAULT_REGISTRY_SHARDS,
@@ -27,7 +23,13 @@ export {
27
23
  } from './sharding.js';
28
24
  export { makeCfRuntime } from './cf-runtime.js';
29
25
  export type { CfConnectionHandlers } from './cf-runtime.js';
30
- export type { ChannelRpc, Env, RegistryRpc } from './env.js';
26
+ export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
27
+ export {
28
+ CREATE_ACCOUNTS_TABLE_SQL,
29
+ loadD1AccountStore,
30
+ putAccountCredential,
31
+ resolveAccountStore,
32
+ } from './d1-account-store.js';
31
33
  export {
32
34
  PERSISTED_STATE_VERSION,
33
35
  STATE_STORAGE_KEY,
@@ -65,7 +65,8 @@ export interface CfHarnessEnv {
65
65
  */
66
66
  export function makeCfHarnessFactory(opts: CfHarnessFactoryOptions): IrcHarnessFactory {
67
67
  return Object.freeze({
68
- name: 'cf',
68
+ name: 'cf+ws',
69
+ transport: 'ws',
69
70
  async create() {
70
71
  return new CfHarness(opts);
71
72
  },
@@ -204,6 +205,15 @@ class CfClientHandle implements ClientHarness {
204
205
  this.ws.send(`${line}\r\n`);
205
206
  }
206
207
 
208
+ async feed(chunk: string): Promise<void> {
209
+ if (this.closed) return;
210
+ // The CF harness speaks the WebSocket transport end-to-end (a real WS
211
+ // to a ConnectionDO), so one feed() is one text frame — identical to
212
+ // send() minus the framing. A CF+tcp factory (Spectrum + container
213
+ // origin) would route this through a TCP byte stream.
214
+ this.ws.send(chunk);
215
+ }
216
+
207
217
  async waitForLine(
208
218
  predicate: (line: string) => boolean,
209
219
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
@@ -62,7 +62,8 @@ afterAll(async () => {
62
62
  // guaranteed to be initialized.
63
63
  runIrcScenarios([
64
64
  {
65
- name: 'cf',
65
+ name: 'cf+ws',
66
+ transport: 'ws',
66
67
  async create() {
67
68
  if (factory === undefined) {
68
69
  factory = makeCfHarnessFactory({ env });
@@ -44,6 +44,7 @@ declare global {
44
44
  CHANNEL_DO: DurableObjectNamespace;
45
45
  CHANNEL_DO_REAL: DurableObjectNamespace;
46
46
  CHANNEL_DO_STUB: DurableObjectNamespace;
47
+ CHANNEL_REGISTRY_DO: DurableObjectNamespace;
47
48
  }
48
49
  }
49
50
  }
@@ -62,6 +63,7 @@ function envWithRealDOs(): Env {
62
63
  CONNECTION_DO: env.CONNECTION_DO,
63
64
  REGISTRY_DO: env.REGISTRY_DO_REAL,
64
65
  CHANNEL_DO: env.CHANNEL_DO_REAL,
66
+ CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
65
67
  SERVER_NAME: 'irc.example.com',
66
68
  NETWORK_NAME: 'ExampleNet',
67
69
  MOTD_LINES: '',
@@ -78,6 +80,7 @@ function envWithChannelStub(): Env {
78
80
  CONNECTION_DO: env.CONNECTION_DO,
79
81
  REGISTRY_DO: env.REGISTRY_DO_REAL,
80
82
  CHANNEL_DO: env.CHANNEL_DO_STUB,
83
+ CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
81
84
  SERVER_NAME: 'irc.example.com',
82
85
  NETWORK_NAME: 'ExampleNet',
83
86
  MOTD_LINES: '',
@@ -484,6 +487,94 @@ describe('CfRuntime — cross-connection RPC via ConnectionDO', () => {
484
487
  });
485
488
  });
486
489
 
490
+ // ---------------------------------------------------------------------------
491
+ // Tests — cross-connection getConnection / getChannelConnections / listChannels / disconnect
492
+ // ---------------------------------------------------------------------------
493
+
494
+ describe('CfRuntime — cross-connection lookups and disconnect', () => {
495
+ it('getConnection(remoteConnId) returns the remote connection state via RPC', async () => {
496
+ const targetName = 'conn-get-target';
497
+ const ws = await openConnection(targetName);
498
+ await new Promise<void>((r) => setTimeout(r, 20));
499
+ ws.send('NICK carol\r\n');
500
+ ws.send('USER carol 0 * :Carol\r\n');
501
+ await new Promise<void>((r) => setTimeout(r, 100));
502
+
503
+ const targetHexId = await getConnectionHexId(targetName);
504
+ const realEnv = envWithRealDOs();
505
+ const rt = new CfRuntime(realEnv, 'conn-get-caller', recordingHandlers());
506
+ const conn = await rt.getConnection(targetHexId);
507
+ expect(conn).not.toBeNull();
508
+ expect(conn?.nick).toBe('carol');
509
+ expect(conn?.id).toBe(targetHexId);
510
+
511
+ ws.close();
512
+ });
513
+
514
+ it('getChannelConnections(chan) returns the ConnectionStates of all members', async () => {
515
+ const memberName = 'conn-who-target';
516
+ const ws = await openConnection(memberName);
517
+ await new Promise<void>((r) => setTimeout(r, 20));
518
+ ws.send('NICK dave\r\n');
519
+ ws.send('USER dave 0 * :Dave\r\n');
520
+ await new Promise<void>((r) => setTimeout(r, 100));
521
+
522
+ const memberHexId = await getConnectionHexId(memberName);
523
+ const realEnv = envWithRealDOs();
524
+ const rt = new CfRuntime(realEnv, 'conn-who-caller', recordingHandlers());
525
+
526
+ await rt.applyChannelDelta('#whochan', {
527
+ memberships: [{ type: 'add' as const, conn: memberHexId, nick: 'dave' }],
528
+ });
529
+
530
+ const conns = await rt.getChannelConnections('#whochan');
531
+ expect(conns.size).toBe(1);
532
+ expect(conns.get(memberHexId)?.nick).toBe('dave');
533
+
534
+ ws.close();
535
+ });
536
+
537
+ it('listChannels returns channels registered in the registry', async () => {
538
+ const realEnv = envWithRealDOs();
539
+ const rt = new CfRuntime(realEnv, 'conn-list', recordingHandlers());
540
+
541
+ await rt.applyChannelDelta('#listchan', {
542
+ memberships: [{ type: 'add' as const, conn: 'conn-list', nick: 'eve' }],
543
+ });
544
+
545
+ const channels = await rt.listChannels();
546
+ expect(channels.length).toBeGreaterThan(0);
547
+ expect(channels.some((c) => c.nameLower === '#listchan')).toBe(true);
548
+ });
549
+
550
+ it('listChannels returns empty array when no channels exist', async () => {
551
+ const realEnv = envWithRealDOs();
552
+ const rt = new CfRuntime(realEnv, 'conn-list-empty', recordingHandlers());
553
+ const channels = await rt.listChannels();
554
+ expect(channels).toEqual([]);
555
+ });
556
+
557
+ it('disconnect(remoteConn) closes the target connection', async () => {
558
+ const targetName = 'conn-disc-target';
559
+ const ws = await openConnection(targetName);
560
+ await new Promise<void>((r) => setTimeout(r, 20));
561
+
562
+ const targetHexId = await getConnectionHexId(targetName);
563
+ const realEnv = envWithRealDOs();
564
+ const rt = new CfRuntime(realEnv, 'conn-disc-caller', recordingHandlers());
565
+
566
+ const closed = new Promise<void>((resolve) => {
567
+ ws.addEventListener('close', () => resolve());
568
+ setTimeout(resolve, 2000);
569
+ });
570
+
571
+ await rt.disconnect(targetHexId, 'Kicked');
572
+ await closed;
573
+
574
+ expect([WebSocket.CLOSING, WebSocket.CLOSED]).toContain(ws.readyState);
575
+ });
576
+ });
577
+
487
578
  // ---------------------------------------------------------------------------
488
579
  // Tests — implements IrcRuntime
489
580
  // ---------------------------------------------------------------------------
@@ -101,6 +101,28 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
101
101
  });
102
102
  expect(cfg.quitMessage).toBe('Bye from CF');
103
103
  });
104
+
105
+ it('threads SERVER_VERSION into serverVersion', () => {
106
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), SERVER_VERSION: '1.2.3' });
107
+ expect(cfg.serverVersion).toBe('1.2.3');
108
+ });
109
+
110
+ it('passes CREATED_AT verbatim when it is non-numeric', () => {
111
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: '2024-06-01' });
112
+ expect(cfg.createdAt).toBe('2024-06-01');
113
+ });
114
+
115
+ it('parses a numeric CREATED_AT string into an epoch-ms number', () => {
116
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), CREATED_AT: '1700000000000' });
117
+ expect(cfg.createdAt).toBe(1_700_000_000_000);
118
+ });
119
+
120
+ it('applies the schema defaults for serverVersion/createdAt when unset', () => {
121
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
122
+ expect(typeof cfg.serverVersion).toBe('string');
123
+ expect(cfg.serverVersion.length).toBeGreaterThan(0);
124
+ expect(cfg.createdAt).toBeDefined();
125
+ });
104
126
  });
105
127
 
106
128
  describe('loadServerConfigFromCfEnv — failure modes', () => {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 085 — CF `webSocketMessage` channel-registration: wire ChannelDO
3
+ * registration OR remove the dead loop.
4
+ *
5
+ * Spike conclusion (documented in the ticket): the dead loop that
6
+ * iterated `state.joinedChannels` to "register" the ConnectionDO with
7
+ * each ChannelDO is redundant. Channel membership — and therefore
8
+ * broadcast fan-out registration — is driven solely by the
9
+ * `ApplyChannelDelta` effect the joinReducer emits. The CfRuntime
10
+ * forwards that effect to the authoritative ChannelDO
11
+ * (`cf-runtime.ts` → `channel-do.ts#applyChannelDelta`), whose roster
12
+ * is the single source of truth used by `ChannelDO.broadcast`. The
13
+ * `connection-do.test.ts` "QUIT fanout to each joined channel" test
14
+ * proves this end-to-end (alice JOINs, closes, and bob observes the
15
+ * QUIT that only the ChannelDO roster could have routed).
16
+ *
17
+ * Because the removed loop was a no-op (`void chan;`), there is no
18
+ * behaviour to assert — these are source-level regression guards that
19
+ * prevent the redundant registration loop from creeping back in.
20
+ */
21
+
22
+ import { describe, expect, it } from 'vitest';
23
+ import connectionDoSource from '../src/connection-do.ts?raw';
24
+
25
+ describe('ConnectionDO — no redundant channel-registration loop', () => {
26
+ it('webSocketMessage contains no dead `void chan` no-op registration body', () => {
27
+ // The joinReducer's `ApplyChannelDelta` effect is the sole channel
28
+ // registration path; a per-ConnectionDO loop here is dead code.
29
+ expect(connectionDoSource).not.toContain('void chan');
30
+ });
31
+
32
+ it('documents that no explicit per-ConnectionDO registration step is needed', () => {
33
+ // The absence of the loop must be explained so it is not mistaken
34
+ // for a missing feature.
35
+ expect(connectionDoSource).toContain('No per-ConnectionDO channel-registration step is needed');
36
+ });
37
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 087 — CF outbound batching: implement OR drop the reserved optimization.
3
+ *
4
+ * Spike conclusion (documented in the ticket): the speculative per-microtask
5
+ * outbound drain that was reserved as a commented-out field in
6
+ * `ConnectionDO` is dropped. Per-call batching is already implemented —
7
+ * `ConnectionDO.deliver()` joins every line of a ChannelDO broadcast into
8
+ * a single `WebSocket.send()`, and the actor `send` handler
9
+ * (`buildActor`) joins every line of one frame's response the same way.
10
+ * What the reservation speculated about was *cross-call* coalescing —
11
+ * queueing lines emitted by independent `deliver` / `send` invocations
12
+ * within the same microtask and flushing them in one write. That adds a
13
+ * drain queue, a `queueMicrotask` scheduler, and ordering hazards between
14
+ * independent fan-out calls, in exchange for no measured benefit: nothing
15
+ * in the load-test report or the ChannelDO fan-out path shows WS
16
+ * round-trips as a bottleneck. The AWS adapter batches at a
17
+ * different layer (per-invocation `outbound: string[]` accumulated then
18
+ * posted once), so there is no shared abstraction worth extracting.
19
+ *
20
+ * Because the removed reservation was a comment with no runtime
21
+ * behaviour, these are source-level regression guards (mirroring the
22
+ * pattern used for other dropped reservations) that prevent the
23
+ * speculative field from creeping back in and keep the deferral
24
+ * documented at the point a reader would otherwise expect the batching
25
+ * hook.
26
+ */
27
+
28
+ import { describe, expect, it } from 'vitest';
29
+ import connectionDoSource from '../src/connection-do.ts?raw';
30
+
31
+ describe('ConnectionDO — no reserved outbound-batching field', () => {
32
+ it('does not contain the deferred "Reserved for a future batching optimization" comment', () => {
33
+ // The reservation is dropped; per-call batching in `deliver()` and
34
+ // the actor `send` handler is the implemented strategy.
35
+ expect(connectionDoSource).not.toContain('Reserved for a future batching optimization');
36
+ });
37
+
38
+ it('does not reintroduce a speculative outbound-drain field declaration', () => {
39
+ // Guards against any variant of the commented-out reservation. The
40
+ // implemented batching is per-call (join lines then `send` once);
41
+ // there is no instance-level drain queue.
42
+ expect(connectionDoSource).not.toContain('Outstanding outbound lines');
43
+ expect(connectionDoSource).not.toContain('drop the reservation');
44
+ });
45
+
46
+ it('documents the deferral decision at the point the reservation used to live', () => {
47
+ // The absence of the field must be explained so it is not mistaken
48
+ // for a missing feature, and a reader is pointed at the perf ADR.
49
+ expect(connectionDoSource).toContain('batched per call');
50
+ expect(connectionDoSource).toContain('ADR-003');
51
+ });
52
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * End-to-end SASL PLAIN through ConnectionDO with D1-backed accounts.
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).
8
+ *
9
+ * Each test file gets its own isolated D1 database from miniflare, so the
10
+ * table state set up here does not leak into `connection-do.test.ts`.
11
+ */
12
+
13
+ import { env } from 'cloudflare:test';
14
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
15
+ import { CREATE_ACCOUNTS_TABLE_SQL, putAccountCredential } from '../src/d1-account-store';
16
+
17
+ declare global {
18
+ namespace Cloudflare {
19
+ interface Env {
20
+ CONNECTION_DO: DurableObjectNamespace;
21
+ REGISTRY_DO: DurableObjectNamespace;
22
+ CHANNEL_DO: DurableObjectNamespace;
23
+ ACCOUNTS_DB: D1Database;
24
+ }
25
+ }
26
+ }
27
+
28
+ class IrcWsClient {
29
+ readonly received: string[] = [];
30
+ readonly ws: WebSocket;
31
+
32
+ constructor(ws: WebSocket) {
33
+ this.ws = ws;
34
+ ws.addEventListener('message', (ev: MessageEvent) => {
35
+ const d = ev.data as string;
36
+ for (const line of d.split('\r\n')) {
37
+ if (line.length > 0) this.received.push(line);
38
+ }
39
+ });
40
+ }
41
+
42
+ send(line: string): void {
43
+ this.ws.send(line);
44
+ }
45
+
46
+ waitForMessage(predicate: (line: string) => boolean, timeoutMs = 2000): Promise<string> {
47
+ return new Promise<string>((resolve, reject) => {
48
+ const start = Date.now();
49
+ const tick = (): void => {
50
+ const hit = this.received.find(predicate);
51
+ if (hit !== undefined) {
52
+ resolve(hit);
53
+ return;
54
+ }
55
+ if (Date.now() - start > timeoutMs) {
56
+ reject(new Error(`timeout waiting for message; got: ${JSON.stringify(this.received)}`));
57
+ return;
58
+ }
59
+ setTimeout(tick, 20);
60
+ };
61
+ tick();
62
+ });
63
+ }
64
+
65
+ close(): Promise<void> {
66
+ return new Promise<void>((resolve) => {
67
+ this.ws.addEventListener('close', () => resolve());
68
+ this.ws.close();
69
+ });
70
+ }
71
+ }
72
+
73
+ async function connect(connId: string): Promise<IrcWsClient> {
74
+ const doId = env.CONNECTION_DO.idFromName(connId);
75
+ const stub = env.CONNECTION_DO.get(doId);
76
+ const response = await stub.fetch('https://do/upgrade', {
77
+ headers: { Upgrade: 'websocket' },
78
+ });
79
+ const ws = response.webSocket;
80
+ if (ws === undefined || ws === null) {
81
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
82
+ }
83
+ ws.accept();
84
+ return new Promise<IrcWsClient>((resolve) => {
85
+ setTimeout(() => resolve(new IrcWsClient(ws as WebSocket)), 0);
86
+ });
87
+ }
88
+
89
+ /** base64(`\0authcid\0password`) — the PLAIN payload format. */
90
+ function plainB64(authcid: string, password: string): string {
91
+ const raw = `\0${authcid}\0${password}`;
92
+ let latin1 = '';
93
+ for (let i = 0; i < raw.length; i++) latin1 += String.fromCharCode(raw.charCodeAt(i));
94
+ return (globalThis as unknown as { btoa: (s: string) => string }).btoa(latin1);
95
+ }
96
+
97
+ /** Negotiates `CAP sasl` and sends an AUTHENTICATE PLAIN payload. */
98
+ async function authenticatePlain(
99
+ client: IrcWsClient,
100
+ authcid: string,
101
+ password: string,
102
+ ): Promise<void> {
103
+ client.send('CAP REQ :sasl\r\n');
104
+ await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
105
+ client.send('AUTHENTICATE PLAIN\r\n');
106
+ await client.waitForMessage((l) => l === 'AUTHENTICATE +');
107
+ client.send(`AUTHENTICATE ${plainB64(authcid, password)}\r\n`);
108
+ }
109
+
110
+ describe('ConnectionDO — SASL PLAIN with D1-backed accounts', () => {
111
+ beforeEach(async () => {
112
+ await env.ACCOUNTS_DB.exec(CREATE_ACCOUNTS_TABLE_SQL);
113
+ });
114
+ afterEach(async () => {
115
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS accounts');
116
+ });
117
+
118
+ it('completes SASL PLAIN with 900/903 for a D1-seeded account', async () => {
119
+ await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
120
+ const client = await connect('conn-d1-sasl-1');
121
+ await authenticatePlain(client, 'd1-alice', 'd1-s3cret');
122
+ await client.waitForMessage((l) => l.includes(' 900 '));
123
+ const success = await client.waitForMessage((l) => l.includes(' 903 '));
124
+ expect(success).toContain(' 903 ');
125
+ await client.close();
126
+ });
127
+
128
+ it('rejects SASL PLAIN with 904 for a wrong password (D1 account)', async () => {
129
+ await putAccountCredential(env.ACCOUNTS_DB, 'd1-alice', 'd1-s3cret');
130
+ const client = await connect('conn-d1-sasl-2');
131
+ await authenticatePlain(client, 'd1-alice', 'wrong');
132
+ const fail = await client.waitForMessage((l) => l.includes(' 904 '));
133
+ expect(fail).toContain(' 904 ');
134
+ await client.close();
135
+ });
136
+
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');
140
+ 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');
143
+ const fail = await client.waitForMessage((l) => l.includes(' 904 '));
144
+ expect(fail).toContain(' 904 ');
145
+ await client.close();
146
+ });
147
+ });
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
+ });
@@ -366,6 +366,46 @@ describe('ConnectionDO — chathistory end-to-end', () => {
366
366
  });
367
367
  });
368
368
 
369
+ // ---------------------------------------------------------------------------
370
+ // SASL PLAIN end-to-end (AccountStore plumbing)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ describe('ConnectionDO — SASL PLAIN end-to-end', () => {
374
+ /** base64(`\0authcid\0password`) — the PLAIN payload format. */
375
+ function plainB64(authcid: string, password: string): string {
376
+ // workerd has no `Buffer`; use btoa on a Latin1-encoded string.
377
+ const raw = `\0${authcid}\0${password}`;
378
+ let latin1 = '';
379
+ for (let i = 0; i < raw.length; i++) latin1 += String.fromCharCode(raw.charCodeAt(i));
380
+ return (globalThis as unknown as { btoa: (s: string) => string }).btoa(latin1);
381
+ }
382
+
383
+ it('completes SASL PLAIN with 900/903 for valid seeded creds', async () => {
384
+ const client = await connect('conn-sasl-1');
385
+ client.send('CAP REQ :sasl\r\n');
386
+ await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
387
+ client.send('AUTHENTICATE PLAIN\r\n');
388
+ await client.waitForMessage((l) => l === 'AUTHENTICATE +');
389
+ client.send(`AUTHENTICATE ${plainB64('cf-sasl', 's3cret')}\r\n`);
390
+ await client.waitForMessage((l) => l.includes(' 900 '));
391
+ const success = await client.waitForMessage((l) => l.includes(' 903 '));
392
+ expect(success).toContain(' 903 ');
393
+ client.ws.close();
394
+ });
395
+
396
+ it('rejects SASL PLAIN with 904 for invalid creds', async () => {
397
+ const client = await connect('conn-sasl-2');
398
+ client.send('CAP REQ :sasl\r\n');
399
+ await client.waitForMessage((l) => / CAP \S+ ACK :sasl/.test(l));
400
+ client.send('AUTHENTICATE PLAIN\r\n');
401
+ await client.waitForMessage((l) => l === 'AUTHENTICATE +');
402
+ client.send(`AUTHENTICATE ${plainB64('cf-sasl', 'wrong')}\r\n`);
403
+ const fail = await client.waitForMessage((l) => l.includes(' 904 '));
404
+ expect(fail).toContain(' 904 ');
405
+ client.ws.close();
406
+ });
407
+ });
408
+
369
409
  // ---------------------------------------------------------------------------
370
410
  // End of file
371
411
  // ---------------------------------------------------------------------------