serverless-ircd 0.1.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -46,7 +46,7 @@ import {
46
46
  createChannel,
47
47
  toSnapshot,
48
48
  } from '@serverless-ircd/irc-core';
49
- import type { Env } from './env.js';
49
+ import type { ChannelRegistryRpc, Env } from './env.js';
50
50
 
51
51
  /** Storage key under which the per-channel state is persisted. */
52
52
  const STATE_STORAGE_KEY = 'channel-state';
@@ -129,8 +129,10 @@ export class ChannelDO extends DurableObject<Env> {
129
129
  */
130
130
  async applyChannelDelta(delta: ChannelDelta): Promise<void> {
131
131
  const state = await this.loadState();
132
+ const wasEmpty = state.members.size === 0;
132
133
  const next = applyChannelDelta(state, delta);
133
134
  await this.persistState(next);
135
+ await this.syncRegistry(wasEmpty, next);
134
136
  }
135
137
 
136
138
  // -------------------------------------------------------------------------
@@ -160,10 +162,12 @@ export class ChannelDO extends DurableObject<Env> {
160
162
  const payload = lines.map((text) => ({ text }));
161
163
  const pruned = await this.fanoutAndCollect(state, payload, except);
162
164
  if (pruned.length > 0) {
165
+ const wasEmpty = false;
163
166
  const next = applyChannelDelta(state, {
164
167
  memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
165
168
  });
166
169
  await this.persistState(next);
170
+ await this.syncRegistry(wasEmpty, next);
167
171
  }
168
172
  }
169
173
 
@@ -182,10 +186,12 @@ export class ChannelDO extends DurableObject<Env> {
182
186
  // OPEN-socket count without sending bytes).
183
187
  const pruned = await this.fanoutAndCollect(state, [], undefined);
184
188
  if (pruned.length > 0) {
189
+ const wasEmpty = false;
185
190
  const next = applyChannelDelta(state, {
186
191
  memberships: pruned.map((conn) => ({ type: 'remove' as const, conn })),
187
192
  });
188
193
  await this.persistState(next);
194
+ await this.syncRegistry(wasEmpty, next);
189
195
  }
190
196
  return pruned.length;
191
197
  }
@@ -206,6 +212,16 @@ export class ChannelDO extends DurableObject<Env> {
206
212
  return toDto(state);
207
213
  }
208
214
 
215
+ /**
216
+ * Returns the ConnIds of every member of this channel. Used by
217
+ * {@link CfRuntime.getChannelConnections} to enumerate the
218
+ * ConnectionStates of all members (for WHO).
219
+ */
220
+ async listMembers(): Promise<string[]> {
221
+ const state = await this.loadState();
222
+ return Array.from(state.members.keys());
223
+ }
224
+
209
225
  // -------------------------------------------------------------------------
210
226
  // Internal helpers
211
227
  // -------------------------------------------------------------------------
@@ -289,6 +305,29 @@ export class ChannelDO extends DurableObject<Env> {
289
305
  }
290
306
  }
291
307
 
308
+ /**
309
+ * Keeps the channel registry in sync: registers the channel when it
310
+ * transitions from empty to non-empty, unregisters on the reverse
311
+ * transition. Called after every membership-mutating path so the
312
+ * registry always reflects the live channel set.
313
+ */
314
+ private async syncRegistry(wasEmpty: boolean, next: ChannelState): Promise<void> {
315
+ const isEmpty = next.members.size === 0;
316
+ if (wasEmpty && !isEmpty) {
317
+ await this.channelRegistry().register(next.nameLower);
318
+ } else if (!wasEmpty && isEmpty) {
319
+ await this.channelRegistry().unregister(next.nameLower);
320
+ }
321
+ }
322
+
323
+ /** Returns the channel registry DO RPC stub. */
324
+ private channelRegistry(): ChannelRegistryRpc {
325
+ const stub = this.env.CHANNEL_REGISTRY_DO.get(
326
+ this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
327
+ );
328
+ return stub as unknown as ChannelRegistryRpc;
329
+ }
330
+
292
331
  // -------------------------------------------------------------------------
