serverless-ircd 0.2.0 → 0.3.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 (134) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +28 -19
  3. package/apps/cf-worker/package.json +1 -1
  4. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  5. package/apps/cf-worker/src/worker.ts +8 -2
  6. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  7. package/apps/cf-worker/wrangler.test.toml +5 -1
  8. package/apps/cf-worker/wrangler.toml +41 -17
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/config-loader.ts +1 -0
  11. package/apps/local-cli/src/main.ts +1 -1
  12. package/apps/local-cli/src/server.ts +28 -2
  13. package/apps/local-cli/tests/e2e.test.ts +52 -0
  14. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  15. package/docs/ADR-002-location-of-authority.md +82 -0
  16. package/docs/ADR-003-durable-object-sharding.md +93 -0
  17. package/docs/ADR-004-dynamodb-schema.md +96 -0
  18. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  19. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  20. package/docs/ADR-007-deterministic-ports.md +82 -0
  21. package/docs/ADR-008-monorepo-tooling.md +60 -0
  22. package/docs/AWS-Adapter-Architecture.md +6 -4
  23. package/docs/AWS-Deployment.md +86 -7
  24. package/docs/Cloudflare-Deployment-Guide.md +5 -5
  25. package/docs/Home.md +11 -1
  26. package/docs/Release-Process.md +9 -6
  27. package/package.json +14 -15
  28. package/packages/aws-adapter/package.json +1 -1
  29. package/packages/aws-adapter/src/account-store.ts +121 -0
  30. package/packages/aws-adapter/src/aws-runtime.ts +118 -30
  31. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  32. package/packages/aws-adapter/src/config-loader.ts +30 -0
  33. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  34. package/packages/aws-adapter/src/handlers/default.ts +8 -0
  35. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  36. package/packages/aws-adapter/src/handlers/index.ts +51 -6
  37. package/packages/aws-adapter/src/index.ts +7 -0
  38. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  39. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  40. package/packages/aws-adapter/tests/config-loader.test.ts +55 -0
  41. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  42. package/packages/aws-adapter/tests/handlers.test.ts +46 -0
  43. package/packages/cf-adapter/package.json +1 -1
  44. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  45. package/packages/cf-adapter/src/channel-do.ts +40 -1
  46. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  47. package/packages/cf-adapter/src/config-loader.ts +29 -0
  48. package/packages/cf-adapter/src/connection-do.ts +85 -0
  49. package/packages/cf-adapter/src/env.ts +27 -1
  50. package/packages/cf-adapter/src/index.ts +2 -1
  51. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  52. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  53. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  54. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  55. package/packages/cf-adapter/wrangler.test.toml +10 -1
  56. package/packages/in-memory-runtime/package.json +1 -1
  57. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  58. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  59. package/packages/irc-core/package.json +3 -2
  60. package/packages/irc-core/src/case-fold.ts +64 -0
  61. package/packages/irc-core/src/commands/index.ts +2 -0
  62. package/packages/irc-core/src/commands/invite.ts +6 -9
  63. package/packages/irc-core/src/commands/isupport.ts +1 -1
  64. package/packages/irc-core/src/commands/join.ts +4 -3
  65. package/packages/irc-core/src/commands/kick.ts +4 -11
  66. package/packages/irc-core/src/commands/list.ts +5 -4
  67. package/packages/irc-core/src/commands/mode.ts +5 -9
  68. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  69. package/packages/irc-core/src/commands/motd.ts +6 -110
  70. package/packages/irc-core/src/commands/oper.ts +151 -0
  71. package/packages/irc-core/src/commands/registration.ts +8 -7
  72. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  73. package/packages/irc-core/src/config.ts +26 -3
  74. package/packages/irc-core/src/index.ts +2 -0
  75. package/packages/irc-core/src/ports.ts +68 -4
  76. package/packages/irc-core/src/types.ts +22 -0
  77. package/packages/irc-core/tests/account-store.test.ts +88 -0
  78. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  79. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  80. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  81. package/packages/irc-core/tests/commands/registration.test.ts +106 -14
  82. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  83. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  84. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  85. package/packages/irc-core/tests/config.test.ts +75 -0
  86. package/packages/irc-server/package.json +1 -1
  87. package/packages/irc-server/src/actor.ts +24 -0
  88. package/packages/irc-server/src/routing.ts +9 -0
  89. package/packages/irc-server/tests/actor.test.ts +220 -1
  90. package/packages/irc-server/tests/routing.test.ts +3 -0
  91. package/packages/irc-test-support/package.json +1 -1
  92. package/packages/irc-test-support/src/in-memory-harness.ts +5 -4
  93. package/pnpm-workspace.yaml +1 -0
  94. package/tools/seed-aws-accounts.ts +80 -0
  95. package/AGENTS.md +0 -5
  96. package/MOTD.txt +0 -3
  97. package/PLAN-FIXES.md +0 -358
  98. package/PLAN.md +0 -420
  99. package/dashboards/cloudwatch-irc.json +0 -139
  100. package/progress.md +0 -107
  101. package/tickets.md +0 -2485
  102. package/webircgateway/LICENSE +0 -201
  103. package/webircgateway/Makefile +0 -44
  104. package/webircgateway/README.md +0 -134
  105. package/webircgateway/config.conf.example +0 -135
  106. package/webircgateway/go.mod +0 -16
  107. package/webircgateway/go.sum +0 -89
  108. package/webircgateway/main.go +0 -118
  109. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  110. package/webircgateway/pkg/identd/identd.go +0 -86
  111. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  112. package/webircgateway/pkg/irc/isupport.go +0 -56
  113. package/webircgateway/pkg/irc/message.go +0 -217
  114. package/webircgateway/pkg/irc/state.go +0 -79
  115. package/webircgateway/pkg/proxy/proxy.go +0 -129
  116. package/webircgateway/pkg/proxy/server.go +0 -237
  117. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  118. package/webircgateway/pkg/webircgateway/client.go +0 -741
  119. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  120. package/webircgateway/pkg/webircgateway/config.go +0 -385
  121. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  122. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  123. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  124. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  125. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  126. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  127. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  128. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  129. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  130. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  131. package/webircgateway/plugins/example/plugin.go +0 -11
  132. package/webircgateway/plugins/stats/plugin.go +0 -52
  133. package/webircgateway/staticcheck.conf +0 -1
  134. package/webircgateway/webircgateway.svg +0 -3
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `ChannelRegistryDO` — Cloudflare Durable Object that tracks the set
3
+ * of all channel names.
4
+ *
5
+ * A single DO instance (keyed by the literal name `channels`) maintains
6
+ * the complete channel-name set so {@link CfRuntime.listChannels} can
7
+ * enumerate every channel without scanning each per-channel DO
8
+ * individually.
9
+ *
10
+ * Storage layout:
11
+ * - One key per channel: `chan:<lower>` → `1`
12
+ *
13
+ * Per-channel keys keep storage proportional to the live channel count
14
+ * and let individual register/unregister calls stay O(1).
15
+ *
16
+ * RPC surface — implements {@link ChannelRegistryRpc}. The ChannelDO
17
+ * calls `register` / `unregister` whenever its membership transitions
18
+ * between empty and non-empty; CfRuntime calls `list` for LIST.
19
+ */
20
+
21
+ import { DurableObject } from 'cloudflare:workers';
22
+ import type { ChannelRegistryRpc } from './env.js';
23
+
24
+ /** Storage key prefix for per-channel entries. */
25
+ const CHAN_KEY_PREFIX = 'chan:';
26
+
27
+ /** Returns the storage key for `nameLower`. */
28
+ function chanKey(nameLower: string): string {
29
+ return `${CHAN_KEY_PREFIX}${nameLower}`;
30
+ }
31
+
32
+ /**
33
+ * Channel registry authority.
34
+ *
35
+ * The class is exported as a binding target in `wrangler.toml`; the
36
+ * Workers runtime instantiates it with `(ctx, env)`.
37
+ */
38
+ export class ChannelRegistryDO extends DurableObject implements ChannelRegistryRpc {
39
+ /** Adds `name` to the registry (idempotent). */
40
+ async register(name: string): Promise<void> {
41
+ await this.ctx.storage.put(chanKey(name.toLowerCase()), 1);
42
+ }
43
+
44
+ /** Removes `name` from the registry (idempotent). */
45
+ async unregister(name: string): Promise<void> {
46
+ await this.ctx.storage.delete(chanKey(name.toLowerCase()));
47
+ }
48
+
49
+ /** Returns all registered channel names (lowercased). */
50
+ async list(): Promise<string[]> {
51
+ const entries = await this.ctx.storage.list({ prefix: CHAN_KEY_PREFIX });
52
+ const names: string[] = [];
53
+ for (const key of entries.keys()) {
54
+ names.push(key.slice(CHAN_KEY_PREFIX.length));
55
+ }
56
+ return names;
57
+ }
58
+ }
@@ -34,6 +34,12 @@ export interface CfConfigEnv {
34
34
  TOPIC_LEN?: string;
35
35
  MAX_LIST_ENTRIES?: string;
36
36
  QUIT_MESSAGE?: string;
37
+ /**
38
+ * SASL PLAIN accounts. Newline-delimited `username:password` pairs.
39
+ * Parsed into the `saslAccounts` config field so the config-driven
40
+ * path (vs the connection-do's direct env read) stays consistent.
41
+ */
42
+ SASL_ACCOUNTS?: string;
37
43
  }
