serverless-ircd 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -1,4 +1,6 @@
1
1
  import {
2
+ type AccountStore,
3
+ CapturingLogger,
2
4
  type ChanName,
3
5
  type ChanSnapshot,
4
6
  type ChannelDelta,
@@ -8,15 +10,26 @@ import {
8
10
  type ConnectionState,
9
11
  EmptyMotdProvider,
10
12
  FakeClock,
13
+ type FloodControlConfig,
14
+ InMemoryMessageStore,
15
+ LogLevel,
16
+ type Logger,
17
+ type MessageStore,
11
18
  type Nick,
12
19
  Numerics,
13
20
  type RawLine,
21
+ type SaslPayload,
22
+ type SaslResult,
14
23
  SequentialIdFactory,
24
+ type ServerConfig,
25
+ type StoredMessage,
15
26
  applyChannelDelta as applyDelta,
16
27
  createChannel,
17
28
  createConnection,
29
+ encodeBase64,
18
30
  isChannelTarget,
19
31
  toChannelSnapshot,
32
+ toSnapshot,
20
33
  } from '@serverless-ircd/irc-core';
21
34
  import { describe, expect, it } from 'vitest';
22
35
  import { ConnectionActor } from '../src/actor';
@@ -56,6 +69,10 @@ class FakeRuntime implements IrcRuntime {
56
69
  return chan;
57
70
  }
58
71
 
72
+ getChannel(name: ChanName): ChannelState | undefined {
73
+ return this.channels.get(name.toLowerCase());
74
+ }
75
+
59
76
  async send(to: ConnId, lines: RawLine[]): Promise<void> {
60
77
  this.calls.push({ method: 'send', args: [to, lines] });
61
78
  }
@@ -104,7 +121,8 @@ class FakeRuntime implements IrcRuntime {
104
121
  }
105
122
  async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
106
123
  this.calls.push({ method: 'getConnectionInfo', args: [conn] });
107
- return null;
124
+ const state = this.testConnections.get(conn);
125
+ return state === undefined ? null : toSnapshot(state);
108
126
  }
