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
@@ -4,7 +4,7 @@ import { capReducer } from '../../src/commands/cap';
4
4
  import { authenticateReducer } from '../../src/commands/sasl';
5
5
  import { Effect } from '../../src/effects';
6
6
  import type { Effect as EffectType, RawLine } from '../../src/effects';
7
- import type { AccountStore, SaslPayload, SaslResult } from '../../src/ports';
7
+ import type { AccountStore, MtlsIdentityProvider, SaslPayload, SaslResult } from '../../src/ports';
8
8
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
9
9
  import { encodeBase64 } from '../../src/protocol/base64';
10
10
  import type { IrcMessage } from '../../src/protocol/messages';
@@ -40,7 +40,11 @@ function saslReadyState(): ConnectionState {
40
40
  return s;
41
41
  }
42
42
 
43
- function makeCtx(state: ConnectionState, accounts?: AccountStore): Ctx {
43
+ function makeCtx(
44
+ state: ConnectionState,
45
+ accounts?: AccountStore,
46
+ mtlsIdentity?: MtlsIdentityProvider,
47
+ ): Ctx {
44
48
  return buildCtx({
45
49
  serverConfig,
46
50
  clock: new FakeClock(1_000),
@@ -48,6 +52,7 @@ function makeCtx(state: ConnectionState, accounts?: AccountStore): Ctx {
48
52
  motd: EmptyMotdProvider,
49
53
  connection: state,
50
54
  ...(accounts !== undefined ? { accounts } : {}),
55
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
51
56
  });
52
57
  }
53
58
 
@@ -61,6 +66,19 @@ class FakeAccountStore implements AccountStore {
61
66
  }
62
67
  }
63
68
 
69
+ /** A fake MtlsIdentityProvider that returns a canned identity for one connId. */
70
+ class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
71
+ readonly calls: string[] = [];
72
+ constructor(
73
+ private readonly connId: string,
74
+ private readonly identity: string | undefined,
75
+ ) {}
76
+ getIdentity(connId: string): string | undefined {
77
+ this.calls.push(connId);
78
+ return connId === this.connId ? this.identity : undefined;
79
+ }
80
+ }
81
+
64
82
  /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