38
44
 
39
45
  /**
@@ -90,9 +96,32 @@ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
90
96
  if (env.QUIT_MESSAGE !== undefined) {
91
97
  input.quitMessage = env.QUIT_MESSAGE;
92
98
  }
99
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
100
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
101
+ }
93
102
  return input;
94
103
  }
95
104
 
105
+ /**
106
+ * Parses a newline-delimited `username:password` string into the config's
107
+ * `saslAccounts` shape. Malformed entries are skipped so a trailing
108
+ * newline or partial edit does not break boot.
109
+ */
110
+ function parseSaslAccountsLines(raw: string): Array<{ username: string; password: string }> {
111
+ const out: Array<{ username: string; password: string }> = [];
112
+ for (const line of raw.split('\n')) {
113
+ const trimmed = line.trim();
114
+ if (trimmed.length === 0) continue;
115
+ const sep = trimmed.indexOf(':');
116
+ if (sep <= 0) continue;
117
+ const username = trimmed.slice(0, sep);
118
+ const password = trimmed.slice(sep + 1);
119
+ if (username.length === 0 || password.length === 0) continue;
120
+ out.push({ username, password });
121
+ }
122
+ return out;
123
+ }
124
+
96
125
  /**
97
126
  * Loads and validates the server config from a Cloudflare Workers env.
98
127
  *
@@ -29,6 +29,7 @@
29
29
 
30
30
  import { DurableObject } from 'cloudflare:workers';
31
31
  import {
32
+ type AccountStore,
32
33
  type ChanName,
33
34
  type ChannelState,
34
35
  type Clock,
@@ -36,12 +37,14 @@ import {
36
37
  type ConnectionState,
37
38
  ConsoleLogger,
38
39
  type IdFactory,
40
+ InMemoryAccountStore,
39
41
  InMemoryMessageStore,
40
42
  LogLevel,
41
43
  type Logger,
42
44
  type MessageStore,
43
45
  type MotdProvider,
44
46
  type RawLine,
47
+ type SaslAccountCredential,
45
48
  type ServerConfig,
46
49
  SystemClock,
47
50
  UuidIdFactory,
@@ -55,6 +58,7 @@ import { makeCfRuntime } from './cf-runtime.js';
55
58
  import type { CfConnectionHandlers } from './cf-runtime.js';
56
59
  import type { Env } from './env.js';
57
60
  import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
61
+ import type { PersistedConnectionState } from './serialize.js';
58
62
 
59
63
  /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
60
64
  export const DEFAULT_PING_INTERVAL_MS = 60_000;
@@ -104,6 +108,13 @@ export class ConnectionDO extends DurableObject<Env> {
104
108
  * follow-up. Lazily constructed so the first frame seeds it.
105
109
  */