109
127
  async getConnection(conn: ConnId): Promise<ConnectionState | null> {
110
128
  this.calls.push({ method: 'getConnection', args: [conn] });
@@ -138,6 +156,12 @@ class FakeRuntime implements IrcRuntime {
138
156
  function makeActor(opts: {
139
157
  conn?: Partial<ConnectionState>;
140
158
  clock?: FakeClock;
159
+ floodControl?: FloodControlConfig | null;
160
+ logger?: Logger;
161
+ traceId?: string;
162
+ messages?: MessageStore;
163
+ accounts?: AccountStore;
164
+ operCreds?: ReadonlyArray<{ user: string; password: string }>;
141
165
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
142
166
  const clock = opts.clock ?? new FakeClock(1_000);
143
167
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -149,25 +173,49 @@ function makeActor(opts: {
149
173
  if (opts.conn) Object.assign(conn, opts.conn);
150
174
 
151
175
  const runtime = new FakeRuntime(clock);
152
- const actor = new ConnectionActor({
176
+ // Build in one expression: `floodControl` is readonly, and only stamp it
177
+ // when the caller supplied a value so `undefined` (omit → default) stays
178
+ // distinct from `null` (explicit disable).
179
+ const serverConfig: ServerConfig = {
180
+ serverName: 'irc.example.com',
181
+ networkName: 'ExampleNet',
182
+ maxChannelsPerUser: 30,
183
+ maxTargetsPerCommand: 10,
184
+ maxListEntries: 50,
185
+ nickLen: 30,
186
+ channelLen: 50,
187
+ topicLen: 390,
188
+ quitMessage: 'Client Quit',
189
+ ...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
190
+ ...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
191
+ };
192
+ const ids = new SequentialIdFactory();
193
+ type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
194
+ // Strip readonly modifiers so we can conditionally assign `logger` /
195
+ // `traceId` without tripping the readonly guards on `ConnectionActorOptions`.
196
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
197
+ const finalOpts: Mutable<ActorOpts> = {
153
198
  state: conn,
154
199
  runtime,
155
200
  channels: runtime,
156
- serverConfig: {
157
- serverName: 'irc.example.com',
158
- networkName: 'ExampleNet',
159
- maxChannelsPerUser: 30,
160
- maxTargetsPerCommand: 10,
161
- maxListEntries: 50,
162
- nickLen: 30,
163
- channelLen: 50,
164
- topicLen: 390,
165
- quitMessage: 'Client Quit',
166
- },
201
+ serverConfig,
167
202
  clock,
168
- ids: new SequentialIdFactory(),
203
+ ids,
169
204
  motd: EmptyMotdProvider,
170
- });
205
+ };
206
+ if (opts.logger !== undefined) {
207
+ finalOpts.logger = opts.logger;
208
+ }
209
+ if (opts.traceId !== undefined) {
210
+ finalOpts.traceId = opts.traceId;
211
+ }
212
+ if (opts.messages !== undefined) {
213
+ finalOpts.messages = opts.messages;
214
+ }
215
+ if (opts.accounts !== undefined) {
216
+ finalOpts.accounts = opts.accounts;
217
+ }
218
+ const actor = new ConnectionActor(finalOpts);
171
219
  return { actor, runtime, conn };
172
220
  }
173
221
 
@@ -442,6 +490,59 @@ describe('ConnectionActor — additional routing', () => {
442
490
  expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
443
491
  });
444
492
 
493
+ it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
494
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
495
+ bobConn.nick = 'bob';
496
+ bobConn.user = 'bob';
497
+ bobConn.host = 'example.org';
498
+ bobConn.registration = 'registered';
499
+ bobConn.caps = new Set<string>(['message-tags']);
500
+ const { actor, runtime } = makeActor({
501
+ conn: { caps: new Set<string>(['message-tags']) },
502
+ });
503
+ runtime.addConnection(bobConn);
504
+ await runtime.applyChannelDelta('#foo', {
505
+ memberships: [
506
+ { type: 'add', conn: 'c1', nick: 'alice' },
507
+ { type: 'add', conn: 'c2', nick: 'bob' },
508
+ ],
509
+ });
510
+ runtime.calls.length = 0;
511
+ await actor.receiveTextFrame('@+typing=active TAGMSG #foo\r\n');
512
+
513
+ // Cap-gated broadcast dispatches per-recipient via send (only to
514
+ // message-tags-enabled members, excluding the sender).
515
+ const sends = runtime.calls.filter(
516
+ (c) => c.method === 'send' && c.args[0] === 'c2',
517
+ );
518
+ expect(sends).toHaveLength(1);
519
+ const lines = sends[0]?.args[1] as RawLine[];
520
+ expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
521
+ // Critical: never 421 for a cap-enabled TAGMSG.
522
+ for (const call of runtime.calls) {
523
+ if (call.method === 'send') {
524
+ for (const line of call.args[1] as RawLine[]) {
525
+ expect(line.text).not.toContain(' 421 ');
526
+ }
527
+ }
528
+ }
529
+ });
530
+
531
+ it('still emits 421 for TAGMSG when message-tags cap is not negotiated', async () => {
532
+ const { actor, runtime } = makeActor({});
533
+ await runtime.applyChannelDelta('#foo', {
534
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
535
+ });
536
+ runtime.calls.length = 0;
537
+ await actor.receiveTextFrame('TAGMSG #foo\r\n');
538
+
539
+ const sends = runtime.calls.filter((c) => c.method === 'send');
540
+ expect(sends).toHaveLength(1);
541
+ const lines = sends[0]?.args[1] as RawLine[];
542
+ expect(lines[0]?.text).toContain(' 421 ');
543
+ expect(lines[0]?.text).toContain('TAGMSG');
544
+ });
545
+
445
546
  it('routes PRIVMSG with mixed targets (#chan,nick) into 2 invocations', async () => {
446
547
  const { actor, runtime } = makeActor({});
447
548
  await runtime.applyChannelDelta('#foo', {
@@ -563,6 +664,176 @@ describe('ConnectionActor — additional routing', () => {
563
664
  }
564
665
  });
565
666
 
667
+ it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
668
+ // Pre-registration: client has opened CAP negotiation and requested sasl.
669
+ // AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
670
+ const { actor, runtime, conn } = makeActor({
671
+ conn: {
672
+ registration: 'pre-registration',
673
+ capNegotiating: true,
674
+ caps: new Set<string>(['sasl']),
675
+ },
676
+ });
677
+ // The connection needs a nick for the reducer's `nickOf(ctx)` fallback.
678
+ conn.nick = 'alice';
679
+ runtime.calls.length = 0;
680
+
681
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
682
+
683
+ const sends = runtime.calls.filter((c) => c.method === 'send');
684
+ expect(sends).toHaveLength(1);
685
+ const lines = sends[0]?.args[1] as RawLine[];
686
+ // SASL reducer responds with `AUTHENTICATE +` to request the payload.
687
+ expect(lines[0]?.text).toBe('AUTHENTICATE +');
688
+ // Critical: never 421 for AUTHENTICATE.
689
+ for (const s of sends) {
690
+ for (const line of s.args[1] as RawLine[]) {
691
+ expect(line.text).not.toContain(' 421 ');
692
+ }
693
+ }
694
+ });
695
+
696
+ it('routes AUTHENTICATE * (abort) through authenticateReducer, never 421', async () => {
697
+ const { actor, runtime } = makeActor({
698
+ conn: {
699
+ registration: 'pre-registration',
700
+ capNegotiating: true,
701
+ caps: new Set<string>(['sasl']),
702
+ saslMech: 'PLAIN',
703
+ },
704
+ });
705
+ runtime.calls.length = 0;
706
+
707
+ await actor.receiveTextFrame('AUTHENTICATE *\r\n');
708
+
709
+ const sends = runtime.calls.filter((c) => c.method === 'send');
710
+ expect(sends).toHaveLength(1);
711
+ const lines = sends[0]?.args[1] as RawLine[];
712
+ // Abort → 906 ERR_SASLABORT.
713
+ expect(lines[0]?.text).toContain(' 906 ');
714
+ for (const s of sends) {
715
+ for (const line of s.args[1] as RawLine[]) {
716
+ expect(line.text).not.toContain(' 421 ');
717
+ }
718
+ }
719
+ });
720
+
721
+ it('routes AWAY :reason through awayReducer, emitting 306 RPL_NOWAWAY (never 421)', async () => {
722
+ const { actor, runtime } = makeActor({});
723
+ runtime.calls.length = 0;
724
+
725
+ await actor.receiveTextFrame('AWAY :gone\r\n');
726
+
727
+ const sends = runtime.calls.filter((c) => c.method === 'send');
728
+ expect(sends).toHaveLength(1);
729
+ const lines = sends[0]?.args[1] as RawLine[];
730
+ expect(lines[0]?.text).toBe(':irc.example.com 306 alice :You have been marked as being away');
731
+ // Critical: never 421 for AWAY.
732
+ for (const s of sends) {
733
+ for (const line of s.args[1] as RawLine[]) {
734
+ expect(line.text).not.toContain(' 421 ');
735
+ }
736
+ }
737
+ });
738
+
739
+ it('routes AWAY with no params through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
740
+ const { actor, runtime, conn } = makeActor({});
741
+ // Start from an away state so the unset path is observable.
742
+ conn.away = 'previously away';
743
+ runtime.calls.length = 0;
744
+
745
+ await actor.receiveTextFrame('AWAY\r\n');
746
+
747
+ const sends = runtime.calls.filter((c) => c.method === 'send');
748
+ expect(sends).toHaveLength(1);
749
+ const lines = sends[0]?.args[1] as RawLine[];
750
+ expect(lines[0]?.text).toBe(
751
+ ':irc.example.com 305 alice :You are no longer marked as being away',
752
+ );
753
+ expect(conn.away).toBeUndefined();
754
+ for (const s of sends) {
755
+ for (const line of s.args[1] as RawLine[]) {
756
+ expect(line.text).not.toContain(' 421 ');
757
+ }
758
+ }
759
+ });
760
+
761
+ it('routes AWAY with an empty reason through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
762
+ const { actor, runtime, conn } = makeActor({});
763
+ conn.away = 'previously away';
764
+ runtime.calls.length = 0;
765
+
766
+ await actor.receiveTextFrame('AWAY :\r\n');
767
+
768
+ const sends = runtime.calls.filter((c) => c.method === 'send');
769
+ expect(sends).toHaveLength(1);
770
+ const lines = sends[0]?.args[1] as RawLine[];
771
+ expect(lines[0]?.text).toContain(' 305 ');
772
+ expect(conn.away).toBeUndefined();
773
+ for (const s of sends) {
774
+ for (const line of s.args[1] as RawLine[]) {
775
+ expect(line.text).not.toContain(' 421 ');
776
+ }
777
+ }
778
+ });
779
+
780
+ it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
781
+ const { actor, runtime, conn } = makeActor({
782
+ operCreds: [{ user: 'alice', password: 'secret' }],
783
+ });
784
+ runtime.calls.length = 0;
785
+
786
+ await actor.receiveTextFrame('OPER alice secret\r\n');
787
+
788
+ const sends = runtime.calls.filter((c) => c.method === 'send');
789
+ expect(sends).toHaveLength(1);
790
+ const lines = sends[0]?.args[1] as RawLine[];
791
+ expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
792
+ expect(conn.userModes.oper).toBe(true);
793
+ // Critical: never 421 for OPER.
794
+ for (const s of sends) {
795
+ for (const line of s.args[1] as RawLine[]) {
796
+ expect(line.text).not.toContain(' 421 ');
797
+ }
798
+ }
799
+ });
800
+
801
+ it('routes OPER with wrong creds through operReducer (464, never 421)', async () => {
802
+ const { actor, runtime } = makeActor({
803
+ operCreds: [{ user: 'alice', password: 'secret' }],
804
+ });
805
+ runtime.calls.length = 0;
806
+
807
+ await actor.receiveTextFrame('OPER alice wrong\r\n');
808
+
809
+ const sends = runtime.calls.filter((c) => c.method === 'send');
810
+ expect(sends).toHaveLength(1);
811
+ const lines = sends[0]?.args[1] as RawLine[];
812
+ expect(lines[0]?.text).toContain(' 464 ');
813
+ for (const s of sends) {
814
+ for (const line of s.args[1] as RawLine[]) {
815
+ expect(line.text).not.toContain(' 421 ');
816
+ }
817
+ }
818
+ });
819
+
820
+ it('routes OPER with no configured creds through operReducer (491, never 421)', async () => {
821
+ const { actor, runtime } = makeActor({});
822
+ runtime.calls.length = 0;
823
+
824
+ await actor.receiveTextFrame('OPER alice secret\r\n');
825
+
826
+ const sends = runtime.calls.filter((c) => c.method === 'send');
827
+ expect(sends).toHaveLength(1);
828
+ const lines = sends[0]?.args[1] as RawLine[];
829
+ expect(lines[0]?.text).toContain(' 491 ');
830
+ for (const s of sends) {
831
+ for (const line of s.args[1] as RawLine[]) {
832
+ expect(line.text).not.toContain(' 421 ');
833
+ }
834
+ }
835
+ });
836
+
566
837
  it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