65
83
  function plainPayload(authcid: string, password: string, authzid = ''): string {
66
84
  return encodeBase64(`${authzid}\0${authcid}\0${password}`);
@@ -243,25 +261,103 @@ describe('authenticateReducer — no AccountStore configured', () => {
243
261
  });
244
262
 
245
263
  // ============================================================================
246
- // AUTHENTICATE EXTERNAL — stubbed
264
+ // AUTHENTICATE EXTERNAL — mTLS-backed SASL EXTERNAL
247
265
  // ============================================================================
248
266
 
249
- describe('authenticateReducer — EXTERNAL stubbed', () => {
250
- it('rejects EXTERNAL immediately with 904 (not yet supported)', () => {
267
+ describe('authenticateReducer — EXTERNAL with mTLS', () => {
268
+ it('enters the payload phase when an mTLS provider is configured', () => {
269
+ const state = saslReadyState();
270
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
271
+ const ctx = makeCtx(state, undefined, provider);
272
+ const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
273
+
274
+ expect(state.saslMech).toBe('EXTERNAL');
275
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
276
+ });
277
+
278
+ it('returns 908 ERR_SASLMECHS when no mTLS provider is configured', () => {
251
279
  const state = saslReadyState();
252
280
  const ctx = makeCtx(state);
253
281
  const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
254
282
 
255
283
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
256
- L(`:${SV} 904 alice :SASL authentication failed`),
284
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
257
285
  ]);
286
+ expect(state.saslMech).toBeUndefined();
258
287
  });
259
288
 
260
- it('does not enter the payload phase for EXTERNAL', () => {
289
+ it('succeeds (900/903) when a valid cert identity is presented', () => {
261
290
  const state = saslReadyState();
262
- const ctx = makeCtx(state);
291
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
292
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
293
+ const ctx = makeCtx(state, accounts, provider);
294
+
263
295
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
296
+ const out = authenticateReducer(state, authenticate('+'), ctx);
297
+
298
+ expect(state.account).toBe('alice');
299
+ expect(accounts.calls).toEqual([
300
+ { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
301
+ ]);
302
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
303
+ L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
304
+ L(`:${SV} 903 alice :SASL authentication successful`),
305
+ ]);
306
+ });
307
+
308
+ it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
309
+ const state = saslReadyState();
310
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
311
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
312
+ const ctx = makeCtx(state, accounts, provider);
313
+
314
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
315
+ authenticateReducer(state, authenticate('+'), ctx);
316
+
264
317
  expect(state.saslMech).toBeUndefined();
318
+ expect(state.saslBuffer).toBeUndefined();
319
+ });
320
+
321
+ it('fails with 904 when the provider has no identity for the connection', () => {
322
+ const state = saslReadyState();
323
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
324
+ const provider = new FakeMtlsIdentityProvider('c1', undefined);
325
+ const ctx = makeCtx(state, accounts, provider);
326
+
327
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
328
+ const out = authenticateReducer(state, authenticate('+'), ctx);
329
+
330
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
331
+ L(`:${SV} 904 alice :SASL authentication failed`),
332
+ ]);
333
+ expect(state.account).toBeUndefined();
334
+ });
335
+
336
+ it('fails with 904 when the account store rejects the identity', () => {
337
+ const state = saslReadyState();
338
+ const accounts = new FakeAccountStore({ ok: false, reason: 'untrusted certificate' });
339
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
340
+ const ctx = makeCtx(state, accounts, provider);
341
+
342
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
343
+ const out = authenticateReducer(state, authenticate('+'), ctx);
344
+
345
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
346
+ L(`:${SV} 904 alice :SASL authentication failed`),
347
+ ]);
348
+ });
349
+
350
+ it('fails with 904 when no account store is configured', () => {
351
+ const state = saslReadyState();
352
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
353
+ const ctx = makeCtx(state, undefined, provider);
354
+
355
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
356
+ const out = authenticateReducer(state, authenticate('+'), ctx);
357
+
358
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
359
+ L(`:${SV} 904 alice :SASL authentication failed`),
360
+ ]);
265
361
  });
266
362
  });
267
363
 
@@ -275,6 +371,17 @@ describe('authenticateReducer — unknown mechanism', () => {
275
371
  const ctx = makeCtx(state);
276
372
  const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
277
373
 
374
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
375
+ L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
376
+ ]);
377
+ });
378
+
379
+ it('lists PLAIN,EXTERNAL in 908 when an mTLS provider is configured', () => {
380
+ const state = saslReadyState();
381
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
382
+ const ctx = makeCtx(state, undefined, provider);
383
+ const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
384
+
278
385
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
279
386
  L(`:${SV} 908 alice PLAIN,EXTERNAL :are the available SASL mechanisms`),
280
387
  ]);
@@ -529,8 +636,9 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
529
636
  expect(state.account).toBe('alice');
530
637
  });
531
638
 