293
332
  // Test hooks (only invoked from `runInDurableObject` in tests)
294
333
  // -------------------------------------------------------------------------
@@ -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
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Cloudflare Workers config loader.
3
+ *
4
+ * Adapts the platform-native env vars (Workers `vars` in `wrangler.toml`
5
+ * or per-environment secrets) into the shared `ServerConfigSchema` from
6
+ * `irc-core`. Both adapters (CF + AWS) consume the same schema; only the
7
+ * env-shape mapping differs per platform.
8
+ *
9
+ * String env vars are coerced into the schema's expected types here so
10
+ * operators can express every config knob as a wrangler var/secret
11
+ * without per-type plumbing in the worker.
12
+ */
13
+
14
+ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
15
+
16
+ /**
17
+ * The subset of the Workers `Env` that this loader reads. Mirrors the
18
+ * production {@link Env} in `env.ts` plus the optional knobs the schema
19
+ * recognises. Optional members are omitted from the input object when
20
+ * undefined so the schema's defaults apply.
21
+ */
22
+ export interface CfConfigEnv {
23
+ SERVER_NAME?: string;
24
+ NETWORK_NAME?: string;
25
+ MOTD_LINES?: string;
26
+ MAX_CLIENTS?: string;
27
+ CHANNEL_PREFIXES?: string;
28
+ OPER_USER?: string;
29
+ OPER_PASSWORD?: string;
30
+ MAX_CHANNELS_PER_USER?: string;
31
+ MAX_TARGETS_PER_COMMAND?: string;
32
+ NICK_LEN?: string;
33
+ CHANNEL_LEN?: string;
34
+ TOPIC_LEN?: string;
35
+ MAX_LIST_ENTRIES?: string;
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;
43
+ }
44
+
45
+ /**
46
+ * Builds the input object the shared schema expects from a Workers env.
47
+ *
48
+ * Helper extracted so the assemble→parse boundary is unit-testable in
49
+ * isolation: the produced input is a plain object that exactly matches
50
+ * `ServerConfigSchema`'s shape (strings converted to numbers / arrays
51
+ * as needed). Missing optional fields are kept out so the schema
52
+ * defaults kick in.
53
+ */
54
+ function buildConfigInput(env: CfConfigEnv): Record<string, unknown> {
55
+ const input: Record<string, unknown> = {
56
+ serverName: env.SERVER_NAME,
57
+ networkName: env.NETWORK_NAME,
58
+ };
59
+ if (env.MOTD_LINES !== undefined && env.MOTD_LINES.length > 0) {
60
+ input.motdLines = env.MOTD_LINES.split('\n');
61
+ }
62
+ if (env.MAX_CLIENTS !== undefined) {
63
+ input.maxClients = Number.parseInt(env.MAX_CLIENTS, 10);
64
+ }
65
+ if (env.CHANNEL_PREFIXES !== undefined) {
66
+ input.channelPrefixes = env.CHANNEL_PREFIXES;
67
+ }
68
+ if (env.OPER_USER !== undefined || env.OPER_PASSWORD !== undefined) {
69
+ // Build the cred object from whatever was supplied; the schema
70
+ // surfaces a `user`/`password` error if only one half was provided.
71
+ input.operCreds = [
72
+ {
73
+ user: env.OPER_USER ?? '',
74
+ password: env.OPER_PASSWORD ?? '',
75
+ },
76
+ ];
77
+ }
78
+ if (env.MAX_CHANNELS_PER_USER !== undefined) {
79
+ input.maxChannelsPerUser = Number.parseInt(env.MAX_CHANNELS_PER_USER, 10);
80
+ }
81
+ if (env.MAX_TARGETS_PER_COMMAND !== undefined) {
82
+ input.maxTargetsPerCommand = Number.parseInt(env.MAX_TARGETS_PER_COMMAND, 10);
83
+ }
84
+ if (env.NICK_LEN !== undefined) {
85
+ input.nickLen = Number.parseInt(env.NICK_LEN, 10);
86
+ }
87
+ if (env.CHANNEL_LEN !== undefined) {
88
+ input.channelLen = Number.parseInt(env.CHANNEL_LEN, 10);
89
+ }
90
+ if (env.TOPIC_LEN !== undefined) {
91
+ input.topicLen = Number.parseInt(env.TOPIC_LEN, 10);
92
+ }
93
+ if (env.MAX_LIST_ENTRIES !== undefined) {
94
+ input.maxListEntries = Number.parseInt(env.MAX_LIST_ENTRIES, 10);
95
+ }
96
+ if (env.QUIT_MESSAGE !== undefined) {
97
+ input.quitMessage = env.QUIT_MESSAGE;
98
+ }
99
+ if (env.SASL_ACCOUNTS !== undefined && env.SASL_ACCOUNTS.length > 0) {
100
+ input.saslAccounts = parseSaslAccountsLines(env.SASL_ACCOUNTS);
101
+ }
102
+ return input;
103
+ }
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
+
125
+ /**
126
+ * Loads and validates the server config from a Cloudflare Workers env.
127
+ *
128
+ * Wraps the shared {@link parseServerConfig} so a single boot-time
129
+ * failure mode covers every adapter: invalid config throws a readable
130
+ * `Error` listing every offending field, surfacing the problem before
131
+ * any connection is accepted.
132
+ */
133
+ export function loadServerConfigFromCfEnv(env: CfConfigEnv): ParsedServerConfig {
134
+ return parseServerConfig(buildConfigInput(env));
135
+ }
@@ -29,14 +29,22 @@
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,
35
36
  type ConnId,
