serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -1,12 +1,16 @@
1
- import { mockClient } from 'aws-sdk-client-mock';
2
1
  import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
3
- import { type SaslPayload, DEFAULT_SERVER_CONFIG, type ParsedServerConfig } from '@serverless-ircd/irc-core';
2
+ import {
3
+ DEFAULT_SERVER_CONFIG,
4
+ type ParsedServerConfig,
5
+ type SaslPayload,
6
+ } from '@serverless-ircd/irc-core';
7
+ import { mockClient } from 'aws-sdk-client-mock';
4
8
  import { describe, expect, it } from 'vitest';
5
9
  import {
6
10
  DynamoAccountStore,
11
+ type HashedAccountCredential,
7
12
  bindAccountStore,
8
13
  hashAccountCredential,
9
- type HashedAccountCredential,
10
14
  putAccountCredential,
11
15
  resolveAccountStore,
12
16
  } from '../src/account-store';
@@ -28,7 +32,7 @@ describe('bindAccountStore', () => {
28
32
  it('returns an AccountStore that accepts valid PLAIN creds', () => {
29
33
  const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
30
34
  expect(store).toBeDefined();
31
- const out = store!.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
35
+ const out = store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'secret' });
32
36
  expect(out).toEqual({ ok: true, account: 'alice' });
33
37
  });
34
38
 
@@ -41,8 +45,9 @@ describe('bindAccountStore', () => {
41
45
  });
42
46
 
43
47
  it('rejects wrong credentials', () => {
44
- const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]))!;
45
- expect(store.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
48
+ const store = bindAccountStore(configWith([{ username: 'alice', password: 'secret' }]));
49
+ expect(store).toBeDefined();
50
+ expect(store?.verify('PLAIN', { kind: 'PLAIN', username: 'alice', password: 'wrong' }).ok).toBe(
46
51
  false,
47
52
  );
48
53
  });
@@ -177,16 +182,14 @@ describe('resolveAccountStore', () => {
177
182
  const store = await resolveAccountStore(
178
183
  ddb as unknown as DynamoDBDocumentClient,
179
184
  'Accounts',
180
- fallback === undefined ? undefined : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
185
+ fallback === undefined
186
+ ? undefined
187
+ : { ...DEFAULT_SERVER_CONFIG, saslAccounts: [{ username: 'envuser', password: 'env-pw' }] },
181
188
  );
182
189
  expect(store).toBeDefined();
183
- expect(
184
- store!.verify('PLAIN', plainPayload('alice', 's3cret')).ok,
185
- ).toBe(true);
190
+ expect(store?.verify('PLAIN', plainPayload('alice', 's3cret')).ok).toBe(true);
186
191
  // Config account does NOT verify (table is authoritative).
187
- expect(
188
- store!.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok,
189
- ).toBe(false);
192
+ expect(store?.verify('PLAIN', plainPayload('envuser', 'env-pw')).ok).toBe(false);
190
193
  });
191
194
 