532
- it('does not affect the advertised sasl cap value (PLAIN,EXTERNAL)', () => {
639
+ it('advertises the base sasl cap value (PLAIN) without mTLS', () => {
533
640
  const advertised = getLsString();
534
- expect(advertised).toContain('sasl=PLAIN,EXTERNAL');
641
+ expect(advertised).toContain('sasl=PLAIN');
642
+ expect(advertised).not.toContain('sasl=PLAIN,EXTERNAL');
535
643
  });
536
644
  });
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ adminReducer,
4
+ infoReducer,
5
+ timeReducer,
6
+ versionReducer,
7
+ } from '../../src/commands/server-info';
8
+ import { DEFAULT_SERVER_VERSION } from '../../src/config';
9
+ import { Effect } from '../../src/effects';
10
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
11
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
12
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
13
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
+
15
+ const baseServerConfig: ServerConfig = {
16
+ serverName: 'irc.example.com',
17
+ networkName: 'ExampleNet',
18
+ maxChannelsPerUser: 30,
19
+ maxTargetsPerCommand: 10,
20
+ maxListEntries: 50,
21
+ nickLen: 30,
22
+ channelLen: 50,
23
+ topicLen: 390,
24
+ quitMessage: 'Client Quit',
25
+ };
26
+
27
+ function makeCtx(state: ConnectionState, clockMs = 5_000): Ctx {
28
+ return buildCtx({
29
+ serverConfig: baseServerConfig,
30
+ clock: new FakeClock(clockMs),
31
+ ids: new SequentialIdFactory(),
32
+ motd: EmptyMotdProvider,
33
+ connection: state,
34
+ });
35
+ }
36
+
37
+ function makeState(): ConnectionState {
38
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
39
+ s.nick = 'alice';
40
+ s.user = 'alice';
41
+ s.host = 'example.com';
42
+ s.realname = 'Alice';
43
+ s.registration = 'registered';
44
+ return s;
45
+ }
46
+
47
+ const L = (text: string): RawLine => ({ text });
48
+
49
+ const version = (server?: string) =>
50
+ ({ command: 'VERSION', params: server === undefined ? [] : [server], tags: {} }) as const;
51
+
52
+ const time = (server?: string) =>
53
+ ({ command: 'TIME', params: server === undefined ? [] : [server], tags: {} }) as const;
54
+
55
+ const admin = (server?: string) =>
56
+ ({ command: 'ADMIN', params: server === undefined ? [] : [server], tags: {} }) as const;
57
+
58
+ const info = (server?: string) =>
59
+ ({ command: 'INFO', params: server === undefined ? [] : [server], tags: {} }) as const;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // VERSION
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('versionReducer', () => {
66
+ it('emits 351 RPL_VERSION with the server version and name', () => {
67
+ const state = makeState();
68
+ const ctx = makeCtx(state);
69
+
70
+ const out = versionReducer(state, version(), ctx);
71
+
72
+ expect(out.effects).toEqual<EffectType[]>([
73
+ Effect.send('c1', [
74
+ L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
75
+ ]),
76
+ ]);
77
+ });
78
+
79
+ it('updates lastSeen to ctx.clock.now()', () => {
80
+ const state = makeState();
81
+ const ctx = makeCtx(state, 9_999);
82
+
83
+ versionReducer(state, version(), ctx);
84
+
85
+ expect(state.lastSeen).toBe(9_999);
86
+ });
87
+
88
+ it('uses * as nick placeholder for unregistered connections', () => {
89
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
90
+ const ctx = makeCtx(state);
91
+
92
+ const out = versionReducer(state, version(), ctx);
93
+
94
+ expect(out.effects[0]).toEqual<EffectType>(
95
+ Effect.send('c1', [
96
+ L(`:irc.example.com 351 * ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
97
+ ]),
98
+ );
99
+ });
100
+
101
+ it('ignores the optional server target parameter (single-server deployment)', () => {
102
+ const state = makeState();
103
+ const ctx = makeCtx(state);
104
+
105
+ const out = versionReducer(state, version('other.server'), ctx);
106
+
107
+ expect(out.effects[0]).toEqual<EffectType>(
108
+ Effect.send('c1', [
109
+ L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
110
+ ]),
111
+ );
112
+ });
113
+
114
+ it('returns the same state reference', () => {
115
+ const state = makeState();
116
+ const ctx = makeCtx(state);
117
+
118
+ const out = versionReducer(state, version(), ctx);
119
+
120
+ expect(out.state).toBe(state);
121
+ });
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // TIME
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe('timeReducer', () => {
129
+ it('emits 391 RPL_TIME with a human-readable timestamp from the clock', () => {
130
+ const state = makeState();
131
+ const ctx = makeCtx(state, 1_700_000_000_000);
132
+
133
+ const out = timeReducer(state, time(), ctx);
134
+
135
+ expect(out.effects).toEqual<EffectType[]>([
136
+ Effect.send('c1', [
137
+ L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
138
+ ]),
139
+ ]);
140
+ });
141
+
142
+ it('updates lastSeen to ctx.clock.now()', () => {
143
+ const state = makeState();
144
+ const ctx = makeCtx(state, 42_000);
145
+
146
+ timeReducer(state, time(), ctx);
147
+
148
+ expect(state.lastSeen).toBe(42_000);
149
+ });
150
+
151
+ it('uses * as nick placeholder for unregistered connections', () => {
152
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
153
+ const ctx = makeCtx(state, 1_700_000_000_000);
154
+
155
+ const out = timeReducer(state, time(), ctx);
156
+
157
+ expect(out.effects[0]).toEqual<EffectType>(
158
+ Effect.send('c1', [L(':irc.example.com 391 * irc.example.com :2023-11-14T22:13:20.000Z')]),
159
+ );
160
+ });
161
+
162
+ it('ignores the optional server target parameter', () => {
163
+ const state = makeState();
164
+ const ctx = makeCtx(state, 1_700_000_000_000);
165
+
166
+ const out = timeReducer(state, time('elsewhere'), ctx);
167
+
168
+ expect(out.effects[0]).toEqual<EffectType>(
169
+ Effect.send('c1', [
170
+ L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
171
+ ]),
172
+ );
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // ADMIN
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe('adminReducer', () => {
181
+ it('emits 256 RPL_ADMINME with the server name', () => {
182
+ const state = makeState();
183
+ const ctx = makeCtx(state);
184
+
185
+ const out = adminReducer(state, admin(), ctx);
186
+
187
+ expect(out.effects).toEqual<EffectType[]>([
188
+ Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
189
+ ]);
190
+ });
191
+
192
+ it('updates lastSeen to ctx.clock.now()', () => {
193
+ const state = makeState();
194
+ const ctx = makeCtx(state, 7_777);
195
+
196
+ adminReducer(state, admin(), ctx);
197
+
198
+ expect(state.lastSeen).toBe(7_777);
199
+ });
200
+
201
+ it('uses * as nick placeholder for unregistered connections', () => {
202
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
203
+ const ctx = makeCtx(state);
204
+
205
+ const out = adminReducer(state, admin(), ctx);
206
+
207
+ expect(out.effects[0]).toEqual<EffectType>(
208
+ Effect.send('c1', [L(':irc.example.com 256 * irc.example.com :Administrative info')]),
209
+ );
210
+ });
211
+
212
+ it('ignores the optional server target parameter', () => {
213
+ const state = makeState();
214
+ const ctx = makeCtx(state);
215
+
216
+ const out = adminReducer(state, admin('foo'), ctx);
217
+
218
+ expect(out.effects[0]).toEqual<EffectType>(
219
+ Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
220
+ );
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // INFO
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe('infoReducer', () => {
229
+ it('emits 371 RPL_INFO lines followed by 374 RPL_ENDOFINFO', () => {
230
+ const state = makeState();
231
+ const ctx = makeCtx(state);
232
+
233
+ const out = infoReducer(state, info(), ctx);
234
+
235
+ const lines = (out.effects[0] as { lines: RawLine[] }).lines;
236
+ expect(lines[0]).toEqual(
237
+ L(`:irc.example.com 371 alice :ServerlessIRCd - version ${DEFAULT_SERVER_VERSION}`),
238
+ );
239
+ expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));
240
+ expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));
241
+ });
242
+
243
+ it('includes at least one 371 line and the 374 terminator', () => {
244
+ const state = makeState();
245
+ const ctx = makeCtx(state);
246
+
247
+ const out = infoReducer(state, info(), ctx);
248
+
249
+ const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
250
+ const rpl371Count = texts.filter((t) => / 371 /.test(t)).length;
251
+ const rpl374Count = texts.filter((t) => / 374 /.test(t)).length;
252
+ expect(rpl371Count).toBeGreaterThanOrEqual(1);
253
+ expect(rpl374Count).toBe(1);
254
+ });
255
+
256
+ it('updates lastSeen to ctx.clock.now()', () => {
257
+ const state = makeState();
258
+ const ctx = makeCtx(state, 3_333);
259
+
260
+ infoReducer(state, info(), ctx);
261
+
262
+ expect(state.lastSeen).toBe(3_333);
263
+ });
264
+
265
+ it('uses * as nick placeholder for unregistered connections', () => {
266
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
267
+ const ctx = makeCtx(state);
268
+
269
+ const out = infoReducer(state, info(), ctx);
270
+
271
+ const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
272
+ expect(texts.every((t) => t.includes(' * '))).toBe(true);
273
+ });
274
+ });