106
110
  private messages: MessageStore | undefined;
111
+ /**
112
+ * In-worker SASL account store seeded from the `SASL_ACCOUNTS` env var.
113
+ * Lazily constructed so the first frame that needs it parses the env once.
114
+ * A Durable-Object / D1-backed persistent variant is a documented
115
+ * follow-up; swapping it in means replacing {@link accountStore}.
116
+ */
117
+ private accounts: AccountStore | undefined;
107
118
  private readonly clock: Clock = SystemClock;
108
119
  private readonly ids: IdFactory = new UuidIdFactory();
109
120
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
@@ -311,6 +322,7 @@ export class ConnectionDO extends DurableObject<Env> {
311
322
  // Bound `connectionId` so every record the actor emits is filterable
312
323
  // per-connection in the Workers dashboard.
313
324
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
325
+ const accounts = this.accountStore();
314
326
  return new ConnectionActor({
315
327
  state,
316
328
  runtime,
@@ -320,6 +332,7 @@ export class ConnectionDO extends DurableObject<Env> {
320
332
  ids: this.ids,
321
333
  motd,
322
334
  messages: this.messageStore(),
335
+ ...(accounts !== undefined ? { accounts } : {}),
323
336
  logger,
324
337
  });
325
338
  }
@@ -384,6 +397,21 @@ export class ConnectionDO extends DurableObject<Env> {
384
397
  return this.messages;
385
398
  }
386
399
 
400
+ /**
401
+ * Lazily constructs the in-worker SASL account store from the
402
+ * `SASL_ACCOUNTS` env var. Returns `undefined` when no accounts are
403
+ * configured so the actor's `ctx.accounts` stays unset (preserving the
404
+ * no-store behaviour: `AUTHENTICATE PLAIN` → `904`). A Durable-Object /
405
+ * D1-backed persistent variant is a documented follow-up.
406
+ */
407
+ private accountStore(): AccountStore | undefined {
408
+ if (this.accounts !== undefined) return this.accounts;
409
+ const creds = parseSaslAccountsEnv(this.env.SASL_ACCOUNTS);
410
+ if (creds.length === 0) return undefined;
411
+ this.accounts = new InMemoryAccountStore(creds);
412
+ return this.accounts;
413
+ }
414
+
387
415
  // -------------------------------------------------------------------------
388
416
  // Test hooks (only invoked from `runInDurableObject` in tests)
389
417
  // -------------------------------------------------------------------------
@@ -470,6 +498,38 @@ export class ConnectionDO extends DurableObject<Env> {
470
498
  const state = await this.loadState();
471
499
  return toSnapshot(state);
472
500
  }
501
+
502
+ /**
503
+ * Cross-connection RPC: returns the full connection state as a
504
+ * JSON-friendly DTO ({@link PersistedConnectionState}). Called by
505
+ * another connection's {@link CfRuntime} when a `GetConnection`
506
+ * lookup targets this connection (e.g. WHOIS). The DTO survives DO
507
+ * RPC because `Set`/`Map` fields are pre-flattened to arrays by
508
+ * {@link serialize}; the caller rehydrates via {@link deserialize}.
509
+ */
510
+ public async getConnState(): Promise<PersistedConnectionState | null> {
511
+ const state = await this.loadState();
512
+ return serialize(state);
513
+ }
514
+
515
+ /**
516
+ * Cross-connection RPC: tears down this connection. Reuses the same
517
+ * QUIT fan-out / nick-release / WebSocket-close path as the normal
518
+ * `webSocketClose` handler so KICK and operator-driven disconnect
519
+ * produce identical side effects. If no live socket is attached the
520
+ * no-socket teardown path still releases the nick.
521
+ */
522
+ public async disconnect(reason?: string): Promise<void> {
523
+ void reason;
524
+ const sockets = this.ctx.getWebSockets();
525
+ const ws = sockets[0];
526
+ if (ws === undefined) {
527
+ const state = await this.loadState();
528
+ await this.tearDownNoSocket(state);
529
+ return;
530
+ }
531
+ await this.tearDown(ws as WebSocket);
532
+ }
473
533
  }