192
195
  it('falls back to config-seeded store when the table is empty', async () => {
@@ -199,9 +202,7 @@ describe('resolveAccountStore', () => {
199
202
  configWith([{ username: 'bob', password: 'pw' }]),
200
203
  );
201
204
  expect(store).toBeDefined();
202
- expect(
203
- store!.verify('PLAIN', plainPayload('bob', 'pw')).ok,
204
- ).toBe(true);
205
+ expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
205
206
  });
206
207
 
207
208
  it('falls back to config-seeded store when the scan throws', async () => {
@@ -214,9 +215,7 @@ describe('resolveAccountStore', () => {
214
215
  configWith([{ username: 'bob', password: 'pw' }]),
215
216
  );
216
217
  expect(store).toBeDefined();
217
- expect(
218
- store!.verify('PLAIN', plainPayload('bob', 'pw')).ok,
219
- ).toBe(true);
218
+ expect(store?.verify('PLAIN', plainPayload('bob', 'pw')).ok).toBe(true);
220
219
  });
221
220
 
222
221
  it('returns undefined when table is empty and no config accounts', async () => {
@@ -252,7 +251,7 @@ describe('putAccountCredential', () => {
252
251
 
253
252
  const calls = ddb.commandCalls(PutCommand);
254
253
  expect(calls).toHaveLength(1);
255
- const item = calls[0]!.args[0].input.Item as HashedAccountCredential;
254
+ const item = calls[0]?.args[0].input.Item as HashedAccountCredential;
256
255
  expect(item.account).toBe('alice');
257
256
  expect(item.algorithm).toBe('scrypt');
258
257
  expect(item.salt).toBe(entry.salt);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure unit tests for the AWS `$connect` admission policy
3
+ * (`src/admission.ts`). No DynamoDB — these exercise every branch of
4
+ * {@link decideConnectAdmission} directly so the per-IP branch is covered
5
+ * even though the AWS handler does not yet feed it a `perIp` count.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { decideConnectAdmission } from '../src/admission.js';
10
+
11
+ describe('decideConnectAdmission', () => {
12
+ it('admits when the total is below the cap and no per-IP limit is set', () => {
13
+ const outcome = decideConnectAdmission({ total: 0 }, { maxClients: 2 });
14
+ expect(outcome).toEqual({ admitted: true });
15
+ });
16
+
17
+ it('admits when the total is exactly one below the cap', () => {
18
+ expect(decideConnectAdmission({ total: 1 }, { maxClients: 2 })).toEqual({
19
+ admitted: true,
20
+ });
21
+ });
22
+
23
+ it('rejects with 429 once the total reaches the cap', () => {
24
+ expect(decideConnectAdmission({ total: 2 }, { maxClients: 2 })).toEqual({
25
+ admitted: false,
26
+ statusCode: 429,
27
+ reason: 'server full',
28
+ });
29
+ });
30
+
31
+ it('rejects with 429 when the total exceeds the cap', () => {
32
+ expect(decideConnectAdmission({ total: 9999 }, { maxClients: 10 })).toEqual({
33
+ admitted: false,
34
+ statusCode: 429,
35
+ reason: 'server full',
36
+ });
37
+ });
38
+
39
+ it('admits when a per-IP limit is set but no per-IP count is supplied', () => {
40
+ expect(
41
+ decideConnectAdmission({ total: 0 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
42
+ ).toEqual({ admitted: true });
43
+ });
44
+
45
+ it('admits when the per-IP count is below the per-IP limit', () => {
46
+ expect(
47
+ decideConnectAdmission({ total: 0, perIp: 2 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
48
+ ).toEqual({ admitted: true });
49
+ });
50
+
51
+ it('rejects with 429 once the per-IP count reaches the per-IP limit', () => {
52
+ expect(
53
+ decideConnectAdmission({ total: 0, perIp: 3 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
54
+ ).toEqual({
55
+ admitted: false,
56
+ statusCode: 429,
57
+ reason: 'too many connections from this IP',
58
+ });
59
+ });
60
+
61
+ it('checks the global cap before the per-IP cap', () => {
62
+ expect(
63
+ decideConnectAdmission({ total: 10, perIp: 3 }, { maxClients: 10, maxConnectionsPerIp: 3 }),
64
+ ).toEqual({
65
+ admitted: false,
66
+ statusCode: 429,
67
+ reason: 'server full',
68
+ });
69
+ });
70
+ });
@@ -57,7 +57,8 @@ const endpoint = process.env.DYNAMO_ENDPOINT;
57
57
  * unavailable so `describe.skipIf` short-circuits before any test runs.
58
58
  */
59
59
  export const awsHarnessFactory: IrcHarnessFactory = Object.freeze({
60
- name: 'aws',
60
+ name: 'aws+ws',
61
+ transport: 'ws',
61
62
  async create() {
62
63
  return new AwsHarness();
63
64
  },
@@ -344,6 +345,17 @@ class AwsClientHandle implements ClientHarness {
344
345
  await this.actor.receiveTextFrame(`${line}\r\n`);
345
346
  }
346
347
 
348
+ async feed(chunk: string): Promise<void> {
349
+ if (this.actor === undefined) {
350
+ throw new Error(`client ${this.id} has no bound actor`);
351
+ }
352
+ if (this.closed) return;
353
+ // Routes the raw chunk through the bound transport (the WS text-frame
354
+ // transport by default; a future aws+tcp factory via the NLB+Lambda
355
+ // byte-stream path would inject a TcpByteStreamTransport).
356
+ await this.actor.receive(chunk);
357
+ }
358
+
347
359
  async waitForLine(
348
360
  predicate: (line: string) => boolean,
349
361
  timeoutMs: number = DEFAULT_TIMEOUT_MS,
@@ -76,6 +76,26 @@ describe('buildLambdaConfigInput', () => {
76
76
  expect(buildLambdaConfigInput({ QUIT_MESSAGE: 'bye' }).quitMessage).toBe('bye');
77
77
  });
78
78
 
79
+ it('threads SERVER_VERSION into serverVersion', () => {
80
+ expect(buildLambdaConfigInput({ SERVER_VERSION: '1.2.3' }).serverVersion).toBe('1.2.3');
81
+ });
82
+
83
+ it('passes a non-numeric CREATED_AT through verbatim', () => {
84
+ expect(buildLambdaConfigInput({ CREATED_AT: '2024-06-01' }).createdAt).toBe('2024-06-01');
85
+ });
86
+
87
+ it('parses a numeric CREATED_AT string into an epoch-ms number', () => {
88
+ expect(buildLambdaConfigInput({ CREATED_AT: '1700000000000' }).createdAt).toBe(
89
+ 1_700_000_000_000,
90
+ );
91
+ });
92
+
93
+ it('omits serverVersion/createdAt when their env vars are unset', () => {
94
+ const out = buildLambdaConfigInput({});
95
+ expect('serverVersion' in out).toBe(false);
96
+ expect('createdAt' in out).toBe(false);
97
+ });
98
+
79
99
  it('parses SASL_ACCOUNTS newline-delimited username:password pairs', () => {
80
100
  const out = buildLambdaConfigInput({ SASL_ACCOUNTS: 'alice:secret\nbob:b-pass' });
81
101
  expect(out.saslAccounts).toEqual([
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Unit tests for `handleConnect` that exercise the DynamoDB plumbing
3
+ * branches the integration suite cannot reach cheaply — namely the
4
+ * paginated `Scan COUNT` loop (a real DynamoDB Local table fits in a
5
+ * single page) and the `Count ?? 0` defensive fallback. A hand-rolled
6
+ * stub client stands in for `DynamoDBDocumentClient`; the admission
7
+ * policy itself is covered by `admission.test.ts`.
8
+ */
9
+
10
+ import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
11
+ import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { handleConnect } from '../src/handlers/connect.js';
14
+ import type { TablesConfig } from '../src/tables.js';
15
+
16
+ const TABLES: TablesConfig = {
17
+ Connections: 'Connections',
18
+ ChannelMeta: 'ChannelMeta',
19
+ ChannelMembers: 'ChannelMembers',
20
+ Nicks: 'Nicks',
21
+ Accounts: 'Accounts',
22
+ };
23
+
24
+ interface Stub {
25
+ dynamo: DynamoDBDocumentClient;
26
+ puts: number;
27
+ }
28
+
29
+ /** Builds a stub client that serves the supplied `Scan` pages in order. */
30
+ function makeStub(pages: Array<Record<string, unknown>>): Stub {
31
+ let scanIdx = 0;
32
+ const calls = { puts: 0 };
33
+ const dynamo = {
34
+ send: async (cmd: unknown) => {
35
+ if (cmd instanceof ScanCommand) {
36
+ return pages[scanIdx++] ?? { Count: 0 };
37
+ }
38
+ if (cmd instanceof PutCommand) {
39
+ calls.puts++;
40
+ return {};
41
+ }
42
+ throw new Error('unexpected command');
43
+ },
44
+ } as unknown as DynamoDBDocumentClient;
45
+ return {
46
+ dynamo,
47
+ get puts() {
48
+ return calls.puts;
49
+ },
50
+ };
51
+ }
52
+
53
+ describe('handleConnect admission plumbing', () => {
54
+ it('sums Scan COUNT across pages and admits (writes the row) when below the cap', async () => {
55
+ const stub = makeStub([{ Count: 1, LastEvaluatedKey: { connectionId: 'page-1' } }, {}]);
56
+ const outcome = await handleConnect({
57
+ dynamo: stub.dynamo,
58
+ tables: TABLES,
59
+ connectionId: 'c-paged',
60
+ now: 42,
61
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
62
+ });
63
+ expect(outcome).toEqual({ admitted: true });
64
+ expect(stub.puts).toBe(1);
65
+ });
66
+
67
+ it('does not write a row when the count reaches the cap', async () => {
68
+ const stub = makeStub([{ Count: 5 }]);
69
+ const outcome = await handleConnect({
70
+ dynamo: stub.dynamo,
71
+ tables: TABLES,
72
+ connectionId: 'c-full',
73
+ serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
74
+ });
75
+ expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
76
+ expect(stub.puts).toBe(0);
77
+ });
78
+ });
@@ -19,15 +19,11 @@ import {
19
19
  createConnection,
20
20
  } from '@serverless-ircd/irc-core';
21
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';
22
+ import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
29
23
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
30
24
  import { createDynamoDocumentClient } from '../src/dynamo.js';
25
+ import { handleDisconnect } from '../src/handlers/disconnect.js';
26
+ import { type HandlerDeps, __resetHandlerCacheForTests, dispatch } from '../src/handlers/index.js';
31
27
  import { marshalChannelMember, marshalConnection, marshalNick } from '../src/serialize.js';
32
28
  import type { TablesConfig } from '../src/tables.js';
33
29
 
@@ -49,6 +45,11 @@ interface Fixture {
49
45
 
50
46
  let fx: Fixture | null;
51
47
 
48
+ function requireFx(): Fixture {
49
+ if (!fx) throw new Error('test fixture not initialized');
50
+ return fx;
51
+ }
52
+
52
53
  async function makeFixture(): Promise<Fixture> {
53
54
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
54
55
  const prefix = `q${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -182,13 +183,13 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
182
183
  });
183
184
 
184
185
  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');
186
+ await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
187
+ await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
187
188
 
188
189
  const mgmt = new RecordingPostToConnection();
189
190
  await handleDisconnect({
190
- dynamo: fx!.client,
191
- tables: fx!.tables,
191
+ dynamo: requireFx().client,
192
+ tables: requireFx().tables,
192
193
  connectionId: 'a',
193
194
  managementApi: mgmt,
194
195
  });
@@ -201,14 +202,14 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
201
202
  });
202
203
 
203
204
  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 seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
205
206
  await handleDisconnect({
206
- dynamo: fx!.client,
207
- tables: fx!.tables,
207
+ dynamo: requireFx().client,
208
+ tables: requireFx().tables,
208
209
  connectionId: 'a',
209
210
  managementApi: null,
210
211
  });
211
- const p = probe(fx!.client, fx!.tables);
212
+ const p = probe(requireFx().client, requireFx().tables);
212
213
  expect(await p.getConnectionInfo('a')).toBeNull();
213
214
  expect(await p.lookupNick('alice' as Nick)).toBeNull();
214
215
  });
@@ -216,8 +217,8 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
216
217
  it('a never-registered connection disconnects without fanout (no nick)', async () => {
217
218
  const mgmt = new RecordingPostToConnection();
218
219
  await handleDisconnect({
219
- dynamo: fx!.client,
220
- tables: fx!.tables,
220
+ dynamo: requireFx().client,
221
+ tables: requireFx().tables,
221
222
  connectionId: 'nobody',
222
223
  managementApi: mgmt,
223
224
  });
@@ -225,43 +226,43 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
225
226
  });
226
227
 
227
228
  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');
229
+ await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
230
+ await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
230
231
 
231
232
  const mgmt = new RecordingPostToConnection();
232
233
  mgmt.markGone('b');
233
234
 
234
235
  await expect(
235
236
  handleDisconnect({
236
- dynamo: fx!.client,
237
- tables: fx!.tables,
237
+ dynamo: requireFx().client,
238
+ tables: requireFx().tables,
238
239
  connectionId: 'a',
239
240
  managementApi: mgmt,
240
241
  }),
241
242
  ).resolves.toBeUndefined();
242
243
 
243
244
  // The gone peer b was lazily cleaned up (Connection row removed).
244
- const p = probe(fx!.client, fx!.tables);
245
+ const p = probe(requireFx().client, requireFx().tables);
245
246
  expect(await p.getConnectionInfo('b')).toBeNull();
246
247
  });
247
248
 
248
249
  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');
250
+ await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
251
+ await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
251
252
 
252
253
  const mgmt = new RecordingPostToConnection();
253
- await cleanupConnection(fx!.client, fx!.tables, 'a', mgmt);
254
+ await cleanupConnection(requireFx().client, requireFx().tables, 'a', mgmt);
254
255
 
255
256
  const bobReceived = mgmt.sent.get('b') ?? [];
256
257
  expect(bobReceived).toContain(':alice!alice@x QUIT :Client Quit');
257
- const p = probe(fx!.client, fx!.tables);
258
+ const p = probe(requireFx().client, requireFx().tables);
258
259
  expect(await p.getConnectionInfo('a')).toBeNull();
259
260
  });
260
261
 
261
262
  it('the $disconnect dispatch route fans QUIT out to peers via the management API', async () => {
262
263
  const deps: HandlerDeps = {
263
- dynamo: fx!.client,
264
- tables: fx!.tables,
264
+ dynamo: requireFx().client,
265
+ tables: requireFx().tables,
265
266
  serverConfig: SERVER_CONFIG,
266
267
  motd: new StaticMotdProvider([]),
267
268
  messages: { record() {}, query: () => Promise.resolve([]) } as never,
@@ -273,11 +274,17 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
273
274
  await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'a' } }, deps);
274
275
  await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'b' } }, deps);
275
276
  await dispatch(
276
- { requestContext: { routeKey: '$default', connectionId: 'a' }, body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n' },
277
+ {
278
+ requestContext: { routeKey: '$default', connectionId: 'a' },
279
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #room\r\n',
280
+ },
277
281
  deps,
278
282
  );
279
283
  await dispatch(
280
- { requestContext: { routeKey: '$default', connectionId: 'b' }, body: 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n' },
284
+ {
285
+ requestContext: { routeKey: '$default', connectionId: 'b' },
286
+ body: 'NICK bob\r\nUSER bob 0 * :Bob\r\nJOIN #room\r\n',
287
+ },
281
288
  deps,
282
289
  );
283
290
  mgmt.sent.clear();
@@ -289,18 +296,18 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
289
296
  expect(bobReceived.some((l) => /^:alice!alice\S* QUIT :/.test(l))).toBe(true);
290
297
  });
291
298
 
292
- it('the send() GoneException cleanup path also broadcasts QUIT to the gone peer\'s channels', async () => {
299
+ it("the send() GoneException cleanup path also broadcasts QUIT to the gone peer's channels", async () => {
293
300
  // 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');
301
+ await seedConnection(requireFx().client, requireFx().tables, 'alice', 'alice', '#room');
302
+ await seedConnection(requireFx().client, requireFx().tables, 'carol', 'carol', '#room');
296
303
 
297
304
  // A management double where alice is "gone" (raises GoneException).
298
305
  const mgmt = new RecordingPostToConnection();
299
306
  mgmt.markGone('alice');
300
307
 
301
308
  const runtime = new AwsRuntime({
302
- dynamo: fx!.client,
303
- tables: fx!.tables,
309
+ dynamo: requireFx().client,
310
+ tables: requireFx().tables,
304
311
  connId: 'carol',
305
312
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
306
313
  managementApi: mgmt,
@@ -313,21 +320,21 @@ describe.skipIf(!available)('$disconnect QUIT fanout', () => {
313
320
  const carolReceived = mgmt.sent.get('carol') ?? [];
314
321
  expect(carolReceived.some((l) => l.startsWith(':alice!alice@x QUIT :'))).toBe(true);
315
322
  // alice was cleaned up.
316
- const p = probe(fx!.client, fx!.tables);
323
+ const p = probe(requireFx().client, requireFx().tables);
317
324
  expect(await p.getConnectionInfo('alice')).toBeNull();
318
325
  });
319
326
 
320
327
  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');
328
+ await seedConnection(requireFx().client, requireFx().tables, 'a', 'alice', '#room');
329
+ await seedConnection(requireFx().client, requireFx().tables, 'b', 'bob', '#room');
323
330
 
324
331
  const mgmt = new RecordingPostToConnection();
325
332
  mgmt.markError('b', new Error('transient network failure'));
326
333
 
327
334
  await expect(
328
335
  handleDisconnect({
329
- dynamo: fx!.client,
330
- tables: fx!.tables,
336
+ dynamo: requireFx().client,
337
+ tables: requireFx().tables,
331
338
  connectionId: 'a',
332
339
  managementApi: mgmt,
333
340
  }),
@@ -42,6 +42,11 @@ interface Fixture {
42
42
 
43
43
  let fx: Fixture | null;
44
44
 
45
+ function requireFx(): Fixture {
46
+ if (!fx) throw new Error('test fixture not initialized');
47
+ return fx;
48
+ }
49
+
45
50
  async function makeFixture(): Promise<Fixture> {
46
51
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
47
52
  const prefix = `g${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -136,7 +141,7 @@ describe.skipIf(!available)('GoneException cleanup', () => {
136
141
  });
137
142
 
138
143
  it('send() to a gone connection triggers cleanupConnection and deletes Connections/Nicks/Members rows', async () => {
139
- await seedConnection(fx!.client, fx!.tables, 'gone', 'bob', '#room');
144
+ await seedConnection(requireFx().client, requireFx().tables, 'gone', 'bob', '#room');
140
145
 
141
146
  // A self-connection runtime that tries to `send` to 'gone'.
142
147
  const mgmt: PostToConnection = {
@@ -149,8 +154,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
149
154
  };
150
155
 
151
156
  const runtime = new AwsRuntime({
152
- dynamo: fx!.client,
153
- tables: fx!.tables,
157
+ dynamo: requireFx().client,
158
+ tables: requireFx().tables,
154
159
  connId: 'self',
155
160
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
156
161
  managementApi: mgmt,
@@ -159,8 +164,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
159
164
  await runtime.send('gone', [{ text: ':self PRIVMSG bob :hi' } as never]);
160
165
 
161
166
  const probe = new AwsRuntime({
162
- dynamo: fx!.client,
163
- tables: fx!.tables,
167
+ dynamo: requireFx().client,
168
+ tables: requireFx().tables,
164
169
  connId: '__probe__',
165
170
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
166
171
  managementApi: null,
@@ -172,15 +177,15 @@ describe.skipIf(!available)('GoneException cleanup', () => {
172
177
  });
173
178
 
174
179
  it('send() to a non-gone error rethrows', async () => {
175
- await seedConnection(fx!.client, fx!.tables, 'present', 'carol', '#room');
180
+ await seedConnection(requireFx().client, requireFx().tables, 'present', 'carol', '#room');
176
181
  const mgmt: PostToConnection = {
177
182
  async postToConnection() {
178
183
  throw new Error('network down');
179
184
  },
180
185
  };
181
186
  const runtime = new AwsRuntime({
182
- dynamo: fx!.client,
183
- tables: fx!.tables,
187
+ dynamo: requireFx().client,
188
+ tables: requireFx().tables,
184
189
  connId: 'self',
185
190
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
186
191
  managementApi: mgmt,
@@ -190,8 +195,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
190
195
  ).rejects.toThrow('network down');
191
196
  // The target row should NOT have been cleaned up.
192
197
  const probe = new AwsRuntime({
193
- dynamo: fx!.client,
194
- tables: fx!.tables,
198
+ dynamo: requireFx().client,
199
+ tables: requireFx().tables,
195
200
  connId: '__probe__',
196
201
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
197
202
  managementApi: null,
@@ -200,12 +205,12 @@ describe.skipIf(!available)('GoneException cleanup', () => {
200
205
  });
201
206
 
202
207
  it('cleanupConnection is idempotent when called twice', async () => {
203
- await seedConnection(fx!.client, fx!.tables, 'gone2', 'dave', '#room');
204
- await cleanupConnection(fx!.client, fx!.tables, 'gone2', null);
205
- await cleanupConnection(fx!.client, fx!.tables, 'gone2', null);
208
+ await seedConnection(requireFx().client, requireFx().tables, 'gone2', 'dave', '#room');
209
+ await cleanupConnection(requireFx().client, requireFx().tables, 'gone2', null);
210
+ await cleanupConnection(requireFx().client, requireFx().tables, 'gone2', null);
206
211
  const probe = new AwsRuntime({
207
- dynamo: fx!.client,
208
- tables: fx!.tables,
212
+ dynamo: requireFx().client,
213
+ tables: requireFx().tables,
209
214
  connId: '__probe__',
210
215
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
211
216
  managementApi: null,
@@ -215,20 +220,20 @@ describe.skipIf(!available)('GoneException cleanup', () => {
215
220
 
216
221
  it('cleanupConnection is safe when the connection row is missing', async () => {
217
222
  await expect(
218
- cleanupConnection(fx!.client, fx!.tables, 'never-existed', null),
223
+ cleanupConnection(requireFx().client, requireFx().tables, 'never-existed', null),
219
224
  ).resolves.toBeUndefined();
220
225
  });
221
226
 
222
227
  it('broadcast() skips and cleans up gone members', async () => {
223
- await seedConnection(fx!.client, fx!.tables, 'gone3', 'eve', '#room');
228
+ await seedConnection(requireFx().client, requireFx().tables, 'gone3', 'eve', '#room');
224
229
  const mgmt: PostToConnection = {
225
230
  async postToConnection() {
226
231
  throw new GoneException({ $metadata: {}, message: 'Gone' });
227
232
  },
228
233
  };
229
234
  const runtime = new AwsRuntime({
230
- dynamo: fx!.client,
231
- tables: fx!.tables,
235
+ dynamo: requireFx().client,
236
+ tables: requireFx().tables,
232
237
  connId: 'self',
233
238
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
234
239
  managementApi: mgmt,
@@ -236,8 +241,8 @@ describe.skipIf(!available)('GoneException cleanup', () => {
236
241
  // `broadcast` iterates ChannelMembers and skips `except`.
237
242
  await runtime.broadcast('#room' as never, [{ text: ':self PRIVMSG #room :hi' } as never]);
238
243
  const probe = new AwsRuntime({
239
- dynamo: fx!.client,
240
- tables: fx!.tables,
244
+ dynamo: requireFx().client,
245
+ tables: requireFx().tables,
241
246
  connId: '__probe__',
242
247
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
243
248
  managementApi: null,
@@ -246,18 +251,18 @@ describe.skipIf(!available)('GoneException cleanup', () => {
246
251
  });
247
252
 
248
253
  it('send() with managementApi=null is a silent no-op (no throw, no cleanup)', async () => {
249
- await seedConnection(fx!.client, fx!.tables, 'present2', 'frank', '#room');
254
+ await seedConnection(requireFx().client, requireFx().tables, 'present2', 'frank', '#room');
250
255
  const runtime = new AwsRuntime({
251
- dynamo: fx!.client,
252
- tables: fx!.tables,
256
+ dynamo: requireFx().client,
257
+ tables: requireFx().tables,
253
258
  connId: 'self',
254
259
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
255
260
  managementApi: null,
256
261
  });
257
262
  await runtime.send('present2', [{ text: ':self PRIVMSG frank :hi' } as never]);
258
263
  const probe = new AwsRuntime({
259
- dynamo: fx!.client,
260
- tables: fx!.tables,
264
+ dynamo: requireFx().client,
265
+ tables: requireFx().tables,
261
266
  connId: '__probe__',
262
267
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
263
268
  managementApi: null,