36
37
  type ConnectionState,
38
+ ConsoleLogger,
37
39
  type IdFactory,
40
+ InMemoryAccountStore,
41
+ InMemoryMessageStore,
42
+ LogLevel,
43
+ type Logger,
44
+ type MessageStore,
38
45
  type MotdProvider,
39
46
  type RawLine,
47
+ type SaslAccountCredential,
40
48
  type ServerConfig,
41
49
  SystemClock,
42
50
  UuidIdFactory,
@@ -50,6 +58,7 @@ import { makeCfRuntime } from './cf-runtime.js';
50
58
  import type { CfConnectionHandlers } from './cf-runtime.js';
51
59
  import type { Env } from './env.js';
52
60
  import { PERSISTED_STATE_VERSION, STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
61
+ import type { PersistedConnectionState } from './serialize.js';
53
62
 
54
63
  /** Default PING cadence (ms) — tuned for typical IRC client behavior. */
55
64
  export const DEFAULT_PING_INTERVAL_MS = 60_000;
@@ -90,6 +99,22 @@ export class ConnectionDO extends DurableObject<Env> {
90
99
  * should seed `createdAt` with its receive time.
91
100
  */
92
101
  private channelAccess: PassthroughChannelAccess | undefined;
102
+ /**
103
+ * Per-connection chat-history store backing `draft/chathistory`
104
+ * playback. The minimum-viable in-worker ring buffer is scoped to this
105
+ * connection: messages this connection sends are recorded and replayable
106
+ * by the same connection. A Durable-Object-backed persistent variant
107
+ * (shared across connections, keyed by channel) is a documented
108
+ * follow-up. Lazily constructed so the first frame seeds it.
109
+ */
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;
93
118
  private readonly clock: Clock = SystemClock;
94
119
  private readonly ids: IdFactory = new UuidIdFactory();
95
120
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
@@ -293,6 +318,11 @@ export class ConnectionDO extends DurableObject<Env> {
293
318
  if (this.channelAccess === undefined) {
294
319
  this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
295
320
  }
321
+ // Workers Observability ingests `console.*` directly.
322
+ // Bound `connectionId` so every record the actor emits is filterable
323
+ // per-connection in the Workers dashboard.
324
+ const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
325
+ const accounts = this.accountStore();
296
326
  return new ConnectionActor({
297
327
  state,
298
328
  runtime,
@@ -301,6 +331,9 @@ export class ConnectionDO extends DurableObject<Env> {
301
331
  clock: this.clock,
302
332
  ids: this.ids,
303
333
  motd,
334
+ messages: this.messageStore(),
335
+ ...(accounts !== undefined ? { accounts } : {}),
336
+ logger,
304
337
  });
305
338
  }
306
339
 
@@ -351,6 +384,34 @@ export class ConnectionDO extends DurableObject<Env> {
351
384
  return { lines: () => raw.split('\n') };
352
385
  }
353
386
 
387
+ /**
388
+ * Lazily constructs the per-connection chat-history store. The ring
389
+ * buffer default cap matches the shared `InMemoryMessageStore`; a
390
+ * future persistent variant (DO/KV-backed, keyed by channel) will
391
+ * replace this without touching the actor wiring.
392
+ */
393
+ private messageStore(): MessageStore {
394
+ if (this.messages === undefined) {
395
+ this.messages = new InMemoryMessageStore();
396
+ }
397
+ return this.messages;
398
+ }
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
+
354
415
  // -------------------------------------------------------------------------
355
416
  // Test hooks (only invoked from `runInDurableObject` in tests)
356
417
  // -------------------------------------------------------------------------
@@ -394,6 +455,7 @@ export class ConnectionDO extends DurableObject<Env> {
394
455
  clock: this.clock,
395
456
  ids: this.ids,
396
457
  motd: this.motdProvider(),
458
+ messages: this.messageStore(),
397
459
  });
398
460
  await actor.receiveTextFrame('QUIT\r\n');
399
461
  await this.releaseAndPersist(state);
@@ -436,6 +498,38 @@ export class ConnectionDO extends DurableObject<Env> {
436
498
  const state = await this.loadState();
437
499
  return toSnapshot(state);
438
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
+ }
439
533
  }
440
534
 
441
535
  /** Default MOTD when no `MOTD_LINES` env is supplied. */
@@ -540,3 +634,28 @@ class PassthroughChannelAccess {
540
634
  // in production today. (Removed from the ConnectionDO path because the
541
635
  // authoritative delta application lives in the ChannelDO stub today.)
542
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,
@@ -20,7 +20,7 @@ import type {
20
20
  IrcHarness,
21
21
  IrcHarnessFactory,
22
22
  SpawnClientOptions,
23
- } from './integration/harness.js';
23
+ } from '@serverless-ircd/irc-test-support';
24
24
 
25
25
  /** Default tick for the polling waitFor* implementations (ms). */
26
26
  const POLL_MS = 10;
@@ -57,7 +57,7 @@ export interface CfHarnessEnv {
57
57
  *
58
58
  * ```ts
59
59
  * import { env } from 'cloudflare:test';
60
- * import { runIrcScenarios } from './integration/scenarios.js';
60
+ * import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
61
61
  * import { makeCfHarnessFactory } from './cf-harness';
62
62
  *
63
63
  * runIrcScenarios([makeCfHarnessFactory({ env })]);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Parametrized IRC scenarios against the Cloudflare runtime — 036.
3
3
  *
4
- * Reuses the scenario runner from `./integration/scenarios.js`
4
+ * Reuses the scenario runner from `@serverless-ircd/irc-test-support`
5
5
  * (032) and registers the CF harness factory built on top of the
6
6
  * real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
7
7
  * scenario spawns one or more real WebSockets and drives the full
@@ -26,9 +26,9 @@
26
26
  */
27
27
 
28
28
  import { env, reset } from 'cloudflare:test';
29
+ import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
29
30
  import { afterAll, afterEach, beforeAll } from 'vitest';
30
31
  import { makeCfHarnessFactory } from './cf-harness';
31
- import { runIrcScenarios } from './integration/scenarios.js';
32
32
 
33
33
  declare global {
34
34
  namespace Cloudflare {
@@ -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
  // ---------------------------------------------------------------------------