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
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Config loading for the cf-adapter.
3
+ *
4
+ * Verifies the shared `ServerConfigSchema` (from `irc-core`) is consumed
5
+ * by the Cloudflare adapter — both adapters (CF + AWS) build the same
6
+ * shape from their platform-native sources.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { loadServerConfigFromCfEnv } from '../src/config-loader';
11
+
12
+ interface CfEnvLike {
13
+ SERVER_NAME?: string;
14
+ NETWORK_NAME?: string;
15
+ MOTD_LINES?: string;
16
+ MAX_CLIENTS?: string;
17
+ CHANNEL_PREFIXES?: string;
18
+ OPER_USER?: string;
19
+ OPER_PASSWORD?: string;
20
+ MAX_CHANNELS_PER_USER?: string;
21
+ MAX_TARGETS_PER_COMMAND?: string;
22
+ NICK_LEN?: string;
23
+ CHANNEL_LEN?: string;
24
+ TOPIC_LEN?: string;
25
+ MAX_LIST_ENTRIES?: string;
26
+ QUIT_MESSAGE?: string;
27
+ }
28
+
29
+ function baseEnv(): CfEnvLike {
30
+ return { SERVER_NAME: 'irc.cf.example.com', NETWORK_NAME: 'CfNet' };
31
+ }
32
+
33
+ describe('loadServerConfigFromCfEnv — valid env', () => {
34
+ it('returns a parsed config derived from the minimal required vars', () => {
35
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
36
+ expect(cfg.serverName).toBe('irc.cf.example.com');
37
+ expect(cfg.networkName).toBe('CfNet');
38
+ // Defaults still apply.
39
+ expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
40
+ expect(cfg.maxClients).toBeGreaterThan(0);
41
+ expect(cfg.operCreds).toEqual([]);
42
+ });
43
+
44
+ it('splits MOTD_LINES on newlines into motdLines', () => {
45
+ const cfg = loadServerConfigFromCfEnv({
46
+ ...baseEnv(),
47
+ MOTD_LINES: 'line 1\nline 2\nline 3',
48
+ });
49
+ expect(cfg.motdLines).toEqual(['line 1', 'line 2', 'line 3']);
50
+ });
51
+
52
+ it('treats an undefined MOTD_LINES as the default empty MOTD', () => {
53
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
54
+ expect(cfg.motdLines).toEqual([]);
55
+ });
56
+
57
+ it('parses MAX_CLIENTS as an integer', () => {
58
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: '4242' });
59
+ expect(cfg.maxClients).toBe(4242);
60
+ });
61
+
62
+ it('parses CHANNEL_PREFIXES verbatim', () => {
63
+ const cfg = loadServerConfigFromCfEnv({
64
+ ...baseEnv(),
65
+ CHANNEL_PREFIXES: '#&+',
66
+ });
67
+ expect(cfg.channelPrefixes).toBe('#&+');
68
+ });
69
+
70
+ it('builds operCreds from OPER_USER + OPER_PASSWORD when both set', () => {
71
+ const cfg = loadServerConfigFromCfEnv({
72
+ ...baseEnv(),
73
+ OPER_USER: 'admin',
74
+ OPER_PASSWORD: 'hunter2',
75
+ });
76
+ expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
77
+ });
78
+
79
+ it('parses numeric limit overrides from string env vars', () => {
80
+ const cfg = loadServerConfigFromCfEnv({
81
+ ...baseEnv(),
82
+ MAX_CHANNELS_PER_USER: '12',
83
+ MAX_TARGETS_PER_COMMAND: '3',
84
+ NICK_LEN: '20',
85
+ CHANNEL_LEN: '40',
86
+ TOPIC_LEN: '300',
87
+ MAX_LIST_ENTRIES: '7',
88
+ });
89
+ expect(cfg.maxChannelsPerUser).toBe(12);
90
+ expect(cfg.maxTargetsPerCommand).toBe(3);
91
+ expect(cfg.nickLen).toBe(20);
92
+ expect(cfg.channelLen).toBe(40);
93
+ expect(cfg.topicLen).toBe(300);
94
+ expect(cfg.maxListEntries).toBe(7);
95
+ });
96
+
97
+ it('honours a custom QUIT_MESSAGE', () => {
98
+ const cfg = loadServerConfigFromCfEnv({
99
+ ...baseEnv(),
100
+ QUIT_MESSAGE: 'Bye from CF',
101
+ });
102
+ expect(cfg.quitMessage).toBe('Bye from CF');
103
+ });
104
+ });
105
+
106
+ describe('loadServerConfigFromCfEnv — failure modes', () => {
107
+ it('throws when SERVER_NAME is missing', () => {
108
+ expect(() => loadServerConfigFromCfEnv({ NETWORK_NAME: 'CfNet' })).toThrowError(/serverName/u);
109
+ });
110
+
111
+ it('throws when NETWORK_NAME is missing', () => {
112
+ expect(() => loadServerConfigFromCfEnv({ SERVER_NAME: 's' })).toThrowError(/networkName/u);
113
+ });
114
+
115
+ it('throws when MAX_CLIENTS is non-numeric', () => {
116
+ expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: 'lots' })).toThrowError(
117
+ /maxClients/u,
118
+ );
119
+ });
120
+
121
+ it('throws when OPER_USER is set but OPER_PASSWORD is missing', () => {
122
+ expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_USER: 'admin' })).toThrowError(
123
+ /password/u,
124
+ );
125
+ });
126
+
127
+ it('throws when OPER_PASSWORD is set but OPER_USER is missing', () => {
128
+ expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_PASSWORD: 'p' })).toThrowError(
129
+ /user/u,
130
+ );
131
+ });
132
+
133
+ it('aggregates multiple errors into a single readable Error', () => {
134
+ try {
135
+ loadServerConfigFromCfEnv({
136
+ MAX_CLIENTS: 'oops',
137
+ OPER_USER: 'admin',
138
+ });
139
+ expect.fail('expected throw');
140
+ } catch (err) {
141
+ expect(err).toBeInstanceOf(Error);
142
+ const msg = (err as Error).message;
143
+ expect(msg).toMatch(/invalid server config/iu);
144
+ // Both bad fields are surfaced.
145
+ expect(msg).toMatch(/serverName/u);
146
+ expect(msg).toMatch(/maxClients/u);
147
+ }
148
+ });
149
+ });
@@ -123,6 +123,23 @@ async function register(client: IrcWsClient, nick: string): Promise<void> {
123
123
  await client.waitForMessage((l) => l.includes(' 001 '));
124
124
  }
