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
@@ -75,6 +75,49 @@ describe('buildLambdaConfigInput', () => {
75
75
  it('passes QUIT_MESSAGE through', () => {
76
76
  expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
77
77
  });
78
+
79
+ it('parses SASL_ACCOUNTS newline-delimited username:password pairs', () => {
80
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass' });
81
+ expect(out.saslAccounts).toEqual([
82
+ { username: 'alice', password: 'secret' },
83
+ { username: 'bob', password: 'b-pass' },
84
+ ]);
85
+ });
86
+
87
+ it('omits saslAccounts when SASL_ACCOUNTS is empty', () => {
88
+ expect('saslAccounts' in buildLambdaConfigInput({ SASL_ACCOUNTS: '' })).toBe(false);
89
+ });
90
+
91
+ it('omits saslAccounts when SASL_ACCOUNTS is undefined', () => {
92
+ expect('saslAccounts' in buildLambdaConfigInput({})).toBe(false);
93
+ });
94
+
95
+ it('skips blank lines in SASL_ACCOUNTS (trailing newline tolerance)', () => {
96
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\n\n \n' });
97
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
98
+ });
99
+
100
+ it('skips SASL_ACCOUNTS entries with no colon separator', () => {
101
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'nopass\nalice:secret' });
102
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
103
+ });
104
+
105
+ it('skips SASL_ACCOUNTS entries with an empty username (leading colon)', () => {
106
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ':onlypass\nalice:secret' });
107
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
108
+ });
109
+
110
+ it('skips SASL_ACCOUNTS entries with an empty password', () => {
111
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:\nbob:real' });
112
+ expect(out.saslAccounts).toEqual([{ username: 'bob', password: 'real' }]);
113
+ });
114
+
115
+ it('trims whole SASL_ACCOUNTS lines but preserves fields around the colon', () => {
116
+ // Only the line is trimmed; whitespace adjacent to ':' is kept (so a
117
+ // stray space does not silently change the stored username/password).
118
+ const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
119
+ expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
120
+ });
78
121
  });
79
122
 
80
123
  describe('loadServerConfigFromLambdaEnv', () => {
@@ -155,4 +198,16 @@ describe('loadServerConfigFromLambdaEnv', () => {
155
198
  });
156
199
  expect(cfg.quitMessage).toBe('goodbye');
157
200
  });
