serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -1,13 +1,11 @@
1
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`.
2
+ * Unit tests for `handleConnect`. A hand-rolled stub client stands in for
3
+ * `DynamoDBDocumentClient`, simulating the atomic admission counter
4
+ * (`UpdateItem` increment/decrement on the meta row) and the `Put` of the
5
+ * new connection row. The admission policy itself is covered by
6
+ * `admission.test.ts`; the counter primitives by `connection-counter.test.ts`.
8
7
  */
9
-
10
- import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
8
+ import { type DynamoDBDocumentClient, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
11
9
  import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
12
10
  import { describe, expect, it } from 'vitest';
13
11
  import { handleConnect } from '../src/handlers/connect.js';
@@ -27,53 +25,114 @@ interface Stub {
27
25
  puts: number;
28
26
  /** Most recently PutCommand Item (undefined when no put happened). */
29
27
  lastPutItem: Record<string, unknown> | undefined;
28
+ /** Current simulated counter value. */
29
+ count: number;
30
+ increments: number;
31
+ decrements: number;
32
+ /** Commands issued, in order (for asserting no Scan is used). */
33
+ commands: string[];
30
34
  }
31
35
 
32
- /** Builds a stub client that serves the supplied `Scan` pages in order. */
33
- function makeStub(pages: Array<Record<string, unknown>>): Stub {
34
- let scanIdx = 0;
35
- const calls = { puts: 0 };
36
+ /**
37
+ * Builds a stub client with an initial admission counter value. The stub
38
+ * honours the floor-at-0 decrement condition the real handler relies on.
39
+ */
40
+ function makeStub(initialCount: number): Stub {
41
+ const state = { count: initialCount, puts: 0, increments: 0, decrements: 0 };
36
42
  let lastPutItem: Record<string, unknown> | undefined;
43
+ const commands: string[] = [];
37
44
  const dynamo = {
38
45
  send: async (cmd: unknown) => {
39
- if (cmd instanceof ScanCommand) {
40
- return pages[scanIdx++] ?? { Count: 0 };
46
+ if (cmd instanceof UpdateCommand) {
47
+ const expr = (cmd.input as { UpdateExpression?: string }).UpdateExpression ?? '';
48
+ const cond = (cmd.input as { ConditionExpression?: string }).ConditionExpression;
49
+ commands.push('Update');
50
+ if (expr.includes('+ :one')) {
51
+ state.count += 1;
52
+ state.increments += 1;
53
+ return { Attributes: { count: state.count } };
54
+ }
55
+ if (expr.includes('- :one')) {
56
+ // Floor at 0: when a condition is present and count <= 0, reject.
57
+ if (cond !== undefined && state.count <= 0) {
58
+ throw Object.assign(new Error('conditional request failed'), {
59
+ name: 'ConditionalCheckFailedException',
60
+ });
61
+ }
62
+ if (state.count > 0) {
63
+ state.count -= 1;
64
+ state.decrements += 1;
65
+ }
66
+ return { Attributes: { count: state.count } };
67
+ }
68
+ throw new Error(`unexpected Update expression: ${expr}`);
41
69
  }
42
70
  if (cmd instanceof PutCommand) {
43
- calls.puts++;
71
+ commands.push('Put');
72
+ state.puts += 1;
44
73
  lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
45
74
  return {};
46
75
  }
47
- throw new Error('unexpected command');
76
+ commands.push('Unknown');
77
+ throw new Error(`unexpected command (Scan must not be used): ${String(cmd)}`);
48
78
  },
49
79
  } as unknown as DynamoDBDocumentClient;
50
80
  return {
51
81
  dynamo,
52
82
  get puts() {
53
- return calls.puts;
83
+ return state.puts;
54
84
  },
55
85
  get lastPutItem() {
56
86
  return lastPutItem;
57
87
  },
88
+ get count() {
89
+ return state.count;
90
+ },
91
+ get increments() {
92
+ return state.increments;
93
+ },
94
+ get decrements() {
95
+ return state.decrements;
96
+ },
97
+ get commands() {
98
+ return commands;
99
+ },
58
100
  };
59
101
  }
60
102
 
61
103
  describe('handleConnect admission plumbing', () => {
62
- it('sums Scan COUNT across pages and admits (writes the row) when below the cap', async () => {
63
- const stub = makeStub([{ Count: 1, LastEvaluatedKey: { connectionId: 'page-1' } }, {}]);
104
+ it('admits (writes the row) and reserves a counter slot when below the cap', async () => {
105
+ const stub = makeStub(0);
64
106
  const outcome = await handleConnect({
65
107
  dynamo: stub.dynamo,
66
108
  tables: TABLES,
67
- connectionId: 'c-paged',
109
+ connectionId: 'c-admit',
68
110
  now: 42,
69
111
  serverConfig: makeTestServerConfig({ maxClients: 5 }),
70
112
  });
71
113
  expect(outcome).toEqual({ admitted: true, subprotocol: null });
72
114
  expect(stub.puts).toBe(1);
115
+ expect(stub.increments).toBe(1);
116
+ expect(stub.decrements).toBe(0);
117
+ expect(stub.count).toBe(1);
118
+ });
119
+
120
+ it('uses only O(1) UpdateItem for admission (no full-table Scan)', async () => {
121
+ const stub = makeStub(0);
122
+ await handleConnect({
123
+ dynamo: stub.dynamo,
124
+ tables: TABLES,
125
+ connectionId: 'c-noop',
126
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
127
+ });
128
+ // Every command must be Update or Put — a Scan would throw via the
129
+ // stub's Unknown branch, and the recorded commands prove the shape.
130
+ expect(stub.commands.every((c) => c === 'Update' || c === 'Put')).toBe(true);
131
+ expect(stub.commands).toContain('Update');
73
132
  });
74
133
 
75
134
  it('does not write a row when the count reaches the cap', async () => {
76
- const stub = makeStub([{ Count: 5 }]);
135
+ const stub = makeStub(5);
77
136
  const outcome = await handleConnect({
78
137
  dynamo: stub.dynamo,
79
138
  tables: TABLES,
@@ -83,11 +142,70 @@ describe('handleConnect admission plumbing', () => {
83
142
  expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
84
143
  expect(stub.puts).toBe(0);
85
144
  });
145
+
146
+ it('rolls back the counter reservation when admission rejects', async () => {
147
+ const stub = makeStub(5);
148
+ await handleConnect({
149
+ dynamo: stub.dynamo,
150
+ tables: TABLES,
151
+ connectionId: 'c-rollback',
152
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
153
+ });
154
+ expect(stub.increments).toBe(1);
155
+ expect(stub.decrements).toBe(1); // reservation released
156
+ expect(stub.count).toBe(5); // back to the starting count
157
+ });
158
+
159
+ it('rolls back the reservation when the row Put fails', async () => {
160
+ // Stub whose Put throws; count starts below cap so admission passes.
161
+ let lastPutItem: Record<string, unknown> | undefined;
162
+ const state = { count: 0, increments: 0, decrements: 0 };
163
+ const dynamo = {
164
+ send: async (cmd: unknown) => {
165
+ if (cmd instanceof UpdateCommand) {
166
+ const expr = (cmd.input as { UpdateExpression?: string }).UpdateExpression ?? '';
167
+ if (expr.includes('+ :one')) {
168
+ state.count += 1;
169
+ state.increments += 1;
170
+ return { Attributes: { count: state.count } };
171
+ }
172
+ if (state.count > 0) {
173
+ state.count -= 1;
174
+ state.decrements += 1;
175
+ }
176
+ return { Attributes: { count: state.count } };
177
+ }
178
+ if (cmd instanceof PutCommand) {
179
+ lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
180
+ throw new Error('Put failed');
181
+ }
182
+ throw new Error('unexpected');
183
+ },
184
+ } as unknown as DynamoDBDocumentClient;
185
+ await expect(
186
+ handleConnect({
187
+ dynamo,
188
+ tables: TABLES,
189
+ connectionId: 'c-putfail',
190
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
191
+ }),
192
+ ).rejects.toThrow('Put failed');
193
+ expect(state.increments).toBe(1);
194
+ expect(state.decrements).toBe(1); // released despite the failed Put
195
+ expect(state.count).toBe(0);
196
+ void lastPutItem;
197
+ });
86
198
  });
87
199
 
88
200
  describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
89
- it('selects binary.ircv3.net, echoes it, and persists a spec-binary mode row', async () => {
90
- const stub = makeStub([{ Count: 0 }]);
201
+ it('falls back to legacy when only binary.ircv3.net is offered (API Gateway cannot transport binary frames)', async () => {
202
+ // API Gateway WebSocket APIs carry TEXT frames only; agreeing to
203
+ // binary.ircv3.net makes APIGW close the connection with code 1003
204
+ // ("Binary is not supported") the instant it is negotiated. A client
205
+ // that offers only binary is therefore downgraded to legacy (no
206
+ // subprotocol, no wsMode column) rather than being handed a binary
207
+ // agreement APIGW cannot honour.
208
+ const stub = makeStub(0);
91
209
  const outcome = await handleConnect({
92
210
  dynamo: stub.dynamo,
93
211
  tables: TABLES,
@@ -96,14 +214,14 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
96
214
  secWebSocketProtocol: 'binary.ircv3.net',
97
215
  serverConfig: makeTestServerConfig({ maxClients: 5 }),
98
216
  });
99
- expect(outcome).toEqual({ admitted: true, subprotocol: 'binary.ircv3.net' });
217
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
100
218
  expect(stub.puts).toBe(1);
101
219
  const item = stub.lastPutItem as MarshalledConnection | undefined;
102
- expect(item?.wsMode).toBe('spec-binary');
220
+ expect(item?.wsMode).toBeUndefined();
103
221
  });
104
222
 
105
223
  it('selects text.ircv3.net and persists a spec-text mode row', async () => {
106
- const stub = makeStub([{ Count: 0 }]);
224
+ const stub = makeStub(0);
107
225
  const outcome = await handleConnect({
108
226
  dynamo: stub.dynamo,
109
227
  tables: TABLES,
@@ -117,21 +235,29 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
117
235
  expect(item?.wsMode).toBe('spec-text');
118
236
  });
119
237
 
120
- it('honours client-preference order when both subprotocols are offered', async () => {
121
- const stub = makeStub([{ Count: 0 }]);
238
+ it('selects text.ircv3.net even when binary.ircv3.net is offered first (API Gateway is text-only)', async () => {
239
+ // Reproduces the 1003 close from production: the IRCv3 relay
240
+ // (tcp-ws-forwarder) offers `binary.ircv3.net, text.ircv3.net` (binary
241
+ // first by preference). The generic core selector would agree to
242
+ // binary and APIGW immediately closes with code 1003
243
+ // "Binary is not supported". On the APIGW transport the handler MUST
244
+ // select text.ircv3.net regardless of client-preference order.
245
+ const stub = makeStub(0);
122
246
  const outcome = await handleConnect({
123
247
  dynamo: stub.dynamo,
124
248
  tables: TABLES,
125
249
  connectionId: 'c-pref',
126
250
  now: 42,
127
- secWebSocketProtocol: 'text.ircv3.net, binary.ircv3.net',
251
+ secWebSocketProtocol: 'binary.ircv3.net, text.ircv3.net',
128
252
  serverConfig: makeTestServerConfig({ maxClients: 5 }),
129
253
  });
130
254
  expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
255
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
256
+ expect(item?.wsMode).toBe('spec-text');
131
257
  });
132
258
 
133
259
  it('falls back to legacy (null subprotocol, no mode column) when no offer is present', async () => {
134
- const stub = makeStub([{ Count: 0 }]);
260
+ const stub = makeStub(0);
135
261
  const outcome = await handleConnect({
136
262
  dynamo: stub.dynamo,
137
263
  tables: TABLES,
@@ -145,7 +271,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
145
271
  });
146
272
 
147
273
  it('falls back to legacy when only unsupported subprotocols are offered', async () => {
148
- const stub = makeStub([{ Count: 0 }]);
274
+ const stub = makeStub(0);
149
275
  const outcome = await handleConnect({
150
276
  dynamo: stub.dynamo,
151
277
  tables: TABLES,
@@ -160,7 +286,7 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
160
286
  });
161
287
 
162
288
  it('does not negotiate a subprotocol when the admission gate rejects', async () => {
163
- const stub = makeStub([{ Count: 5 }]);
289
+ const stub = makeStub(5);
164
290
  const outcome = await handleConnect({
165
291
  dynamo: stub.dynamo,
166
292
  tables: TABLES,
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Unit tests for the O(1) admission counter (`connection-counter.ts`).
3
+ *
4
+ * A hand-rolled stub stands in for `DynamoDBDocumentClient` so the atomic
5
+ * increment/decrement `UpdateItem` shapes and the floor-at-0 behaviour are
6
+ * verified without DynamoDB Local.
7
+ */
8
+ import { type DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';
9
+ import { describe, expect, it } from 'vitest';
10
+ import {
11
+ CONNECTION_COUNT_ATTR,
12
+ CONNECTION_COUNT_META_ID,
13
+ decrementConnectionCount,
14
+ incrementConnectionCount,
15
+ } from '../src/connection-counter.js';
16
+
17
+ interface Stub {
18
+ dynamo: DynamoDBDocumentClient;
19
+ /** Current simulated counter value (`undefined` = meta row absent). */
20
+ count: number | undefined;
21
+ updates: Array<{ setExpr: string; conditionExpr?: string }>;
22
+ }
23
+
24
+ function makeStub(count: number | undefined): Stub {
25
+ const state = { count };
26
+ const updates: Array<{ setExpr: string; conditionExpr?: string }> = [];
27
+ const dynamo = {
28
+ send: async (cmd: unknown) => {
29
+ if (cmd instanceof UpdateCommand) {
30
+ const input = cmd.input as {
31
+ UpdateExpression?: string;
32
+ ConditionExpression?: string;
33
+ };
34
+ updates.push({
35
+ setExpr: input.UpdateExpression ?? '',
36
+ ...(input.ConditionExpression !== undefined
37
+ ? { conditionExpr: input.ConditionExpression }
38
+ : {}),
39
+ });
40
+ // Honour the floor-at-0 condition the real handler relies on.
41
+ if (
42
+ input.ConditionExpression !== undefined &&
43
+ (state.count === undefined || state.count <= 0)
44
+ ) {
45
+ throw Object.assign(new Error('conditional request failed'), {
46
+ name: 'ConditionalCheckFailedException',
47
+ });
48
+ }
49
+ // Apply increment / decrement by inspecting the expression.
50
+ const expr = input.UpdateExpression ?? '';
51
+ if (expr.includes('+ :one')) {
52
+ state.count = (state.count ?? 0) + 1;
53
+ } else if (expr.includes('- :one')) {
54
+ state.count = (state.count ?? 0) - 1;
55
+ }
56
+ return { Attributes: { [CONNECTION_COUNT_ATTR]: state.count } };
57
+ }
58
+ throw new Error('unexpected command');
59
+ },
60
+ } as unknown as DynamoDBDocumentClient;
61
+ return {
62
+ dynamo,
63
+ get count() {
64
+ return state.count;
65
+ },
66
+ get updates() {
67
+ return updates;
68
+ },
69
+ };
70
+ }
71
+
72
+ describe('incrementConnectionCount', () => {
73
+ it('issues a single UpdateItem on the meta row and returns the new value', async () => {
74
+ const stub = makeStub(4);
75
+ const result = await incrementConnectionCount(stub.dynamo, 'Connections');
76
+ expect(result).toBe(5);
77
+ expect(stub.updates).toHaveLength(1);
78
+ expect(stub.count).toBe(5);
79
+ });
80
+
81
+ it('creates the meta row on first use (if_not_exists from 0)', async () => {
82
+ const stub = makeStub(undefined);
83
+ const result = await incrementConnectionCount(stub.dynamo, 'Connections');
84
+ expect(result).toBe(1);
85
+ expect(stub.count).toBe(1);
86
+ // The expression must use if_not_exists so an absent row seeds at 0.
87
+ expect(stub.updates[0]?.setExpr).toContain('if_not_exists');
88
+ });
89
+
90
+ it('targets the sentinel meta row key and the count attribute', async () => {
91
+ const stub = makeStub(0);
92
+ await incrementConnectionCount(stub.dynamo, 'Connections');
93
+ // The stub applied the increment; the contract is the sentinel key +
94
+ // count attr names are used (verified via the ExpressionAttributeNames).
95
+ expect(stub.count).toBe(1);
96
+ expect(CONNECTION_COUNT_META_ID).toContain('__meta:');
97
+ });
98
+
99
+ it('returns 0 when the response carries no numeric count (defensive)', async () => {
100
+ // A malformed/empty response must not crash the connect path; the
101
+ // handler treats a missing count as a 0 reservation so admission still
102
+ // proceeds conservatively rather than throwing.
103
+ const dynamo = {
104
+ send: async () => ({ Attributes: { count: 'not-a-number' } }),
105
+ } as unknown as DynamoDBDocumentClient;
106
+ await expect(incrementConnectionCount(dynamo, 'Connections')).resolves.toBe(0);
107
+ });
108
+
109
+ it('returns 0 when the response carries no Attributes at all', async () => {
110
+ const dynamo = {
111
+ send: async () => ({}),
112
+ } as unknown as DynamoDBDocumentClient;
113
+ await expect(incrementConnectionCount(dynamo, 'Connections')).resolves.toBe(0);
114
+ });
115
+ });
116
+
117
+ describe('decrementConnectionCount', () => {
118
+ it('decrements the counter by one', async () => {
119
+ const stub = makeStub(3);
120
+ await decrementConnectionCount(stub.dynamo, 'Connections');
121
+ expect(stub.count).toBe(2);
122
+ });
123
+
124
+ it('floors at 0 (condition prevents a negative count)', async () => {
125
+ const stub = makeStub(0);
126
+ await decrementConnectionCount(stub.dynamo, 'Connections');
127
+ expect(stub.count).toBe(0); // unchanged — condition rejected the update
128
+ });
129
+
130
+ it('is a silent no-op when the meta row is absent', async () => {
131
+ const stub = makeStub(undefined);
132
+ await expect(decrementConnectionCount(stub.dynamo, 'Connections')).resolves.toBeUndefined();
133
+ expect(stub.count).toBeUndefined();
134
+ });
135
+
136
+ it('never throws (teardown must not fail on counter drift)', async () => {
137
+ const dynamo = {
138
+ send: async () => {
139
+ throw new Error('network exploded');
140
+ },
141
+ } as unknown as DynamoDBDocumentClient;
142
+ await expect(decrementConnectionCount(dynamo, 'Connections')).resolves.toBeUndefined();
143
+ });
144
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * DynamoDB-backed integration tests for `DynamoServicesStore`.
3
+ *
4
+ * Exercises the real DynamoDB round-trip that the pure unit suite
5
+ * (`dynamo-services-store-unit.test.ts`) cannot: every services mutator
6
+ * enqueues a write-behind op, `flush()` drains it into the `Services`
7
+ * table, and a fresh `loadDynamoServicesStore` against the same table
8
+ * rehydrates the snapshot. This is the services-state persistence
9
+ * contract for the AWS adapter — a cold start must not lose services state.
10
+ *
11
+ * Gated on DynamoDB Local availability (same `DDB_AVAILABLE` flag as
12
+ * `account-store-dynamo.test.ts`).
13
+ */
14
+
15
+ import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
16
+ import { FakeClock } from '@serverless-ircd/irc-core';
17
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
+ import { createDynamoDocumentClient } from '../src/dynamo';
19
+ import { loadDynamoServicesStore } from '../src/dynamo-services-store';
20
+
21
+ const available = process.env.DDB_AVAILABLE === '1';
22
+ const endpoint = process.env.DYNAMO_ENDPOINT;
23
+
24
+ const NOW = 1_700_000_000_000;
25
+
26
+ interface Fx {
27
+ tableName: string;
28
+ client: ReturnType<typeof createDynamoDocumentClient>;
29
+ cleanup: () => Promise<void>;
30
+ }
31
+
32
+ let fx: Fx | null = null;
33
+
34
+ function requireFx(): Fx {
35
+ if (!fx) throw new Error('test fixture not initialized');
36
+ return fx;
37
+ }
38
+
39
+ async function makeFx(): Promise<Fx> {
40
+ if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
41
+ const tableName = `svc-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
42
+ const client = createDynamoDocumentClient({ endpoint });
43
+ await client.send(
44
+ new CreateTableCommand({
45
+ TableName: tableName,
46
+ AttributeDefinitions: [
47
+ { AttributeName: 'pk', AttributeType: 'S' },
48
+ { AttributeName: 'sk', AttributeType: 'S' },
49
+ ],
50
+ KeySchema: [
51
+ { AttributeName: 'pk', KeyType: 'HASH' },
52
+ { AttributeName: 'sk', KeyType: 'RANGE' },
53
+ ],
54
+ BillingMode: 'PAY_PER_REQUEST',
55
+ }),
56
+ );
57
+ return {
58
+ tableName,
59
+ client,
60
+ cleanup: async () => {
61
+ try {
62
+ await client.send(new DeleteTableCommand({ TableName: tableName }));
63
+ } catch {
64
+ // Idempotent.
65
+ }
66
+ },
67
+ };
68
+ }
69
+
70
+ describe.skipIf(!available)('DynamoServicesStore — real DynamoDB round-trip', () => {
71
+ beforeEach(async () => {
72
+ fx = await makeFx();
73
+ });
74
+ afterEach(async () => {
75
+ if (fx) await fx.cleanup();
76
+ fx = null;
77
+ });
78
+
79
+ it('persists a NickServ registration and verifies after reload (register→identify→SASL)', async () => {
80
+ const store = await loadDynamoServicesStore(
81
+ requireFx().client,
82
+ requireFx().tableName,
83
+ new FakeClock(NOW),
84
+ );
85
+ expect(store).toBeDefined();
86
+ store?.registerNick('alice', 'hunter2', 'a@example.com');
87
+ await store?.flush();
88
+
89
+ const reloaded = await loadDynamoServicesStore(
90
+ requireFx().client,
91
+ requireFx().tableName,
92
+ new FakeClock(NOW),
93
+ );
94
+ expect(reloaded).toBeDefined();
95
+ expect(reloaded?.isRegisteredNick('Alice')).toBe(true);
96
+ expect(reloaded?.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
97
+ });
98
+
99
+ it('channel registration survives a reload (persists across a deploy)', async () => {
100
+ const store = await loadDynamoServicesStore(
101
+ requireFx().client,
102
+ requireFx().tableName,
103
+ new FakeClock(NOW),
104
+ );
105
+ store?.registerChannel('#chan', 'alice');
106
+ store?.setChannelAccess('#chan', 'bob', 5);
107
+ await store?.flush();
108
+
109
+ const reloaded = await loadDynamoServicesStore(
110
+ requireFx().client,
111
+ requireFx().tableName,
112
+ new FakeClock(NOW),
113
+ );
114
+ expect(reloaded?.getChannelFounder('#chan')).toBe('alice');
115
+ expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(5);
116
+ });
117
+
118
+ it('delivers a queued memo on reconnect after identify', async () => {
119
+ const store = await loadDynamoServicesStore(
120
+ requireFx().client,
121
+ requireFx().tableName,
122
+ new FakeClock(NOW),
123
+ );
124
+ const id = store?.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: NOW });
125
+ await store?.flush();
126
+
127
+ const reloaded = await loadDynamoServicesStore(
128
+ requireFx().client,
129
+ requireFx().tableName,
130
+ new FakeClock(NOW),
131
+ );
132
+ expect(reloaded).toBeDefined();
133
+ if (!reloaded) throw new Error('reloaded services store failed to load');
134
+ const memos = reloaded.listMemos('alice');
135
+ expect(memos).toHaveLength(1);
136
+ expect(memos[0]?.id).toBe(id);
137
+ expect(memos[0]?.body).toBe('hi');
138
+ });
139
+
140
+ it('drops a channel subtree (channel + access + levels) on dropChannel', async () => {
141
+ const store = await loadDynamoServicesStore(
142
+ requireFx().client,
143
+ requireFx().tableName,
144
+ new FakeClock(NOW),
145
+ );
146
+ store?.registerChannel('#chan', 'alice');
147
+ store?.setChannelAccess('#chan', 'bob', 5);
148
+ store?.setChannelLevel('#chan', 'AUTOOP', 7);
149
+ await store?.flush();
150
+ const mid = await loadDynamoServicesStore(
151
+ requireFx().client,
152
+ requireFx().tableName,
153
+ new FakeClock(NOW),
154
+ );
155
+ mid?.dropChannel('#chan');
156
+ await mid?.flush();
157
+
158
+ const reloaded = await loadDynamoServicesStore(
159
+ requireFx().client,
160
+ requireFx().tableName,
161
+ new FakeClock(NOW),
162
+ );
163
+ expect(reloaded?.getChannelFounder('#chan')).toBeUndefined();
164
+ expect(reloaded?.getChannelAccess('#chan', 'bob')).toBe(0);
165
+ expect(reloaded?.getChannelLevel('#chan', 'AUTOOP')).toBeUndefined();
166
+ });
167
+
168
+ it('returns undefined when the table name is absent (services unbound)', async () => {
169
+ const store = await loadDynamoServicesStore(requireFx().client, undefined, new FakeClock(NOW));
170
+ expect(store).toBeUndefined();
171
+ });
172
+
173
+ it('returns an empty store on a fresh table', async () => {
174
+ const store = await loadDynamoServicesStore(
175
+ requireFx().client,
176
+ requireFx().tableName,
177
+ new FakeClock(NOW),
178
+ );
179
+ expect(store).toBeDefined();
180
+ expect(store?.listAkills()).toEqual([]);
181
+ expect(store?.listMemos('alice')).toEqual([]);
182
+ });
183
+ });