474
534
 
475
535
  /** Default MOTD when no `MOTD_LINES` env is supplied. */
@@ -574,3 +634,28 @@ class PassthroughChannelAccess {
574
634
  // in production today. (Removed from the ConnectionDO path because the
575
635
  // authoritative delta application lives in the ChannelDO stub today.)
576
636
  export { applyChannelDelta, PERSISTED_STATE_VERSION };
637
+
638
+ /**
639
+ * Parses the `SASL_ACCOUNTS` env var into credential entries.
640
+ *
641
+ * Format: newline-delimited `username:password` pairs (mirroring the
642
+ * `MOTD_LINES` convention). Blank lines and malformed entries (missing
643
+ * colon or empty username/password) are skipped so a trailing newline or a
644
+ * partial edit does not break boot. Exported so the parsing boundary is
645
+ * unit-testable in isolation.
646
+ */
647
+ export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
648
+ if (raw === undefined || raw.length === 0) return [];
649
+ const out: SaslAccountCredential[] = [];
650
+ for (const line of raw.split('\n')) {
651
+ const trimmed = line.trim();
652
+ if (trimmed.length === 0) continue;
653
+ const sep = trimmed.indexOf(':');
654
+ if (sep <= 0) continue; // missing colon or empty username
655
+ const username = trimmed.slice(0, sep);
656
+ const password = trimmed.slice(sep + 1);
657
+ if (username.length === 0 || password.length === 0) continue;
658
+ out.push({ username, password });
659
+ }
660
+ return out;
661
+ }
@@ -31,13 +31,28 @@ export interface Env {
31
31
  REGISTRY_DO: DurableObjectNamespace;
32
32
  /**
33
33
  * Channel authority, keyed by lowercased channel name. RPC: broadcast /
34
- * applyChannelDelta / getChannelSnapshot. Stubbed now; real in 035.
34
+ * applyChannelDelta / getChannelSnapshot / listMembers. Stubbed now;
35
+ * real in 035.
35
36
  */
36
37
  CHANNEL_DO: DurableObjectNamespace;
38
+ /**
39
+ * Channel registry authority. A single DO instance tracks the set of
40
+ * all channel names so {@link CfRuntime.listChannels} can enumerate
41
+ * them without scanning every per-channel DO. RPC: register /
42
+ * unregister / list.
43
+ */
44
+ CHANNEL_REGISTRY_DO: DurableObjectNamespace;
37
45
  /** Server-level config knobs (name, MOTD lines, limits). */
38
46
  SERVER_NAME: string;
39
47
  NETWORK_NAME: string;
40
48
  MOTD_LINES: string;
49
+ /**
50
+ * SASL PLAIN accounts seeded into the in-worker `AccountStore`.
51
+ * Newline-delimited `username:password` pairs; the connection-do parses
52
+ * these into an `InMemoryAccountStore` so `AUTHENTICATE PLAIN` works
53
+ * end-to-end. Empty/undefined disables SASL account verification.
54
+ */
55
+ SASL_ACCOUNTS?: string;
41
56
  }