201
+
202
+ it('round-trips SASL_ACCOUNTS into cfg.saslAccounts end-to-end', () => {
203
+ const cfg = loadServerConfigFromLambdaEnv({
204
+ SERVER_NAME: 's',
205
+ NETWORK_NAME: 'n',
206
+ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass',
207
+ });
208
+ expect(cfg.saslAccounts).toEqual([
209
+ { username: 'alice', password: 'secret' },
210
+ { username: 'bob', password: 'b-pass' },
211
+ ]);
212
+ });
158
213
  });
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Integration tests for QUIT fanout on `$disconnect` (and on the
3
+ * `GoneException` cleanup path).
4
+ *
5
+ * Verifies that when connection A tears down, every peer sharing a
6
+ * channel with A receives the canonical `:A QUIT :reason` line via
7
+ * `managementApi.postToConnection`, and that a gone peer during fanout
8
+ * triggers lazy cleanup instead of crashing.
9
+ */
10
+
11
+ import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
+ import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
+ import {
15
+ DEFAULT_SERVER_CONFIG,
16
+ type Nick,
17
+ type ParsedServerConfig,
18
+ StaticMotdProvider,
19
+ createConnection,
20
+ } from '@serverless-ircd/irc-core';
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+ import { AwsRuntime, cleanupConnection, type PostToConnection } from '../src/aws-runtime.js';
23
+ import { handleDisconnect } from '../src/handlers/disconnect.js';
24
+ import {
25
+ type HandlerDeps,
26
+ __resetHandlerCacheForTests,
27
+ dispatch,
28
+ } from '../src/handlers/index.js';
29
+ import { TABLE_DEFS } from '../src/cdk-table-defs.js';
30
+ import { createDynamoDocumentClient } from '../src/dynamo.js';
31
+ import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
32
+ import type { TablesConfig } from '../src/tables.js';
33
+
34
+ const available = process.env.DDB_AVAILABLE === '1';
35
+ const endpoint = process.env.DYNAMO_ENDPOINT;
36
+
37
+ const SERVER_CONFIG: ParsedServerConfig = {
38
+ ...DEFAULT_SERVER_CONFIG,
39
+ serverName: 'irc.example.com',
40
+ networkName: 'ExampleNet',
41
+ motdLines: [],
42
+ };
43
+
44
+ interface Fixture {
45
+ tables: TablesConfig;
46
+ client: ReturnType<typeof createDynamoDocumentClient>;
47
+ cleanup: () => Promise<void>;
48
+ }
49
+
50
+ let fx: Fixture | null;
51
+
52
+ async function makeFixture(): Promise<Fixture> {
53
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
54
+ const prefix = `q${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
55
+ const tables = Object.fromEntries(
56
+ Object.keys(TABLE_DEFS).map((name) => [name, `${prefix}${name}`]),
57
+ ) as TablesConfig;
58
+ const client = createDynamoDocumentClient({ endpoint });
59
+ for (const [logical, props] of Object.entries(TABLE_DEFS)) {
60
+ const pkName = props.partitionKey?.name;
61
+ const skName = props.sortKey?.name;
62
+ if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
63
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
64
+ { AttributeName: pkName, AttributeType: 'S' },
65
+ ];
66
+ const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
67
+ { AttributeName: pkName, KeyType: 'HASH' },
68
+ ];
69
+ if (skName !== undefined) {
70
+ attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
71
+ keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
72
+ }
73
+ await client.send(
74
+ new CreateTableCommand({
75
+ TableName: `${prefix}${logical}`,
76
+ AttributeDefinitions: attributeDefinitions,
77
+ KeySchema: keySchema,
78
+ BillingMode: 'PAY_PER_REQUEST',
79
+ }),
80
+ );
81
+ }
82
+ return {
83
+ tables,
84
+ client,
85
+ cleanup: async () => {
86
+ for (const logical of Object.keys(TABLE_DEFS)) {
87
+ try {
88
+ await client.send(new DeleteTableCommand({ TableName: `${prefix}${logical}` }));
89
+ } catch {
90
+ // best-effort
91
+ }
92
+ }
93
+ },
94
+ };
95
+ }
96
+
97
+ /** Seed a registered connection with nick/user/host and a channel membership. */
98
+ async function seedConnection(
99
+ client: ReturnType<typeof createDynamoDocumentClient>,
100
+ tables: TablesConfig,
101
+ connId: string,
102
+ nick: string,
103
+ channel: string,
104
+ ): Promise<void> {
105
+ const now = Date.now();
106
+ const state = createConnection({ id: connId, connectedSince: now });
107
+ state.registration = 'registered';
108
+ state.nick = nick as Nick;
109
+ state.user = nick;
110
+ state.host = 'x';
111
+ state.realname = nick;
112
+ state.joinedChannels = new Set([channel as never]);
113
+ state.lastSeen = now;
114
+ await client.send(
115
+ new PutCommand({ TableName: tables.Connections, Item: marshalConnection(state, now) }),
116
+ );
117
+ await client.send(
118
+ new PutCommand({
119
+ TableName: tables.Nicks,
120
+ Item: marshalNick(nick as Nick, connId, now),
121
+ }),
122
+ );
123
+ await client.send(
124
+ new PutCommand({
125
+ TableName: tables.ChannelMembers,
126
+ Item: marshalChannelMember(channel, {
127
+ conn: connId,
128
+ nick: nick as Nick,
129
+ op: false,
130
+ voice: false,
131
+ }),
132
+ }),
133
+ );
134
+ }
135
+
136
+ /** Records every `postToConnection` call keyed by ConnectionId. */
137
+ class RecordingPostToConnection implements PostToConnection {
138
+ readonly sent = new Map<string, string[]>();
139
+ private readonly gone = new Set<string>();
140
+ private readonly errorPeers = new Map<string, Error>();
141
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
142
+ const custom = this.errorPeers.get(input.ConnectionId);
143
+ if (custom !== undefined) throw custom;
144
+ if (this.gone.has(input.ConnectionId)) {
145
+ throw new GoneException({ $metadata: {}, message: 'Gone' });
146
+ }
147
+ const list = this.sent.get(input.ConnectionId) ?? [];
148
+ for (const line of input.Data.split('\r\n')) {
149
+ if (line.length > 0) list.push(line);
150
+ }
151
+ this.sent.set(input.ConnectionId, list);
152
+ return {};
153
+ }
154
+ markGone(connId: string): void {
155
+ this.gone.add(connId);
156
+ }
157
+ markError(connId: string, err: Error): void {
158
+ this.errorPeers.set(connId, err);
159
+ }
160
+ }
161
+
162
+ function probe(
163
+ client: ReturnType<typeof createDynamoDocumentClient>,
164
+ tables: TablesConfig,
165
+ ): AwsRuntime {
166
+ return new AwsRuntime({
167
+ dynamo: client,
168
+ tables,
169
+ connId: '__probe__',
170
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
171
+ managementApi: null,
172
+ });
173
+ }
174
+
175
+ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
176
+ beforeEach(async () => {
177
+ fx = await makeFixture();
178
+ });
179
+ afterEach(async () => {
180
+ if (fx) await fx.cleanup();
181
+ fx = null;
182
+ });
183
+
184
+ it('handleDisconnect fans the QUIT line out to every other channel member', async () => {
185
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
186
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
187
+
188
+ const mgmt = new RecordingPostToConnection();
189
+ await handleDisconnect({
190
+ dynamo: fx!.client,
191
+ tables: fx!.tables,
192
+ connectionId: 'a',
193
+ managementApi: mgmt,
194
+ });
195
+
196
+ // Peer b received the QUIT line for alice.
197
+ const bobReceived = mgmt.sent.get('b') ?? [];
198
+ expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
199
+ // The disconnecting connection never receives its own QUIT.
200
+ expect(mgmt.sent.has('a')).toBe(false);
201
+ });
202
+
203
+ it('handleDisconnect without a managementApi performs no fanout but still cleans up', async () => {
204
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
205
+ await handleDisconnect({
206
+ dynamo: fx!.client,
207
+ tables: fx!.tables,
208
+ connectionId: 'a',
209
+ managementApi: null,
210
+ });
211
+ const p = probe(fx!.client, fx!.tables);
212
+ expect(await p.getConnectionInfo('a')).toBeNull();
213
+ expect(await p.lookupNick('alice' as Nick)).toBeNull();
214
+ });
215
+
216
+ it('a never-registered connection disconnects without fanout (no nick)', async () => {
217
+ const mgmt = new RecordingPostToConnection();
218
+ await handleDisconnect({
219
+ dynamo: fx!.client,
220
+ tables: fx!.tables,
221
+ connectionId: 'nobody',
222
+ managementApi: mgmt,
223
+ });
224
+ expect(mgmt.sent.size).toBe(0);
225
+ });
226
+
227
+ it('fanout to a gone peer triggers lazy cleanup instead of crashing', async () => {
228
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
229
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
230
+
231
+ const mgmt = new RecordingPostToConnection();
232
+ mgmt.markGone('b');
233
+
234
+ await expect(
235
+ handleDisconnect({
236
+ dynamo: fx!.client,
237
+ tables: fx!.tables,
238
+ connectionId: 'a',
239
+ managementApi: mgmt,
240
+ }),
241
+ ).resolves.toBeUndefined();
242
+
243
+ // The gone peer b was lazily cleaned up (Connection row removed).
244
+ const p = probe(fx!.client, fx!.tables);
245
+ expect(await p.getConnectionInfo('b')).toBeNull();
246
+ });
247
+
248
+ it('cleanupConnection directly broadcasts QUIT when given a managementApi', async () => {
249
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
250
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
251
+
252
+ const mgmt = new RecordingPostToConnection();
253
+ await cleanupConnection(fx!.client, fx!.tables, 'a', mgmt);
254
+
255
+ const bobReceived = mgmt.sent.get('b') ?? [];
256
+ expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
257
+ const p = probe(fx!.client, fx!.tables);
258
+ expect(await p.getConnectionInfo('a')).toBeNull();
259
+ });
260
+
261
+ it('the $disconnect dispatch route fans QUIT out to peers via the management API', async () => {
262
+ const deps: HandlerDeps = {
263
+ dynamo: fx!.client,
264
+ tables: fx!.tables,
265
+ serverConfig: SERVER_CONFIG,
266
+ motd: new StaticMotdProvider([]),
267
+ messages: { record() {}, query: () => Promise.resolve([]) } as never,
268
+ managementApi: null,
269
+ };
270
+ // Register alice + bob into the same channel via $default frames.
271
+ const mgmt = new RecordingPostToConnection();
272
+ deps.managementApi = mgmt as never;
273
+ await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'a' } }, deps);
274
+ await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'b' } }, deps);
275
+ await dispatch(
276
+ { requestContext: { routeKey: '$default', connectionId: 'a' }, body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n' },
277
+ deps,
278
+ );
279
+ await dispatch(
280
+ { requestContext: { routeKey: '$default', connectionId: 'b' }, body: 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n' },
281
+ deps,
282
+ );
283
+ mgmt.sent.clear();
284
+
285
+ // alice disconnects.
286
+ await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'a' } }, deps);
287
+
288
+ const bobReceived = mgmt.sent.get('b') ?? [];
289
+ expect(bobReceived.some((l) => /^:alice!alice\S* QUIT :/.test(l))).toBe(true);
290
+ });
291
+
292
+ it('the send() GoneException cleanup path also broadcasts QUIT to the gone peer\'s channels', async () => {
293
+ // alice and carol share #room; bob shares a different channel with carol.
294
+ await seedConnection(fx!.client, fx!.tables, 'alice', 'alice', '#room');
295
+ await seedConnection(fx!.client, fx!.tables, 'carol', 'carol', '#room');
296
+
297
+ // A management double where alice is "gone" (raises GoneException).
298
+ const mgmt = new RecordingPostToConnection();
299
+ mgmt.markGone('alice');
300
+
301
+ const runtime = new AwsRuntime({
302
+ dynamo: fx!.client,
303
+ tables: fx!.tables,
304
+ connId: 'carol',
305
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
306
+ managementApi: mgmt,
307
+ });
308
+
309
+ // carol sends to alice → GoneException → cleanupConnection(alice)
310
+ // → alice's QUIT fans out to carol (the other #room member).
311
+ await runtime.send('alice', [{ text: ':carol!carol@x PRIVMSG alice :hi' } as never]);
312
+
313
+ const carolReceived = mgmt.sent.get('carol') ?? [];
314
+ expect(carolReceived.some((l) => l.startsWith(':alice!alice@x QUIT :'))).toBe(true);
315
+ // alice was cleaned up.
316
+ const p = probe(fx!.client, fx!.tables);
317
+ expect(await p.getConnectionInfo('alice')).toBeNull();
318
+ });
319
+
320
+ it('fanout to a peer that throws a non-Gone error rethrows (no silent swallow)', async () => {
321
+ await seedConnection(fx!.client, fx!.tables, 'a', 'alice', '#room');
322
+ await seedConnection(fx!.client, fx!.tables, 'b', 'bob', '#room');
323
+
324
+ const mgmt = new RecordingPostToConnection();
325
+ mgmt.markError('b', new Error('transient network failure'));
326
+
327
+ await expect(
328
+ handleDisconnect({
329
+ dynamo: fx!.client,
330
+ tables: fx!.tables,
331
+ connectionId: 'a',
332
+ managementApi: mgmt,
333
+ }),
334
+ ).rejects.toThrow('transient network failure');
335
+ });
336
+ });
@@ -9,6 +9,7 @@
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
10
  import {
11
11
  DEFAULT_SERVER_CONFIG,
12
+ InMemoryAccountStore,
12
13
  type ParsedServerConfig,
13
14
  StaticMotdProvider,
14
15
  } from '@serverless-ircd/irc-core';
@@ -383,6 +384,51 @@ describe.skipIf(!available)('handlers', () => {
383
384
  expect(replay).toBeDefined();
384
385
  });
385
386
 
387
+ it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
388
+ await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
389
+ const sink: string[] = [];
390
+ fx!.mgmt.register('c1', (data) => {
391
+ for (const line of data.split('\r\n')) {
392
+ if (line.length > 0) sink.push(line);
393
+ }
394
+ });
395
+ // Swap in a deps with a seeded AccountStore for this dispatch.
396
+ const depsWithAccounts: HandlerDeps = {
397
+ ...fx!.deps,
398
+ accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
399
+ };
400
+ const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
401
+ await dispatch(
402
+ {
403
+ requestContext: { routeKey: '$default', connectionId: 'c1' },
404
+ body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
405
+ },
406
+ depsWithAccounts,
407
+ );
408
+ expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
409
+ expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
410
+ expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
411
+ });
412
+
413
+ it('emits 904 when no AccountStore is bound (regression guard)', async () => {
414
+ await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
415
+ const sink: string[] = [];
416
+ fx!.mgmt.register('c2', (data) => {
417
+ for (const line of data.split('\r\n')) {
418
+ if (line.length > 0) sink.push(line);
419
+ }
420
+ });
421
+ const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
422
+ await dispatch(
423
+ {
424
+ requestContext: { routeKey: '$default', connectionId: 'c2' },
425
+ body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
426
+ },
427
+ fx!.deps,
428
+ );
429
+ expect(sink.some((l) => l.includes(' 904 '))).toBe(true);
430
+ });
431
+
386
432
  it('returns 500 when unmarshalConnection throws on a corrupted row (caught by Lambda)', async () => {
387
433
  // Skip: the catch in handleDefault wraps `actor.receiveTextFrame`,
388
434
  // not `unmarshalConnection`. A corrupted-row test would exercise
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-adapter",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Workers adapter: Durable Objects (ConnectionDO, RegistryDO, ChannelDO) and CfRuntime implementing IrcRuntime",
6
6
  "license": "BSD-3-Clause",
@@ -38,8 +38,10 @@ import {
38
38
  toSnapshot,
39
39
  } from '@serverless-ircd/irc-core';
40
40
  import type { IrcRuntime } from '@serverless-ircd/irc-server';
41
- import type { Env, RegistryRpc } from './env.js';
41
+ import type { ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
42
42
  import { DEFAULT_REGISTRY_SHARDS, registryKeyForNick, shardNick } from './sharding.js';
43
+ import type { PersistedConnectionState } from './serialize.js';
44
+ import { deserialize } from './serialize.js';
43
45
 
44
46
  /** Per-connection transport callbacks the ConnectionDO wires up. */
45
47
  export interface CfConnectionHandlers {
@@ -64,6 +66,10 @@ export interface ConnectionRpc {
64
66
  deliver(lines: RawLine[]): Promise<{ delivered: number }>;
65
67
  /** Returns a serializable snapshot of the connection state. */
66
68
  getConnSnapshot(): Promise<ConnSnapshot | null>;
69
+ /** Returns the full connection state as a serializable DTO. */
70
+ getConnState(): Promise<PersistedConnectionState | null>;
71
+ /** Cross-connection disconnect: closes the WebSocket and runs teardown. */
72
+ disconnect(reason?: string): Promise<void>;
67
73
  }
68
74
 
69
75
  /**
@@ -117,12 +123,8 @@ export class CfRuntime implements IrcRuntime {
117
123
  this.handlers.disconnect(reason);
118
124
  return;
119
125
  }
120
- // Cross-connection disconnect is a teardown signal; we route it via
121
- // the target's ConnectionDO RPC as well. (ConnectionDO doesn't expose
122
- // a dedicated `disconnect` RPC today — the canonical teardown path is
123
- // the WebSocket close, which is owned by the connection itself. We
124
- // therefore no-op for cross-connection disconnects; this preserves
125
- // the IrcRuntime contract without inventing a new RPC.)
126
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
127
+ await (stub as unknown as ConnectionRpc).disconnect(reason);
126
128
  }
127
129
 
128
130
  async sendToNick(
@@ -232,29 +234,37 @@ export class CfRuntime implements IrcRuntime {
232
234
  }
233
235
 
234
236
  async getConnection(conn: ConnId): Promise<ConnectionState | null> {
235
- // CF stores ConnectionState inside each ConnectionDO; the only
236
- // authoritative view this runtime can hand out without a new RPC is
237
- // the local connection's live state. Remote connections return null
238
- // until a dedicated `getConnState` RPC lands alongside the broader
239
- // WHOIS-over-CF work — the actor degrades to "unknown nick" for them.
240
237
  if (conn === this.connId) {
241
238
  return this.handlers.snapshot() ?? null;
242
239
  }
243
- return null;
240
+ const stub = this.env.CONNECTION_DO.get(this.env.CONNECTION_DO.idFromString(conn));
241
+ const dto = await (stub as unknown as ConnectionRpc).getConnState();
242
+ return dto === null ? null : deserialize(dto);
244
243
  }
245
244
 
246
- async getChannelConnections(_chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
247
- // CF has no single source of truth for full ConnectionStates across
248
- // all ConnectionDOs. The WHO command (the only current consumer)
249
- // ships in a later ticket; return an empty map until then.
250
- return new Map();
245
+ async getChannelConnections(chan: ChanName): Promise<ReadonlyMap<ConnId, ConnectionState>> {
246
+ const memberIds = await this.channelRpc(chan).listMembers();
247
+ const out = new Map<ConnId, ConnectionState>();
248
+ await Promise.all(
249
+ memberIds.map(async (id) => {
250
+ const connStub = this.env.CONNECTION_DO.get(
251
+ this.env.CONNECTION_DO.idFromString(id),
252
+ );
253
+ const dto = await (connStub as unknown as ConnectionRpc).getConnState();
254
+ if (dto !== null) {
255
+ out.set(id, deserialize(dto));
256
+ }
257
+ }),
258
+ );
259
+ return out;
251
260
  }
252
261
 
253
262
  async listChannels(): Promise<ChanSnapshot[]> {
254
- // CF has no central channel registry; each ChannelDO is independent.
255
- // The LIST command (the only current consumer) ships in a later
256
- // ticket; return an empty array until then.
257
- return [];
263
+ const names = await this.channelRegistryRpc().list();
264
+ const snapshots = await Promise.all(
265
+ names.map((nameLower) => this.getChannelSnapshot(nameLower)),
266
+ );
267
+ return snapshots.filter((s): s is ChanSnapshot => s !== null);
258
268
  }
259
269
 
260
270
  // -------------------------------------------------------------------------
@@ -278,6 +288,7 @@ export class CfRuntime implements IrcRuntime {
278
288
  broadcast(lines: unknown[], except?: string): Promise<void>;
279
289
  applyChannelDelta(delta: unknown): Promise<void>;
280
290
  getChannelSnapshot(): Promise<unknown>;
291
+ listMembers(): Promise<string[]>;
281
292
  } {
282
293
  const lower = name.toLowerCase();
283
294
  const stub = this.env.CHANNEL_DO.get(this.env.CHANNEL_DO.idFromName(lower));
@@ -285,8 +296,17 @@ export class CfRuntime implements IrcRuntime {
285
296
  broadcast(lines: unknown[], except?: string): Promise<void>;
286
297
  applyChannelDelta(delta: unknown): Promise<void>;
287
298
  getChannelSnapshot(): Promise<unknown>;
299
+ listMembers(): Promise<string[]>;
288
300
  };
289
301
  }
302
+
303
+ /** Returns the channel registry DO RPC stub. */
304
+ private channelRegistryRpc(): ChannelRegistryRpc {
305
+ const stub = this.env.CHANNEL_REGISTRY_DO.get(
306
+ this.env.CHANNEL_REGISTRY_DO.idFromName('channels'),
307
+ );
308
+ return stub as unknown as ChannelRegistryRpc;
309
+ }
290
310
  }
291
311
 
292
312
  /**
@@ -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
  // -------------------------------------------------------------------------