125
125
 
126
+ /**
127
+ * Negotiates the chathistory cap set then registers. Mirrors the
128
+ * pre-registration `CAP REQ` → `NICK`/`USER` → `CAP END` flow a real
129
+ * client uses.
130
+ */
131
+ async function registerWithChathistory(client: IrcWsClient, nick: string): Promise<void> {
132
+ // `echo-message` is included so tests can deterministically wait for a
133
+ // PRIVMSG to be recorded before issuing a follow-up CHATHISTORY query
134
+ // (otherwise the two async webSocketMessage handlers race).
135
+ client.send('CAP REQ :draft/chathistory server-time message-tags batch echo-message\r\n');
136
+ await client.waitForMessage((l) => / CAP \S+ ACK :/.test(l));
137
+ client.send(`NICK ${nick}\r\n`);
138
+ client.send(`USER ${nick} 0 * :${nick}\r\n`);
139
+ client.send('CAP END\r\n');
140
+ await client.waitForMessage((l) => l.includes(' 001 '));
141
+ }
142
+
126
143
  /**
127
144
  * Forces the ConnectionDO to drop its in-memory cache of the
128
145
  * {@link ConnectionState}, simulating wake-from-hibernation. The next
@@ -324,6 +341,71 @@ describe('ConnectionDO — alarms', () => {
324
341
  });
325
342
  });
326
343
 
344
+ describe('ConnectionDO — chathistory end-to-end', () => {
345
+ it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
346
+ const client = await connect('conn-chathist-1');
347
+ await registerWithChathistory(client, 'cf-alice');
348
+
349
+ client.send('JOIN #cf-chathist\r\n');
350
+ await client.waitForMessage((l) => l.includes(' 366 ') && l.includes('#cf-chathist'));
351
+
352
+ client.send('PRIVMSG #cf-chathist :recorded via cf\r\n');
353
+ // Wait for the echo (echo-message cap) so the PRIVMSG is recorded
354
+ // before the CHATHISTORY query arrives as a separate frame.
355
+ await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
356
+
357
+ client.send('CHATHISTORY LATEST #cf-chathist * 10\r\n');
358
+ const batchOpen = await client.waitForMessage((l) =>
359
+ /^BATCH \+\S+ chathistory #cf-chathist/.test(l),
360
+ );
361
+ expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #cf-chathist/);
362
+
363
+ await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
364
+
365
+ client.ws.close();
366
+ });
367
+ });
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
+
327
409
  // ---------------------------------------------------------------------------
328
410
  // End of file
329
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.1.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",
@@ -13,6 +13,9 @@
13
13
  */