42
57
 
43
58
  /**
@@ -64,4 +79,15 @@ export interface ChannelRpc {
64
79
  broadcast(lines: unknown[], except?: string): Promise<void>;
65
80
  applyChannelDelta(delta: unknown): Promise<void>;
66
81
  getChannelSnapshot(): Promise<unknown>;
82
+ listMembers(): Promise<string[]>;
83
+ }
84
+
85
+ /**
86
+ * RPC contract for the channel registry DO. Tracks the set of all
87
+ * channel names so {@link CfRuntime.listChannels} can enumerate them.
88
+ */
89
+ export interface ChannelRegistryRpc {
90
+ register(name: string): Promise<void>;
91
+ unregister(name: string): Promise<void>;
92
+ list(): Promise<string[]>;
67
93
  }
@@ -19,6 +19,7 @@ export {
19
19
  } from './connection-do.js';
20
20
  export { RegistryDO } from './registry-do.js';
21
21
  export { ChannelDO } from './channel-do.js';
22
+ export { ChannelRegistryDO } from './channel-registry-do.js';
22
23
  export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
23
24
  export {
24
25
  DEFAULT_REGISTRY_SHARDS,
@@ -27,7 +28,7 @@ export {
27
28
  } from './sharding.js';
28
29
  export { makeCfRuntime } from './cf-runtime.js';
29
30
  export type { CfConnectionHandlers } from './cf-runtime.js';
30
- export type { ChannelRpc, Env, RegistryRpc } from './env.js';
31
+ export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
31
32
  export {
32
33
  PERSISTED_STATE_VERSION,
33
34
  STATE_STORAGE_KEY,
@@ -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
  // ---------------------------------------------------------------------------
@@ -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
  // ---------------------------------------------------------------------------
@@ -19,12 +19,13 @@
19
19
  */
20
20
 
21
21
  import { ChannelDO } from '../../src/channel-do';
22
+ import { ChannelRegistryDO } from '../../src/channel-registry-do';
22
23
  import { ConnectionDO } from '../../src/connection-do';
23
24
  import { RegistryDO } from '../../src/registry-do';
24
25
  import { RecordingChannelDO } from './stubs/channel-stub';
25
26
  import { RecordingRegistryDO } from './stubs/registry-stub';
26
27
 
27
- export { ChannelDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
28
+ export { ChannelDO, ChannelRegistryDO, ConnectionDO, RegistryDO, RecordingRegistryDO, RecordingChannelDO };
28
29
 
29
30
  export default {
30
31
  async fetch(): Promise<Response> {
@@ -12,7 +12,7 @@
12
12
  import { DurableObject } from 'cloudflare:workers';
13
13
 
14
14
  export interface RecordedChannelCall {
15
- method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot';
15
+ method: 'broadcast' | 'applyChannelDelta' | 'getChannelSnapshot' | 'listMembers';
16
16
  args: unknown[];
17
17
  }
18
18
 
@@ -20,6 +20,7 @@ interface ChannelRpc {
20
20
  broadcast(lines: unknown[], except?: string): Promise<void>;
21
21
  applyChannelDelta(delta: unknown): Promise<void>;
22
22
  getChannelSnapshot(): Promise<unknown>;
23
+ listMembers(): Promise<string[]>;
23
24
  }
24
25
 
25
26
  const LOG_KEY = 'channel-call-log';
@@ -47,4 +48,9 @@ export class RecordingChannelDO extends DurableObject implements ChannelRpc {
47
48
  await this.append({ method: 'getChannelSnapshot', args: [] });
48
49
  return null;
49
50
  }
51
+
52
+ async listMembers(): Promise<string[]> {
53
+ await this.append({ method: 'listMembers', args: [] });
54
+ return [];
55
+ }
50
56
  }
@@ -9,6 +9,10 @@ compatibility_flags = ["nodejs_compat"]
9
9
  # body text in every runtime factory.
10
10
  [vars]
11
11
  MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
12
+ # SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
13
+ # delimited `username:password` pairs; the connection-do parses these into
14
+ # an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
15
+ SASL_ACCOUNTS = "cf-sasl:s3cret"
12
16
 
13
17
  # Production bindings: REGISTRY_DO and CHANNEL_DO point at the real DO
14
18
  # classes so CfRuntime, ConnectionDO, and the integration suite all
@@ -25,6 +29,11 @@ class_name = "RegistryDO"
25
29
  name = "CHANNEL_DO"
26
30
  class_name = "ChannelDO"
27
31
 
32
+ # Channel registry — single DO tracking all channel names for LIST.
33
+ [[durable_objects.bindings]]
34
+ name = "CHANNEL_REGISTRY_DO"
35
+ class_name = "ChannelRegistryDO"
36
+
28
37
  # Recording stubs (kept under separate names for any test that wants to
29
38
  # assert "this exact RPC was emitted" rather than the end-to-end effect).
30
39
  [[durable_objects.bindings]]
@@ -47,4 +56,4 @@ class_name = "ChannelDO"
47
56
 
48
57
  [[migrations]]
49
58
  tag = "v1"
50
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "RecordingRegistryDO", "RecordingChannelDO"]
59
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",