567
838
  const { actor, runtime } = makeActor({});
568
839
  await actor.receiveTextFrame('TOPIC\r\n');
@@ -814,3 +1085,407 @@ describe('ConnectionActor — WHO', () => {
814
1085
  expect(lines[0]?.text).toBe(':irc.example.com 403 alice badname :No such channel');
815
1086
  });
816
1087
  });
1088
+
1089
+ describe('ConnectionActor — flood control', () => {
1090
+ // Pre-add the connection to #foo so under-limit PRIVMSG frames broadcast
1091
+ // (makes the pass-through vs. disconnect contrast observable).
1092
+ async function joinFoo(runtime: FakeRuntime): Promise<void> {
1093
+ await runtime.applyChannelDelta('#foo', {
1094
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1095
+ });
1096
+ runtime.calls.length = 0;
1097
+ }
1098
+
1099
+ it('disconnects a client bursting PRIVMSG past the bucket capacity with "Excess Flood"', async () => {
1100
+ const { actor, runtime } = makeActor({});
1101
+ await joinFoo(runtime);
1102
+
1103
+ // Default bucket (capacity 10, threshold 0): the first 10 PRIVMSG pass,
1104
+ // the 11th onward trip the Disconnect path.
1105
+ for (let i = 0; i < 20; i++) {
1106
+ await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
1107
+ }
1108
+
1109
+ const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
1110
+ expect(disconnects.length).toBeGreaterThan(0);
1111
+ expect(disconnects[0]?.args[1]).toBe('Excess Flood');
1112
+ // The close path targets the actor's own connection.
1113
+ expect(disconnects[0]?.args[0]).toBe('c1');
1114
+ });
1115
+
1116
+ it('lets the same burst pass through unchanged when flood control is disabled (floodControl: null)', async () => {
1117
+ const { actor, runtime } = makeActor({ floodControl: null });
1118
+ await joinFoo(runtime);
1119
+
1120
+ for (let i = 0; i < 20; i++) {
1121
+ await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
1122
+ }
1123
+
1124
+ // Disabled → every frame reaches the inner reducer and broadcasts; the
1125
+ // close path is never invoked.
1126
+ expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1127
+ expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(20);
1128
+ });
1129
+
1130
+ it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
1131
+ const { actor, runtime } = makeActor({});
1132
+ await joinFoo(runtime);
1133
+
1134
+ // 10 PRIVMSG drain the default bucket exactly to zero (no disconnect yet).
1135
+ for (let i = 0; i < 10; i++) {
1136
+ await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
1137
+ }
1138
+ expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1139
+
1140
+ // A PING is control-plane traffic: it must not decrement the bucket, so
1141
+ // it neither disconnects here nor consumes the last token. (A flat-cost
1142
+ // wrapper would trip the Disconnect on this very frame.)
1143
+ await actor.receiveTextFrame('PING :keepalive\r\n');
1144
+ expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1145
+
1146
+ // The bucket is still at zero, so the next PRIVMSG is the one that trips
1147
+ // the disconnect — proving the PING was free.
1148
+ await actor.receiveTextFrame('PRIVMSG #foo :one-more\r\n');
1149
+ const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
1150
+ expect(disconnects).toHaveLength(1);
1151
+ expect(disconnects[0]?.args[1]).toBe('Excess Flood');
1152
+ });
1153
+ });
1154
+
1155
+ // ===========================================================================
1156
+ // Observability: structured logs + traceId/connectionId
1157
+ // ===========================================================================
1158
+
1159
+ describe('ConnectionActor — observability', () => {
1160
+ it('emits a structured log record with traceId and connectionId for a processed frame', async () => {
1161
+ const logger = new CapturingLogger();
1162
+ const { actor } = makeActor({ logger, traceId: 'trace-xyz' });
1163
+
1164
+ await actor.receiveTextFrame('PING :tok\r\n');
1165
+
1166
+ const received = logger.records.filter((r) => r.msg === 'frame.receive');
1167
+ expect(received).toHaveLength(1);
1168
+ const rec = received[0];
1169
+ expect(rec?.level).toBe(LogLevel.Debug);
1170
+ expect(rec?.traceId).toBe('trace-xyz');
1171
+ expect(rec?.connectionId).toBe('c1');
1172
+ expect(rec?.fields?.command).toBe('PING');
1173
+ expect(rec?.fields?.lines).toBe(1);
1174
+ });
1175
+
1176
+ it('stamps the same traceId on every record emitted while processing one frame', async () => {
1177
+ const logger = new CapturingLogger();
1178
+ const { actor } = makeActor({ logger, traceId: 'trace-abc' });
1179
+
1180
+ // A joined frame exercises the per-line path; a successful reducer
1181
+ // followed by a parse failure exercises both frame.receive and
1182
+ // frame.parse-error records on the same trace.
1183
+ await actor.receiveTextFrame('PING :tok\r\nBADLINE_7_DIGITS\r\n');
1184
+
1185
+ expect(logger.records.length).toBeGreaterThan(0);
1186
+ for (const rec of logger.records) {
1187
+ expect(rec.traceId).toBe('trace-abc');
1188
+ expect(rec.connectionId).toBe('c1');
1189
+ }
1190
+ });
1191
+
1192
+ it('emits a warn-level frame.parse-error record on a malformed line', async () => {
1193
+ const logger = new CapturingLogger();
1194
+ const { actor } = makeActor({ logger, traceId: 't1' });
1195
+
1196
+ // 5-digit numeric prefix is not a legal IRC command → parse fails.
1197
+ await actor.receiveTextFrame('12345\r\n');
1198
+
1199
+ const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
1200
+ expect(errs).toHaveLength(1);
1201
+ expect(errs[0]?.level).toBe(LogLevel.Warn);
1202
+ expect(errs[0]?.traceId).toBe('t1');
1203
+ expect(typeof errs[0]?.fields?.line).toBe('string');
1204
+ });
1205
+
1206
+ it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
1207
+ // The actor must still function when no logger/traceId is supplied — the
1208
+ // codebase's default. No exceptions, no observable output.
1209
+ const { actor, runtime } = makeActor({});
1210
+ await actor.receiveTextFrame('PING :tok\r\n');
1211
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1212
+ expect(sends).toHaveLength(1);
1213
+ });
1214
+
1215
+ it('allocates a fresh traceId per receiveTextFrame call when none was supplied', async () => {
1216
+ // Each inbound frame gets its own trace id; the actor generates one
1217
+ // itself via IdFactory.traceId() when the caller did not pin one. Two
1218
+ // frames → two distinct ids.
1219
+ const logger = new CapturingLogger();
1220
+ const { actor } = makeActor({ logger });
1221
+
1222
+ await actor.receiveTextFrame('PING :a\r\n');
1223
+ await actor.receiveTextFrame('PING :b\r\n');
1224
+
1225
+ const ids = new Set(logger.records.map((r) => r.traceId));
1226
+ expect(ids.size).toBe(2);
1227
+ });
1228
+
1229
+ it('logs dispatch effects with the bound traceId', async () => {
1230
+ const logger = new CapturingLogger();
1231
+ const { actor, runtime } = makeActor({ logger, traceId: 'trace-d1' });
1232
+ await runtime.applyChannelDelta('#foo', {
1233
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1234
+ });
1235
+
1236
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1237
+
1238
+ const dispatchLogs = logger.records.filter((r) => r.msg === 'dispatch');
1239
+ expect(dispatchLogs.length).toBeGreaterThan(0);
1240
+ for (const rec of dispatchLogs) {
1241
+ expect(rec.traceId).toBe('trace-d1');
1242
+ expect(rec.fields?.effects).toEqual(expect.any(Array));
1243
+ }
1244
+ });
1245
+ });
1246
+
1247
+ // ===========================================================================
1248
+ // CHATHISTORY — dispatcher routing + MessageStore plumbing
1249
+ // ===========================================================================
1250
+
1251
+ describe('ConnectionActor — CHATHISTORY', () => {
1252
+ /** The full cap set a chathistory-capable client negotiates. */
1253
+ const CHATHISTORY_CAPS = new Set<string>([
1254
+ 'draft/chathistory',
1255
+ 'server-time',
1256
+ 'message-tags',
1257
+ 'batch',
1258
+ ]);
1259
+
1260
+ /** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
1261
+ function seedMessages(store: MessageStore, chan: string, count: number): StoredMessage[] {
1262
+ const out: StoredMessage[] = [];
1263
+ for (let i = 1; i <= count; i++) {
1264
+ const m: StoredMessage = {
1265
+ msgid: `m${i}`,
1266
+ time: i * 1_000,
1267
+ chan: chan.toLowerCase(),
1268
+ command: 'PRIVMSG',
1269
+ nick: 'bob',
1270
+ user: 'bob',
1271
+ host: 'ex.org',
1272
+ text: `msg ${i}`,
1273
+ };
1274
+ store.record(m);
1275
+ out.push(m);
1276
+ }
1277
+ return out;
1278
+ }
1279
+
1280
+ it('routes CHATHISTORY LATEST to the reducer, emitting a BATCH chathistory frame (never 421)', async () => {
1281
+ const store = new InMemoryMessageStore();
1282
+ const { actor, runtime } = makeActor({
1283
+ conn: { caps: CHATHISTORY_CAPS },
1284
+ messages: store,
1285
+ });
1286
+ // Create the channel so the peek resolves it (no creation side-effect
1287
+ // during the query itself).
1288
+ runtime.getOrCreateChannel('#foo');
1289
+ seedMessages(store, '#foo', 2);
1290
+ runtime.calls.length = 0;
1291
+
1292
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
1293
+
1294
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1295
+ expect(sends).toHaveLength(1);
1296
+ const lines = sends[0]?.args[1] as RawLine[];
1297
+ // BATCH +id chathistory #foo … BATCH -id (id from the shared id factory).
1298
+ expect(lines[0]?.text).toMatch(/^BATCH \+batch-\d+ chathistory #foo$/);
1299
+ const batchId = lines[0]?.text.match(/^BATCH \+batch-(\d+)/)?.[1];
1300
+ expect(lines[lines.length - 1]?.text).toBe(`BATCH -batch-${batchId}`);
1301
+ // Two replay lines between the batch markers.
1302
+ expect(lines).toHaveLength(4);
1303
+ expect(lines[1]?.text).toContain('PRIVMSG #foo :msg 1');
1304
+ expect(lines[2]?.text).toContain('PRIVMSG #foo :msg 2');
1305
+ // Critical: never 421 for a cap-enabled CHATHISTORY query.
1306
+ for (const line of lines) {
1307
+ expect(line.text).not.toContain(' 421 ');
1308
+ }
1309
+ });
1310
+
1311
+ it('still emits 421 for CHATHISTORY when the cap is not negotiated (reducer cap-gating)', async () => {
1312
+ const store = new InMemoryMessageStore();
1313
+ const { actor, runtime } = makeActor({
1314
+ conn: { caps: new Set<string>() },
1315
+ messages: store,
1316
+ });
1317
+ runtime.getOrCreateChannel('#foo');
1318
+ seedMessages(store, '#foo', 2);
1319
+ runtime.calls.length = 0;
1320
+
1321
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
1322
+
1323
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1324
+ expect(sends).toHaveLength(1);
1325
+ const lines = sends[0]?.args[1] as RawLine[];
1326
+ expect(lines[0]?.text).toContain(' 421 ');
1327
+ expect(lines[0]?.text).toContain('CHATHISTORY');
1328
+ });
1329
+
1330
+ it('emits 403 ERR_NOSUCHCHANNEL for an unknown channel target', async () => {
1331
+ const store = new InMemoryMessageStore();
1332
+ const { actor, runtime } = makeActor({
1333
+ conn: { caps: CHATHISTORY_CAPS },
1334
+ messages: store,
1335
+ });
1336
+ // Deliberately do NOT create #nope.
1337
+ runtime.calls.length = 0;
1338
+
1339
+ await actor.receiveTextFrame('CHATHISTORY LATEST #nope * 2\r\n');
1340
+
1341
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1342
+ expect(sends).toHaveLength(1);
1343
+ const lines = sends[0]?.args[1] as RawLine[];
1344
+ expect(lines[0]?.text).toContain(' 403 ');
1345
+ expect(lines[0]?.text).toContain('#nope');
1346
+ });
1347
+
1348
+ it('does not crash and emits nothing when no store is bound (ctx.messages undefined)', async () => {
1349
+ const { actor, runtime } = makeActor({
1350
+ conn: { caps: CHATHISTORY_CAPS },
1351
+ // No `messages` option → ctx.messages is undefined.
1352
+ });
1353
+ runtime.getOrCreateChannel('#foo');
1354
+ runtime.calls.length = 0;
1355
+
1356
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
1357
+
1358
+ // No store → the reducer's query returns [] → empty batch elided → no
1359
+ // Send effects. Proves the no-store path is a graceful no-op.
1360
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1361
+ expect(sends).toHaveLength(0);
1362
+ });
1363
+
1364
+ it('records a channel PRIVMSG into the bound store (JOIN auto-playback data source)', async () => {
1365
+ const store = new InMemoryMessageStore();
1366
+ const { actor, runtime } = makeActor({ messages: store });
1367
+ await runtime.applyChannelDelta('#foo', {
1368
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1369
+ });
1370
+ runtime.calls.length = 0;
1371
+
1372
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1373
+
1374
+ const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
1375
+ expect(recorded).toHaveLength(1);
1376
+ expect(recorded[0]?.text).toBe('hello');
1377
+ expect(recorded[0]?.command).toBe('PRIVMSG');
1378
+ expect(recorded[0]?.nick).toBe('alice');
1379
+ });
1380
+
1381
+ it('does not record a PRIVMSG when no store is bound (unchanged behaviour)', async () => {
1382
+ const { actor, runtime } = makeActor({});
1383
+ await runtime.applyChannelDelta('#foo', {
1384
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1385
+ });
1386
+ runtime.calls.length = 0;
1387
+
1388
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1389
+
1390
+ // Broadcast still happens (the message is delivered live)…
1391
+ expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
1392
+ // …but there is no store to record into.
1393
+ });
1394
+ });
1395
+
1396
+ // ===========================================================================
1397
+ // SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
1398
+ // ===========================================================================
1399
+
1400
+ describe('ConnectionActor — SASL AccountStore plumbing', () => {
1401
+ /** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
1402
+ class FakeAccountStore implements AccountStore {
1403
+ readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
1404
+ constructor(private readonly result: SaslResult) {}
1405
+ verify(mech: string, payload: SaslPayload): SaslResult {
1406
+ this.calls.push({ mech, payload });
1407
+ return this.result;
1408
+ }
1409
+ }
1410
+
1411
+ /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
1412
+ function plainPayload(authcid: string, password: string): string {
1413
+ return encodeBase64(`\0${authcid}\0${password}`);
1414
+ }
1415
+
1416
+ /** A connection that has opened CAP negotiation and requested `sasl`. */
1417
+ function saslReadyConn(): Partial<ConnectionState> {
1418
+ return {
1419
+ registration: 'pre-registration',
1420
+ capNegotiating: true,
1421
+ caps: new Set<string>(['sasl']),
1422
+ };
1423
+ }
1424
+
1425
+ it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
1426
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1427
+ const { actor, runtime, conn } = makeActor({
1428
+ conn: saslReadyConn(),
1429
+ accounts,
1430
+ });
1431
+ conn.nick = 'alice';
1432
+ runtime.calls.length = 0;
1433
+
1434
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1435
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
1436
+
1437
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1438
+ // Second frame emits the success numerics; gather every sent line.
1439
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1440
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
1441
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
1442
+ // Critical: never 904 for valid creds.
1443
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
1444
+ // The AccountStore was consulted with the parsed PLAIN credentials.
1445
+ expect(accounts.calls).toEqual([
1446
+ { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
1447
+ ]);
1448
+ // Account name recorded on the connection for extended-join/account-tag.
1449
+ expect(conn.account).toBe('alice');
1450
+ });
1451
+
1452
+ it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
1453
+ const { actor, runtime, conn } = makeActor({
1454
+ conn: saslReadyConn(),
1455
+ // No `accounts` option → ctx.accounts is undefined.
1456
+ });
1457
+ conn.nick = 'alice';
1458
+ runtime.calls.length = 0;
1459
+
1460
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1461
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
1462
+
1463
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1464
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1465
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
1466
+ // No success numerics.
1467
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(false);
1468
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
1469
+ // Account name never recorded.
1470
+ expect(conn.account).toBeUndefined();
1471
+ });
1472
+
1473
+ it('emits 904 when a bound AccountStore rejects the credentials', async () => {
1474
+ const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1475
+ const { actor, runtime, conn } = makeActor({
1476
+ conn: saslReadyConn(),
1477
+ accounts,
1478
+ });
1479
+ conn.nick = 'alice';
1480
+ runtime.calls.length = 0;
1481
+
1482
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1483
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'wrong')}\r\n`);
1484
+
1485
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1486
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1487
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
1488
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
1489
+ expect(conn.account).toBeUndefined();
1490
+ });
1491
+ });