serverless-ircd 0.2.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 (221) 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 +258 -18
  8. package/README.md +72 -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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -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';
@@ -48,6 +49,11 @@ interface Fixture {
48
49
 
49
50
  let fx: Fixture | null;
50
51
 
52
+ function requireFx(): Fixture {
53
+ if (!fx) throw new Error('test fixture not initialized');
54
+ return fx;
55
+ }
56
+
51
57
  async function makeFixture(): Promise<Fixture> {
52
58
  if (endpoint === undefined) throw new Error('DYNAMO_ENDPOINT missing');
53
59
  const prefix = `h${Date.now()}-${Math.floor(Math.random() * 1e6)}-`;
@@ -121,12 +127,12 @@ describe.skipIf(!available)('handlers', () => {
121
127
  it('returns 200 and persists a Connections row', async () => {
122
128
  const res = await dispatch(
123
129
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
124
- fx!.deps,
130
+ requireFx().deps,
125
131
  );
126
132
  expect(res.statusCode).toBe(200);
127
133
  const probe = new AwsRuntime({
128
- dynamo: fx!.client,
129
- tables: fx!.tables,
134
+ dynamo: requireFx().client,
135
+ tables: requireFx().tables,
130
136
  connId: '__probe__',
131
137
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
132
138
  managementApi: null,
@@ -138,11 +144,14 @@ describe.skipIf(!available)('handlers', () => {
138
144
 
139
145
  it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
140
146
  for (let i = 0; i < 2; i++) {
141
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
147
+ await dispatch(
148
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
149
+ requireFx().deps,
150
+ );
142
151
  }
143
152
  const probe = new AwsRuntime({
144
- dynamo: fx!.client,
145
- tables: fx!.tables,
153
+ dynamo: requireFx().client,
154
+ tables: requireFx().tables,
146
155
  connId: '__probe__',
147
156
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
148
157
  managementApi: null,
@@ -152,21 +161,84 @@ describe.skipIf(!available)('handlers', () => {
152
161
  });
153
162
  });
154
163
 
164
+ // -------------------------------------------------------------------------
165
+ // $connect admission gate (max-clients cap)
166
+ // -------------------------------------------------------------------------
167
+
168
+ describe('$connect admission gate', () => {
169
+ it('rejects the connection that exceeds the maxClients cap', async () => {
170
+ const capped: HandlerDeps = {
171
+ ...requireFx().deps,
172
+ serverConfig: { ...SERVER_CONFIG, maxClients: 2 },
173
+ };
174
+ const r1 = await dispatch(
175
+ { requestContext: { routeKey: '$connect', connectionId: 'a1' } },
176
+ capped,
177
+ );
178
+ const r2 = await dispatch(
179
+ { requestContext: { routeKey: '$connect', connectionId: 'a2' } },
180
+ capped,
181
+ );
182
+ const r3 = await dispatch(
183
+ { requestContext: { routeKey: '$connect', connectionId: 'a3' } },
184
+ capped,
185
+ );
186
+ expect(r1.statusCode).toBe(200);
187
+ expect(r2.statusCode).toBe(200);
188
+ expect(r3.statusCode).toBe(429);
189
+
190
+ const probe = new AwsRuntime({
191
+ dynamo: requireFx().client,
192
+ tables: requireFx().tables,
193
+ connId: '__probe__',
194
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
195
+ managementApi: null,
196
+ });
197
+ expect((await probe.getConnectionInfo('a1'))?.id).toBe('a1');
198
+ expect((await probe.getConnectionInfo('a2'))?.id).toBe('a2');
199
+ // The over-cap connection must NOT be recorded.
200
+ expect(await probe.getConnectionInfo('a3')).toBeNull();
201
+ });
202
+
203
+ it('admits a connection when the table is below the cap', async () => {
204
+ const capped: HandlerDeps = {
205
+ ...requireFx().deps,
206
+ serverConfig: { ...SERVER_CONFIG, maxClients: 5 },
207
+ };
208
+ const res = await dispatch(
209
+ { requestContext: { routeKey: '$connect', connectionId: 'b1' } },
210
+ capped,
211
+ );
212
+ expect(res.statusCode).toBe(200);
213
+ const probe = new AwsRuntime({
214
+ dynamo: requireFx().client,
215
+ tables: requireFx().tables,
216
+ connId: '__probe__',
217
+ handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
218
+ managementApi: null,
219
+ });
220
+ expect((await probe.getConnectionInfo('b1'))?.id).toBe('b1');
221
+ });
222
+ });
223
+
155
224
  // -------------------------------------------------------------------------
156
225
  // $disconnect
157
226
  // -------------------------------------------------------------------------
158
227
 
159
228
  describe('$disconnect', () => {
160
229
  it('returns 200 and deletes the Connections row', async () => {
161
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
230
+ await dispatch(
231
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
232
+ requireFx().deps,
233
+ );
162
234
  const res = await dispatch(
163
235
  { requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
164
- fx!.deps,
236
+ requireFx().deps,
165
237
  );
166
238
  expect(res.statusCode).toBe(200);
167
239
  const probe = new AwsRuntime({
168
- dynamo: fx!.client,
169
- tables: fx!.tables,
240
+ dynamo: requireFx().client,
241
+ tables: requireFx().tables,
170
242
  connId: '__probe__',
171
243
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
172
244
  managementApi: null,
@@ -177,33 +249,39 @@ describe.skipIf(!available)('handlers', () => {
177
249
  it('returns 200 even when the connection was never seen', async () => {
178
250
  const res = await dispatch(
179
251
  { requestContext: { routeKey: '$disconnect', connectionId: 'never' } },
180
- fx!.deps,
252
+ requireFx().deps,
181
253
  );
182
254
  expect(res.statusCode).toBe(200);
183
255
  });
184
256
 
185
257
  it('releases the nick on disconnect', async () => {
186
258
  // Connect + register alice.
187
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
259
+ await dispatch(
260
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
261
+ requireFx().deps,
262
+ );
188
263
  await dispatch(
189
264
  {
190
265
  requestContext: { routeKey: '$default', connectionId: 'c1' },
191
266
  body: 'NICK alice\r\n',
192
267
  },
193
- fx!.deps,
268
+ requireFx().deps,
194
269
  );
195
270
  await dispatch(
196
271
  {
197
272
  requestContext: { routeKey: '$default', connectionId: 'c1' },
198
273
  body: 'USER alice 0 * :Alice\r\n',
199
274
  },
200
- fx!.deps,
275
+ requireFx().deps,
201
276
  );
202
277
  // Disconnect.
203
- await dispatch({ requestContext: { routeKey: '$disconnect', connectionId: 'c1' } }, fx!.deps);
278
+ await dispatch(
279
+ { requestContext: { routeKey: '$disconnect', connectionId: 'c1' } },
280
+ requireFx().deps,
281
+ );
204
282
  const probe = new AwsRuntime({
205
- dynamo: fx!.client,
206
- tables: fx!.tables,
283
+ dynamo: requireFx().client,
284
+ tables: requireFx().tables,
207
285
  connId: '__probe__',
208
286
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
209
287
  managementApi: null,
@@ -223,7 +301,7 @@ describe.skipIf(!available)('handlers', () => {
223
301
  requestContext: { routeKey: '$default', connectionId: 'ghost' },
224
302
  body: 'PING :tok\r\n',
225
303
  },
226
- fx!.deps,
304
+ requireFx().deps,
227
305
  );
228
306
  expect(res.statusCode).toBe(410);
229
307
  });
@@ -233,23 +311,26 @@ describe.skipIf(!available)('handlers', () => {
233
311
  const event = {
234
312
  requestContext: { routeKey: '$default', connectionId: 'c1' },
235
313
  } as never;
236
- const res = await dispatch(event, fx!.deps);
314
+ const res = await dispatch(event, requireFx().deps);
237
315
  expect(res.statusCode).toBe(400);
238
316
  });
239
317
 
240
318
  it('runs a NICK + USER frame and emits the welcome block', async () => {
241
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
242
- fx!.mgmt.register('c1', () => {});
319
+ await dispatch(
320
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
321
+ requireFx().deps,
322
+ );
323
+ requireFx().mgmt.register('c1', () => {});
243
324
  await dispatch(
244
325
  {
245
326
  requestContext: { routeKey: '$default', connectionId: 'c1' },
246
327
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
247
328
  },
248
- fx!.deps,
329
+ requireFx().deps,
249
330
  );
250
331
  const probe = new AwsRuntime({
251
- dynamo: fx!.client,
252
- tables: fx!.tables,
332
+ dynamo: requireFx().client,
333
+ tables: requireFx().tables,
253
334
  connId: '__probe__',
254
335
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
255
336
  managementApi: null,
@@ -260,9 +341,12 @@ describe.skipIf(!available)('handlers', () => {
260
341
  });
261
342
 
262
343
  it('echoes outbound lines back to the caller via the management API', async () => {
263
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
344
+ await dispatch(
345
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
346
+ requireFx().deps,
347
+ );
264
348
  const sink: string[] = [];
265
- fx!.mgmt.register('c1', (data) => {
349
+ requireFx().mgmt.register('c1', (data) => {
266
350
  for (const line of data.split('\r\n')) {
267
351
  if (line.length > 0) sink.push(line);
268
352
  }
@@ -272,31 +356,34 @@ describe.skipIf(!available)('handlers', () => {
272
356
  requestContext: { routeKey: '$default', connectionId: 'c1' },
273
357
  body: 'PING :tok\r\n',
274
358
  },
275
- fx!.deps,
359
+ requireFx().deps,
276
360
  );
277
361
  expect(sink).toContain('PONG :tok');
278
362
  });
279
363
 
280
364
  it('persists joined channels across $default invocations', async () => {
281
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
282
- fx!.mgmt.register('c1', () => {});
365
+ await dispatch(
366
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
367
+ requireFx().deps,
368
+ );
369
+ requireFx().mgmt.register('c1', () => {});
283
370
  await dispatch(
284
371
  {
285
372
  requestContext: { routeKey: '$default', connectionId: 'c1' },
286
373
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
287
374
  },
288
- fx!.deps,
375
+ requireFx().deps,
289
376
  );
290
377
  await dispatch(
291
378
  {
292
379
  requestContext: { routeKey: '$default', connectionId: 'c1' },
293
380
  body: 'JOIN #persist\r\n',
294
381
  },
295
- fx!.deps,
382
+ requireFx().deps,
296
383
  );
297
384
  const probe = new AwsRuntime({
298
- dynamo: fx!.client,
299
- tables: fx!.tables,
385
+ dynamo: requireFx().client,
386
+ tables: requireFx().tables,
300
387
  connId: '__probe__',
301
388
  handlers: { send: () => {}, disconnect: () => {}, snapshot: () => undefined },
302
389
  managementApi: null,
@@ -307,26 +394,32 @@ describe.skipIf(!available)('handlers', () => {
307
394
 
308
395
  it('rebuilds an existing channel roster via refreshChannel when a second client JOINs', async () => {
309
396
  // First connection registers + JOINs.
310
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
311
- fx!.mgmt.register('c1', () => {});
397
+ await dispatch(
398
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
399
+ requireFx().deps,
400
+ );
401
+ requireFx().mgmt.register('c1', () => {});
312
402
  await dispatch(
313
403
  {
314
404
  requestContext: { routeKey: '$default', connectionId: 'c1' },
315
405
  body: 'NICK alice\r\nUSER alice 0 * :Alice\r\n',
316
406
  },
317
- fx!.deps,
407
+ requireFx().deps,
318
408
  );
319
409
  await dispatch(
320
410
  {
321
411
  requestContext: { routeKey: '$default', connectionId: 'c1' },
322
412
  body: 'JOIN #room\r\n',
323
413
  },
324
- fx!.deps,
414
+ requireFx().deps,
325
415
  );
326
416
  // Second connection registers + JOINs the SAME channel.
327
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c2' } }, fx!.deps);
417
+ await dispatch(
418
+ { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
419
+ requireFx().deps,
420
+ );
328
421
  const c2sink: string[] = [];
329
- fx!.mgmt.register('c2', (data) => {
422
+ requireFx().mgmt.register('c2', (data) => {
330
423
  for (const line of data.split('\r\n')) {
331
424
  if (line.length > 0) c2sink.push(line);
332
425
  }
@@ -336,14 +429,14 @@ describe.skipIf(!available)('handlers', () => {
336
429
  requestContext: { routeKey: '$default', connectionId: 'c2' },
337
430
  body: 'NICK bob\r\nUSER bob 0 * :Bob\r\n',
338
431
  },
339
- fx!.deps,
432
+ requireFx().deps,
340
433
  );
341
434
  await dispatch(
342
435
  {
343
436
  requestContext: { routeKey: '$default', connectionId: 'c2' },
344
437
  body: 'JOIN #room\r\n',
345
438
  },
346
- fx!.deps,
439
+ requireFx().deps,
347
440
  );
348
441
  // The 353 RPL_NAMREPLY c2 receives must list BOTH alice and bob —
349
442
  // proving that the actor's `refreshChannel` populated the cache
@@ -355,9 +448,12 @@ describe.skipIf(!available)('handlers', () => {
355
448
  });
356
449
 
357
450
  it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
358
- await dispatch({ requestContext: { routeKey: '$connect', connectionId: 'c1' } }, fx!.deps);
451
+ await dispatch(
452
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
453
+ requireFx().deps,
454
+ );
359
455
  const sink: string[] = [];
360
- fx!.mgmt.register('c1', (data) => {
456
+ requireFx().mgmt.register('c1', (data) => {
361
457
  for (const line of data.split('\r\n')) {
362
458
  if (line.length > 0) sink.push(line);
363
459
  }
@@ -367,7 +463,7 @@ describe.skipIf(!available)('handlers', () => {
367
463
  requestContext: { routeKey: '$default', connectionId: 'c1' },
368
464
  body: 'CAP REQ :draft/chathistory server-time message-tags batch\r\nNICK aws-alice\r\nUSER aws-alice 0 * :Alice\r\nCAP END\r\nJOIN #aws-chathist\r\n',
369
465
  },
370
- fx!.deps,
466
+ requireFx().deps,
371
467
  );
372
468
  sink.length = 0;
373
469
  await dispatch(
@@ -375,7 +471,7 @@ describe.skipIf(!available)('handlers', () => {
375
471
  requestContext: { routeKey: '$default', connectionId: 'c1' },
376
472
  body: 'PRIVMSG #aws-chathist :hello from aws\r\nCHATHISTORY LATEST #aws-chathist * 10\r\n',
377
473
  },
378
- fx!.deps,
474
+ requireFx().deps,
379
475
  );
380
476
  const batchOpen = sink.find((l) => /^BATCH \+\S+ chathistory #aws-chathist/.test(l));
381
477
  expect(batchOpen).toBeDefined();
@@ -383,6 +479,57 @@ describe.skipIf(!available)('handlers', () => {
383
479
  expect(replay).toBeDefined();
384
480
  });
385
481
 
482
+ it('completes SASL PLAIN with 900/903 when an AccountStore is bound', async () => {
483
+ await dispatch(
484
+ { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
485
+ requireFx().deps,
486
+ );
487
+ const sink: string[] = [];
488
+ requireFx().mgmt.register('c1', (data) => {
489
+ for (const line of data.split('\r\n')) {
490
+ if (line.length > 0) sink.push(line);
491
+ }
492
+ });
493
+ // Swap in a deps with a seeded AccountStore for this dispatch.
494
+ const depsWithAccounts: HandlerDeps = {
495
+ ...requireFx().deps,
496
+ accounts: new InMemoryAccountStore([{ username: 'aws-sasl', password: 's3cret' }]),
497
+ };
498
+ const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
499
+ await dispatch(
500
+ {
501
+ requestContext: { routeKey: '$default', connectionId: 'c1' },
502
+ body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
503
+ },
504
+ depsWithAccounts,
505
+ );
506
+ expect(sink.some((l) => l.includes(' 900 '))).toBe(true);
507
+ expect(sink.some((l) => l.includes(' 903 '))).toBe(true);
508
+ expect(sink.some((l) => l.includes(' 904 '))).toBe(false);
509
+ });
510
+
511
+ it('emits 904 when no AccountStore is bound (regression guard)', async () => {
512
+ await dispatch(
513
+ { requestContext: { routeKey: '$connect', connectionId: 'c2' } },
514
+ requireFx().deps,
515
+ );
516
+ const sink: string[] = [];
517
+ requireFx().mgmt.register('c2', (data) => {
518
+ for (const line of data.split('\r\n')) {
519
+ if (line.length > 0) sink.push(line);
520
+ }
521
+ });
522
+ const payload = Buffer.from('\0aws-sasl\0s3cret', 'utf8').toString('base64');
523
+ await dispatch(
524
+ {
525
+ requestContext: { routeKey: '$default', connectionId: 'c2' },
526
+ body: `CAP REQ :sasl\r\nAUTHENTICATE PLAIN\r\nAUTHENTICATE ${payload}\r\n`,
527
+ },
528
+ requireFx().deps,
529
+ );
530
+ expect(sink.some((l) => l.includes(' 904 '))).toBe(true);
531
+ });
532
+
386
533
  it('returns 500 when unmarshalConnection throws on a corrupted row (caught by Lambda)', async () => {
387
534
  // Skip: the catch in handleDefault wraps `actor.receiveTextFrame`,
388
535
  // not `unmarshalConnection`. A corrupted-row test would exercise
@@ -400,7 +547,7 @@ describe.skipIf(!available)('handlers', () => {
400
547
  it('returns 200 (APIGW only forwards the three known routes)', async () => {
401
548
  const res = await dispatch(
402
549
  { requestContext: { routeKey: '$custom', connectionId: 'c1' } },
403
- fx!.deps,
550
+ requireFx().deps,
404
551
  );
405
552
  expect(res.statusCode).toBe(200);
406
553
  });
@@ -416,7 +563,7 @@ class LocalPostToConnection implements PostToConnection {
416
563
  register(connId: string, sink: (data: string) => void): void {
417
564
  this.sinks.set(connId, sink);
418
565
  }
419
- async postToConnection(input: { ConnectionId: string; Data: string }): Promise<{}> {
566
+ async postToConnection(input: { ConnectionId: string; Data: string }): Promise<unknown> {
420
567
  const sink = this.sinks.get(input.ConnectionId);
421
568
  if (sink !== undefined) sink(input.Data);
422
569
  return {};