14
14
 
15
15
  import {
16
+ type AdmissionConfig,
17
+ type AdmissionDecision,
18
+ AdmissionStats,
16
19
  type ChanName,
17
20
  type ChanSnapshot,
18
21
  type ChannelDelta,
@@ -24,7 +27,9 @@ import {
24
27
  type Nick,
25
28
  type RawLine,
26
29
  applyChannelDelta as applyDelta,
30
+ caseFold,
27
31
  createChannel,
32
+ decideAdmission,
28
33
  toChannelSnapshot,
29
34
  toSnapshot as toConnSnapshot,
30
35
  } from '@serverless-ircd/irc-core';
@@ -41,10 +46,24 @@ export interface ConnectionHandlers {
41
46
  interface TrackedConnection {
42
47
  state: ConnectionState;
43
48
  handlers: ConnectionHandlers;
49
+ /**
50
+ * Optional admission record id paired with this connection at register
51
+ * time. When set, {@link InMemoryRuntime.unregisterConnection} releases
52
+ * it so per-IP / per-user connection counters stay accurate.
53
+ */
54
+ admissionRecordId?: string;
44
55
  }
45
56
 
46
- function lower(s: string): string {
47
- return s.toLowerCase();
57
+ export interface InMemoryRuntimeOptions {
58
+ clock: Clock;
59
+ /**
60
+ * Connection-admission policy. When omitted the admission gate is
61
+ * disabled — every {@link InMemoryRuntime.admitConnection} call returns
62
+ * an admit decision. Adapters that want to enforce per-IP / per-user /
63
+ * rate limits pass the same {@link AdmissionConfig} derived from the
64
+ * deployment's `ServerConfig`.
65
+ */
66
+ readonly admission?: AdmissionConfig;
48
67
  }
49
68
 
50
69
  export class InMemoryRuntime implements IrcRuntime {
@@ -52,9 +71,53 @@ export class InMemoryRuntime implements IrcRuntime {
52
71
  private readonly connections = new Map<ConnId, TrackedConnection>();
53
72
  private readonly channels = new Map<string, ChannelState>();
54
73
  private readonly nicks = new Map<string, ConnId>();
74
+ private readonly admissionStats: AdmissionStats | undefined;
75
+ private readonly admissionConfig: AdmissionConfig | undefined;
55
76
 
56
- constructor(opts: { clock: Clock }) {
77
+ constructor(opts: InMemoryRuntimeOptions) {
57
78
  this.clock = opts.clock;
79
+ if (opts.admission !== undefined) {
80
+ this.admissionConfig = opts.admission;
81
+ this.admissionStats = new AdmissionStats(this.clock);
82
+ }
83
+ }
84
+
85
+ // ------------------------------------------------------------------
86
+ // Admission gate (NOT part of the IrcRuntime port — adapter only)
87
+ // ------------------------------------------------------------------
88
+
89
+ /**
90
+ * Decides whether a new connection should be admitted under the
91
+ * configured {@link AdmissionConfig}. Returns `{ ok: true, recordId }`
92
+ * when the connection may proceed; the caller MUST then call
93
+ * {@link commitAdmission} exactly once with the returned recordId.
94
+ *
95
+ * Returns `{ ok: false, reason }` when the connection must be refused.
96
+ *
97
+ * When no admission policy was supplied at construction, this method
98
+ * unconditionally admits (the gate is disabled).
99
+ */
100
+ admitConnection(ip: string, user: string | undefined): AdmissionDecision {
101
+ if (this.admissionConfig === undefined || this.admissionStats === undefined) {
102
+ return { ok: true, recordId: '' };
103
+ }
104
+ return decideAdmission(ip, user, this.admissionStats, this.admissionConfig);
105
+ }
106
+
107
+ /**
108
+ * Records an accepted admission. MUST be called exactly once per
109
+ * successful {@link admitConnection}. No-op when admission is disabled.
110
+ */
111
+ commitAdmission(ip: string, user: string | undefined, recordId: string): void {
112
+ this.admissionStats?.recordAdmission(ip, user, recordId);
113
+ }
114
+
115
+ /**
116
+ * Releases a previously-committed admission record. Safe to call with
117
+ * an unknown id. No-op when admission is disabled.
118
+ */
119
+ releaseAdmission(recordId: string): void {
120
+ this.admissionStats?.release(recordId);
58
121
  }
59
122
 
60
123
  // ------------------------------------------------------------------
@@ -65,13 +128,31 @@ export class InMemoryRuntime implements IrcRuntime {
65
128
  * Tracks a connection and its transport handlers. The runtime keeps a
66
129
  * reference to `state`; the actor may continue mutating it in place and
67
130
  * the runtime will observe the changes via {@link getConnectionInfo}.
131
+ *
132
+ * @param admissionRecordId Optional record id returned by
133
+ * {@link admitConnection} + {@link commitAdmission}. When supplied,
134
+ * {@link unregisterConnection} releases it automatically so the
135
+ * per-IP / per-user connection counters stay accurate without the
136
+ * adapter remembering to call {@link releaseAdmission} itself.
68
137
  */
69
- registerConnection(state: ConnectionState, handlers: ConnectionHandlers): void {
70
- this.connections.set(state.id, { state, handlers });
138
+ registerConnection(
139
+ state: ConnectionState,
140
+ handlers: ConnectionHandlers,
141
+ admissionRecordId?: string,
142
+ ): void {
143
+ const tracked: TrackedConnection =
144
+ admissionRecordId !== undefined
145
+ ? { state, handlers, admissionRecordId }
146
+ : { state, handlers };
147
+ this.connections.set(state.id, tracked);
71
148
  }
72
149
 
73
150
  /** Stops tracking a connection. Idempotent. Does NOT release its nick. */
74
151
  unregisterConnection(conn: ConnId): void {
152
+ const tracked = this.connections.get(conn);
153
+ if (tracked !== undefined && tracked.admissionRecordId !== undefined) {
154
+ this.releaseAdmission(tracked.admissionRecordId);
155
+ }
75
156
  this.connections.delete(conn);
76
157
  }
77
158
 
@@ -90,7 +171,7 @@ export class InMemoryRuntime implements IrcRuntime {
90
171
  * return the same object.
91
172
  */
92
173
  getOrCreateChannel(name: ChanName): ChannelState {
93
- const key = lower(name);
174
+ const key = caseFold('rfc1459', name);
94
175
  const existing = this.channels.get(key);
95
176
  if (existing !== undefined) return existing;
96
177
  const created = createChannel({
@@ -102,6 +183,16 @@ export class InMemoryRuntime implements IrcRuntime {
102
183
  return created;
103
184
  }
104
185
 
186
+ /**
187
+ * Peek-only channel lookup that does NOT create the channel. Returns the
188
+ * authoritative {@link ChannelState} when it exists, or `undefined`.
189
+ * Used by query-only commands (CHATHISTORY) that must distinguish a
190
+ * missing channel from an empty one.
191
+ */
192
+ getChannel(name: ChanName): ChannelState | undefined {
193
+ return this.channels.get(caseFold('rfc1459', name));
194
+ }
195
+
105
196
  // ------------------------------------------------------------------
106
197
  // IrcRuntime — transport
107
198
  // ------------------------------------------------------------------
@@ -113,7 +204,7 @@ export class InMemoryRuntime implements IrcRuntime {
113
204
  }
114
205
 
115
206
  async broadcast(chan: ChanName, lines: RawLine[], except?: ConnId): Promise<void> {
116
- const channel = this.channels.get(lower(chan));
207
+ const channel = this.channels.get(caseFold('rfc1459', chan));
117
208
  if (channel === undefined) return;
118
209
  for (const member of channel.members.keys()) {
119
210
  if (member === except) continue;
@@ -135,7 +226,7 @@ export class InMemoryRuntime implements IrcRuntime {
135
226
  lines: RawLine[],
136
227
  notFoundLines?: RawLine[],
137
228
  ): Promise<void> {
138
- const owner = this.nicks.get(lower(nick));
229
+ const owner = this.nicks.get(caseFold('rfc1459', nick));
139
230
  if (owner !== undefined) {
140
231
  const conn = this.connections.get(owner);
141
232
  if (conn !== undefined) {
@@ -156,7 +247,7 @@ export class InMemoryRuntime implements IrcRuntime {
156
247
  // ------------------------------------------------------------------
157
248
 
158
249
  async reserveNick(nick: Nick, conn: ConnId): Promise<{ ok: true } | { ok: false }> {
159
- const key = lower(nick);
250
+ const key = caseFold('rfc1459', nick);
160
251
  const existing = this.nicks.get(key);
161
252
  if (existing !== undefined && existing !== conn) {
162
253
  return { ok: false };
@@ -166,8 +257,8 @@ export class InMemoryRuntime implements IrcRuntime {
166
257
  }
167
258
 
168
259
  async changeNick(conn: ConnId, oldNick: Nick, newNick: Nick): Promise<boolean> {
169
- const newKey = lower(newNick);
170
- const oldKey = lower(oldNick);
260
+ const newKey = caseFold('rfc1459', newNick);
261
+ const oldKey = caseFold('rfc1459', oldNick);
171
262
  const owner = this.nicks.get(newKey);
172
263
  if (owner !== undefined && owner !== conn) {
173
264
  return false;
@@ -182,7 +273,7 @@ export class InMemoryRuntime implements IrcRuntime {
182
273
  }
183
274
 
184
275
  async releaseNick(nick: Nick): Promise<void> {
185
- this.nicks.delete(lower(nick));
276
+ this.nicks.delete(caseFold('rfc1459', nick));
186
277
  }
187
278
 
188
279
  // ------------------------------------------------------------------
@@ -195,7 +286,7 @@ export class InMemoryRuntime implements IrcRuntime {
195
286
  // replace the stored object so subsequent snapshots see the new data
196
287
  // while any outstanding references to the prior state stay coherent.
197
288
  const next = applyDelta(channel, delta);
198
- this.channels.set(lower(chan), next);
289
+ this.channels.set(caseFold('rfc1459', chan), next);
199
290
  }
200
291
 
201
292
  // ------------------------------------------------------------------
@@ -203,7 +294,7 @@ export class InMemoryRuntime implements IrcRuntime {
203
294
  // ------------------------------------------------------------------
204
295
 
205
296
  async lookupNick(nick: Nick): Promise<ConnId | null> {
206
- return this.nicks.get(lower(nick)) ?? null;
297
+ return this.nicks.get(caseFold('rfc1459', nick)) ?? null;
207
298
  }
208
299
 
209
300
  async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
@@ -223,13 +314,13 @@ export class InMemoryRuntime implements IrcRuntime {
223
314
  }
224
315
 
225
316
  async getChannelSnapshot(chan: ChanName): Promise<ChanSnapshot | null> {
226
- const channel = this.channels.get(lower(chan));
317
+ const channel = this.channels.get(caseFold('rfc1459', chan));
227
318
  if (channel === undefined) return null;
228
319
  return toChannelSnapshot(channel);
229
320
  }
230
321
 
231
322
  async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
232
- const channel = this.channels.get(lower(chan));
323
+ const channel = this.channels.get(caseFold('rfc1459', chan));
233
324
  const out = new Map<ConnId, ConnectionState>();
234
325
  if (channel === undefined) return out;
235
326
  for (const connId of channel.members.keys()) {
@@ -6,4 +6,4 @@
6
6
  * and AWS runtimes.
7
7
  */
8
8
  export { InMemoryRuntime } from './in-memory-runtime.js';
9
- export type { ConnectionHandlers } from './in-memory-runtime.js';
9
+ export type { ConnectionHandlers, InMemoryRuntimeOptions } from './in-memory-runtime.js';