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
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AccountStore,
2
3
  CapturingLogger,
3
4
  type ChanName,
4
5
  type ChanSnapshot,
@@ -11,24 +12,33 @@ import {
11
12
  FakeClock,
12
13
  type FloodControlConfig,
13
14
  InMemoryMessageStore,
15
+ InMemoryNickHistoryStore,
14
16
  LogLevel,
15
17
  type Logger,
16
18
  type MessageStore,
19
+ type MtlsIdentityProvider,
17
20
  type Nick,
21
+ type NickHistoryStore,
18
22
  Numerics,
19
23
  type RawLine,
24
+ type SaslPayload,
25
+ type SaslResult,
20
26
  SequentialIdFactory,
21
27
  type ServerConfig,
22
28
  type StoredMessage,
23
29
  applyChannelDelta as applyDelta,
24
30
  createChannel,
25
31
  createConnection,
32
+ encodeBase64,
26
33
  isChannelTarget,
27
34
  toChannelSnapshot,
35
+ toSnapshot,
28
36
  } from '@serverless-ircd/irc-core';
29
37
  import { describe, expect, it } from 'vitest';
30
38
  import { ConnectionActor } from '../src/actor';
31
39
  import type { IrcRuntime } from '../src/runtime';
40
+ import { TcpByteStreamTransport, WsTextFrameTransport } from '../src/transport';
41
+ import type { Transport } from '../src/transport';
32
42
 
33
43
  // ---------------------------------------------------------------------------
34
44
  // Fakes
@@ -116,7 +126,8 @@ class FakeRuntime implements IrcRuntime {
116
126
  }
117
127
  async getConnectionInfo(conn: ConnId): Promise<ConnSnapshot | null> {
118
128
  this.calls.push({ method: 'getConnectionInfo', args: [conn] });
119
- return null;
129
+ const state = this.testConnections.get(conn);
130
+ return state === undefined ? null : toSnapshot(state);
120
131
  }
121
132
  async getConnection(conn: ConnId): Promise<ConnectionState | null> {
122
133
  this.calls.push({ method: 'getConnection', args: [conn] });
@@ -154,6 +165,11 @@ function makeActor(opts: {
154
165
  logger?: Logger;
155
166
  traceId?: string;
156
167
  messages?: MessageStore;
168
+ accounts?: AccountStore;
169
+ mtlsIdentity?: MtlsIdentityProvider;
170
+ history?: NickHistoryStore;
171
+ operCreds?: ReadonlyArray<{ user: string; password: string }>;
172
+ transport?: Transport;
157
173
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
158
174
  const clock = opts.clock ?? new FakeClock(1_000);
159
175
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -179,6 +195,7 @@ function makeActor(opts: {
179
195
  topicLen: 390,
180
196
  quitMessage: 'Client Quit',
181
197
  ...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
198
+ ...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
182
199
  };
183
200
  const ids = new SequentialIdFactory();
184
201
  type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
@@ -203,6 +220,18 @@ function makeActor(opts: {
203
220
  if (opts.messages !== undefined) {
204
221
  finalOpts.messages = opts.messages;
205
222
  }
223
+ if (opts.accounts !== undefined) {
224
+ finalOpts.accounts = opts.accounts;
225
+ }
226
+ if (opts.mtlsIdentity !== undefined) {
227
+ finalOpts.mtlsIdentity = opts.mtlsIdentity;
228
+ }
229
+ if (opts.history !== undefined) {
230
+ finalOpts.history = opts.history;
231
+ }
232
+ if (opts.transport !== undefined) {
233
+ finalOpts.transport = opts.transport;
234
+ }
206
235
  const actor = new ConnectionActor(finalOpts);
207
236
  return { actor, runtime, conn };
208
237
  }
@@ -266,6 +295,112 @@ describe('ConnectionActor — framing', () => {
266
295
  });
267
296
  });
268
297
 
298
+ describe('ConnectionActor — TCP byte-stream transport seam', () => {
299
+ it('two TCP chunks split mid-line produce exactly one PRIVMSG broadcast', async () => {
300
+ const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
301
+ await runtime.applyChannelDelta('#foo', {
302
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
303
+ });
304
+ runtime.calls.length = 0;
305
+
306
+ // Split the single message mid-payload across two chunks. The partial
307
+ // line is buffered across the first chunk; only the second completes it.
308
+ await actor.receive('PRIVMSG #foo :hel');
309
+ expect(runtime.calls).toHaveLength(0);
310
+ await actor.receive('lo\r\n');
311
+
312
+ const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
313
+ expect(broadcasts).toHaveLength(1);
314
+ expect(broadcasts[0]?.args[0]).toBe('#foo');
315
+ const lines = broadcasts[0]?.args[1] as RawLine[];
316
+ expect(lines[0]?.text).toBe(':alice!alice@example.com PRIVMSG #foo :hello');
317
+ });
318
+
319
+ it('one message per WS text frame and one per CRLF over TCP produce identical effects', async () => {
320
+ // Same bytes, same connection setup, two transports. The WS actor gets
321
+ // the whole frame in one receiveTextFrame; the TCP actor gets the same
322
+ // bytes split across arbitrary chunks via receive().
323
+ const wsSetup = makeActor({});
324
+ const tcpSetup = makeActor({ transport: new TcpByteStreamTransport() });
325
+ for (const rt of [wsSetup.runtime, tcpSetup.runtime]) {
326
+ await rt.applyChannelDelta('#foo', {
327
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
328
+ });
329
+ }
330
+ wsSetup.runtime.calls.length = 0;
331
+ tcpSetup.runtime.calls.length = 0;
332
+
333
+ const stream = 'PRIVMSG #foo :hello\r\n';
334
+ await wsSetup.actor.receiveTextFrame(stream);
335
+ // Feed the TCP actor the same bytes in 4-byte chunks.
336
+ for (let i = 0; i < stream.length; i += 4) {
337
+ await tcpSetup.actor.receive(stream.slice(i, i + 4));
338
+ }
339
+
340
+ const wsBroadcast = wsSetup.runtime.calls.filter((c) => c.method === 'broadcast');
341
+ const tcpBroadcast = tcpSetup.runtime.calls.filter((c) => c.method === 'broadcast');
342
+ expect(tcpBroadcast).toHaveLength(wsBroadcast.length);
343
+ expect((tcpBroadcast[0]?.args[1] as RawLine[])[0]?.text).toBe(
344
+ (wsBroadcast[0]?.args[1] as RawLine[])[0]?.text,
345
+ );
346
+ });
347
+
348
+ it('buffers a partial line across multiple chunks with no loss or duplication', async () => {
349
+ const { actor, runtime } = makeActor({ transport: new TcpByteStreamTransport() });
350
+ await runtime.applyChannelDelta('#foo', {
351
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
352
+ });
353
+ runtime.calls.length = 0;
354
+
355
+ // Three messages, each fed byte-by-byte. Every complete line is emitted
356
+ // exactly once; nothing is lost or duplicated at the chunk boundaries.
357
+ const stream = 'PING :a\r\nPRIVMSG #foo :b\r\nPING :c\r\n';
358
+ for (const ch of stream) {
359
+ await actor.receive(ch);
360
+ }
361
+ const sends = runtime.calls.filter((c) => c.method === 'send');
362
+ const broadcasts = runtime.calls.filter((c) => c.method === 'broadcast');
363
+ expect(sends).toHaveLength(2); // PONG :a, PONG :c
364
+ expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :a');
365
+ expect((sends[1]?.args[1] as RawLine[])[0]?.text).toBe('PONG :c');
366
+ expect(broadcasts).toHaveLength(1); // PRIVMSG #foo :b
367
+ expect((broadcasts[0]?.args[1] as RawLine[])[0]?.text).toBe(
368
+ ':alice!alice@example.com PRIVMSG #foo :b',
369
+ );
370
+ });
371
+
372
+ it('uses the default WS-frame transport when none is injected (backward compat)', () => {
373
+ // No `transport` option → the actor still frames WS text frames exactly
374
+ // as before. Confirms the default is the stateless WS transport.
375
+ const { actor, runtime } = makeActor({});
376
+ // A trailing unterminated line is still emitted by the WS transport
377
+ // (frame = complete unit), so this produces one PONG with no terminator.
378
+ return actor.receiveTextFrame('PING :tok').then(() => {
379
+ const sends = runtime.calls.filter((c) => c.method === 'send');
380
+ expect(sends).toHaveLength(1);
381
+ expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe('PONG :tok');
382
+ });
383
+ });
384
+
385
+ it('a TCP actor does NOT emit a trailing unterminated line until the terminator arrives', async () => {
386
+ const tcpActor = makeActor({ transport: new TcpByteStreamTransport() });
387
+ const wsActor = makeActor({ transport: new WsTextFrameTransport() });
388
+ tcpActor.runtime.calls.length = 0;
389
+ wsActor.runtime.calls.length = 0;
390
+
391
+ // WS treats the frame as complete → emits a PONG. TCP buffers the
392
+ // unterminated line → emits nothing yet. This is the defining
393
+ // behavioural difference of stateful byte-stream framing.
394
+ await wsActor.actor.receiveTextFrame('PING :unterminated');
395
+ await tcpActor.actor.receive('PING :unterminated');
396
+ expect(wsActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
397
+ expect(tcpActor.runtime.calls).toHaveLength(0);
398
+ // …until the terminator completes the line.
399
+ await tcpActor.actor.receive('\r\n');
400
+ expect(tcpActor.runtime.calls.filter((c) => c.method === 'send')).toHaveLength(1);
401
+ });
402
+ });
403
+
269
404
  describe('ConnectionActor — malformed input', () => {
270
405
  it('emits 421 for an unparseable line and continues processing the rest', async () => {
271
406
  const { actor, runtime } = makeActor({});
@@ -478,6 +613,57 @@ describe('ConnectionActor — additional routing', () => {
478
613
  expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
479
614
  });
480
615
 
616
+ it('routes TAGMSG to a channel broadcast (never 421) when message-tags cap is present', async () => {
617
+ const bobConn = createConnection({ id: 'c2', connectedSince: 0 });
618
+ bobConn.nick = 'bob';
619
+ bobConn.user = 'bob';
620
+ bobConn.host = 'example.org';
621
+ bobConn.registration = 'registered';
622
+ bobConn.caps = new Set<string>(['message-tags']);
623
+ const { actor, runtime } = makeActor({
624
+ conn: { caps: new Set<string>(['message-tags']) },
625
+ });
626
+ runtime.addConnection(bobConn);
627
+ await runtime.applyChannelDelta('#foo', {
628
+ memberships: [
629
+ { type: 'add', conn: 'c1', nick: 'alice' },
630
+ { type: 'add', conn: 'c2', nick: 'bob' },
631
+ ],
632
+ });
633
+ runtime.calls.length = 0;
634
+ await actor.receiveTextFrame('@+typing=active TAGMSG #foo\r\n');
635
+
636
+ // Cap-gated broadcast dispatches per-recipient via send (only to
637
+ // message-tags-enabled members, excluding the sender).
638
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c2');
639
+ expect(sends).toHaveLength(1);
640
+ const lines = sends[0]?.args[1] as RawLine[];
641
+ expect(lines[0]?.text).toBe('@+typing=active :alice!alice@example.com TAGMSG #foo');
642
+ // Critical: never 421 for a cap-enabled TAGMSG.
643
+ for (const call of runtime.calls) {
644
+ if (call.method === 'send') {
645
+ for (const line of call.args[1] as RawLine[]) {
646
+ expect(line.text).not.toContain(' 421 ');
647
+ }
648
+ }
649
+ }
650
+ });
651
+
652
+ it('still emits 421 for TAGMSG when message-tags cap is not negotiated', async () => {
653
+ const { actor, runtime } = makeActor({});
654
+ await runtime.applyChannelDelta('#foo', {
655
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
656
+ });
657
+ runtime.calls.length = 0;
658
+ await actor.receiveTextFrame('TAGMSG #foo\r\n');
659
+
660
+ const sends = runtime.calls.filter((c) => c.method === 'send');
661
+ expect(sends).toHaveLength(1);
662
+ const lines = sends[0]?.args[1] as RawLine[];
663
+ expect(lines[0]?.text).toContain(' 421 ');
664
+ expect(lines[0]?.text).toContain('TAGMSG');
665
+ });
666
+
481
667
  it('routes PRIVMSG with mixed targets (#chan,nick) into 2 invocations', async () => {
482
668
  const { actor, runtime } = makeActor({});
483
669
  await runtime.applyChannelDelta('#foo', {
@@ -599,6 +785,76 @@ describe('ConnectionActor — additional routing', () => {
599
785
  }
600
786
  });
601
787
 
788
+ it('routes WHOWAS of a quit nick through whowasReducer (314 + 369, never 421)', async () => {
789
+ const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
790
+ history.record({
791
+ nick: 'bob',
792
+ connId: 'c2',
793
+ username: 'bob',
794
+ hostname: 'example.com',
795
+ realname: 'Bob',
796
+ signoffTime: 500,
797
+ });
798
+ const { actor, runtime } = makeActor({ history });
799
+ runtime.calls.length = 0;
800
+
801
+ await actor.receiveTextFrame('WHOWAS bob\r\n');
802
+
803
+ const sends = runtime.calls.filter((c) => c.method === 'send');
804
+ expect(sends.length).toBeGreaterThanOrEqual(2);
805
+ const firstBatch = sends[0]?.args[1] as RawLine[];
806
+ expect(firstBatch[0]?.text).toContain(' 314 ');
807
+ expect(firstBatch[0]?.text).toContain(' bob ');
808
+ const endBatch = sends[sends.length - 1]?.args[1] as RawLine[];
809
+ expect(endBatch[0]?.text).toContain(' 369 ');
810
+ for (const s of sends) {
811
+ for (const line of s.args[1] as RawLine[]) {
812
+ expect(line.text).not.toContain(' 421 ');
813
+ }
814
+ }
815
+ });
816
+
817
+ it('routes WHOWAS of an unknown nick to 406 then 369 (never 421)', async () => {
818
+ const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
819
+ const { actor, runtime } = makeActor({ history });
820
+ runtime.calls.length = 0;
821
+
822
+ await actor.receiveTextFrame('WHOWAS ghost\r\n');
823
+
824
+ const sends = runtime.calls.filter((c) => c.method === 'send');
825
+ expect(sends).toHaveLength(2);
826
+ const notFound = sends[0]?.args[1] as RawLine[];
827
+ expect(notFound[0]?.text).toContain(' 406 ');
828
+ expect(notFound[0]?.text).toContain(' ghost ');
829
+ const end = sends[1]?.args[1] as RawLine[];
830
+ expect(end[0]?.text).toContain(' 369 ');
831
+ });
832
+
833
+ it('still emits 406 + 369 for WHOWAS when no history store is bound (no crash)', async () => {
834
+ const { actor, runtime } = makeActor({});
835
+ runtime.calls.length = 0;
836
+
837
+ await actor.receiveTextFrame('WHOWAS ghost\r\n');
838
+
839
+ const sends = runtime.calls.filter((c) => c.method === 'send');
840
+ expect(sends).toHaveLength(2);
841
+ const notFound = sends[0]?.args[1] as RawLine[];
842
+ expect(notFound[0]?.text).toContain(' 406 ');
843
+ const end = sends[1]?.args[1] as RawLine[];
844
+ expect(end[0]?.text).toContain(' 369 ');
845
+ });
846
+
847
+ it('records a nick into history on QUIT when a store is bound', async () => {
848
+ const history = new InMemoryNickHistoryStore(new FakeClock(1_000));
849
+ const { actor } = makeActor({ history });
850
+
851
+ await actor.receiveTextFrame('QUIT\r\n');
852
+
853
+ const entries = history.query('alice', 10);
854
+ expect(entries).toHaveLength(1);
855
+ expect(entries[0]?.nick).toBe('alice');
856
+ });
857
+
602
858
  it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
603
859
  // Pre-registration: client has opened CAP negotiation and requested sasl.
604
860
  // AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
@@ -712,6 +968,63 @@ describe('ConnectionActor — additional routing', () => {
712
968
  }
713
969
  });
714
970
 
971
+ it('routes OPER with matching creds through operReducer (381, never 421)', async () => {
972
+ const { actor, runtime, conn } = makeActor({
973
+ operCreds: [{ user: 'alice', password: 'secret' }],
974
+ });
975
+ runtime.calls.length = 0;
976
+
977
+ await actor.receiveTextFrame('OPER alice secret\r\n');
978
+
979
+ const sends = runtime.calls.filter((c) => c.method === 'send');
980
+ expect(sends).toHaveLength(1);
981
+ const lines = sends[0]?.args[1] as RawLine[];
982
+ expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
983
+ expect(conn.userModes.oper).toBe(true);
984
+ // Critical: never 421 for OPER.
985
+ for (const s of sends) {
986
+ for (const line of s.args[1] as RawLine[]) {
987
+ expect(line.text).not.toContain(' 421 ');
988
+ }
989
+ }
990
+ });
991
+
992
+ it('routes OPER with wrong creds through operReducer (464, never 421)', async () => {
993
+ const { actor, runtime } = makeActor({
994
+ operCreds: [{ user: 'alice', password: 'secret' }],
995
+ });
996
+ runtime.calls.length = 0;
997
+
998
+ await actor.receiveTextFrame('OPER alice wrong\r\n');
999
+
1000
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1001
+ expect(sends).toHaveLength(1);
1002
+ const lines = sends[0]?.args[1] as RawLine[];
1003
+ expect(lines[0]?.text).toContain(' 464 ');
1004
+ for (const s of sends) {
1005
+ for (const line of s.args[1] as RawLine[]) {
1006
+ expect(line.text).not.toContain(' 421 ');
1007
+ }
1008
+ }
1009
+ });
1010
+
1011
+ it('routes OPER with no configured creds through operReducer (491, never 421)', async () => {
1012
+ const { actor, runtime } = makeActor({});
1013
+ runtime.calls.length = 0;
1014
+
1015
+ await actor.receiveTextFrame('OPER alice secret\r\n');
1016
+
1017
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1018
+ expect(sends).toHaveLength(1);
1019
+ const lines = sends[0]?.args[1] as RawLine[];
1020
+ expect(lines[0]?.text).toContain(' 491 ');
1021
+ for (const s of sends) {
1022
+ for (const line of s.args[1] as RawLine[]) {
1023
+ expect(line.text).not.toContain(' 421 ');
1024
+ }
1025
+ }
1026
+ });
1027
+
715
1028
  it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
716
1029
  const { actor, runtime } = makeActor({});
717
1030
  await actor.receiveTextFrame('TOPIC\r\n');
@@ -1270,3 +1583,306 @@ describe('ConnectionActor — CHATHISTORY', () => {
1270
1583
  // …but there is no store to record into.
1271
1584
  });
1272
1585
  });
1586
+
1587
+ // ===========================================================================
1588
+ // SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
1589
+ // ===========================================================================
1590
+
1591
+ describe('ConnectionActor — SASL AccountStore plumbing', () => {
1592
+ /** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
1593
+ class FakeAccountStore implements AccountStore {
1594
+ readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
1595
+ constructor(private readonly result: SaslResult) {}
1596
+ verify(mech: string, payload: SaslPayload): SaslResult {
1597
+ this.calls.push({ mech, payload });
1598
+ return this.result;
1599
+ }
1600
+ }
1601
+
1602
+ /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
1603
+ function plainPayload(authcid: string, password: string): string {
1604
+ return encodeBase64(`\0${authcid}\0${password}`);
1605
+ }
1606
+
1607
+ /** A connection that has opened CAP negotiation and requested `sasl`. */
1608
+ function saslReadyConn(): Partial<ConnectionState> {
1609
+ return {
1610
+ registration: 'pre-registration',
1611
+ capNegotiating: true,
1612
+ caps: new Set<string>(['sasl']),
1613
+ };
1614
+ }
1615
+
1616
+ it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
1617
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1618
+ const { actor, runtime, conn } = makeActor({
1619
+ conn: saslReadyConn(),
1620
+ accounts,
1621
+ });
1622
+ conn.nick = 'alice';
1623
+ runtime.calls.length = 0;
1624
+
1625
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1626
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
1627
+
1628
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1629
+ // Second frame emits the success numerics; gather every sent line.
1630
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1631
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
1632
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
1633
+ // Critical: never 904 for valid creds.
1634
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
1635
+ // The AccountStore was consulted with the parsed PLAIN credentials.
1636
+ expect(accounts.calls).toEqual([
1637
+ { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
1638
+ ]);
1639
+ // Account name recorded on the connection for extended-join/account-tag.
1640
+ expect(conn.account).toBe('alice');
1641
+ });
1642
+
1643
+ it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
1644
+ const { actor, runtime, conn } = makeActor({
1645
+ conn: saslReadyConn(),
1646
+ // No `accounts` option → ctx.accounts is undefined.
1647
+ });
1648
+ conn.nick = 'alice';
1649
+ runtime.calls.length = 0;
1650
+
1651
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1652
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'secret')}\r\n`);
1653
+
1654
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1655
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1656
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
1657
+ // No success numerics.
1658
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(false);
1659
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
1660
+ // Account name never recorded.
1661
+ expect(conn.account).toBeUndefined();
1662
+ });
1663
+
1664
+ it('emits 904 when a bound AccountStore rejects the credentials', async () => {
1665
+ const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1666
+ const { actor, runtime, conn } = makeActor({
1667
+ conn: saslReadyConn(),
1668
+ accounts,
1669
+ });
1670
+ conn.nick = 'alice';
1671
+ runtime.calls.length = 0;
1672
+
1673
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
1674
+ await actor.receiveTextFrame(`AUTHENTICATE ${plainPayload('alice', 'wrong')}\r\n`);
1675
+
1676
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1677
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1678
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(true);
1679
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(false);
1680
+ expect(conn.account).toBeUndefined();
1681
+ });
1682
+ });
1683
+
1684
+ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
1685
+ /** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
1686
+ class FakeAccountStore implements AccountStore {
1687
+ readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
1688
+ constructor(private readonly result: SaslResult) {}
1689
+ verify(mech: string, payload: SaslPayload): SaslResult {
1690
+ this.calls.push({ mech, payload });
1691
+ return this.result;
1692
+ }
1693
+ }
1694
+
1695
+ /** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
1696
+ class FakeMtlsProvider implements MtlsIdentityProvider {
1697
+ constructor(private readonly identity: string | undefined) {}
1698
+ getIdentity(connId: string): string | undefined {
1699
+ return connId === 'c1' ? this.identity : undefined;
1700
+ }
1701
+ }
1702
+
1703
+ function saslReadyConn(): Partial<ConnectionState> {
1704
+ return {
1705
+ registration: 'pre-registration',
1706
+ capNegotiating: true,
1707
+ caps: new Set<string>(['sasl']),
1708
+ };
1709
+ }
1710
+
1711
+ it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
1712
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1713
+ const mtlsIdentity = new FakeMtlsProvider('CN=alice');
1714
+ const { actor, runtime, conn } = makeActor({
1715
+ conn: saslReadyConn(),
1716
+ accounts,
1717
+ mtlsIdentity,
1718
+ });
1719
+ conn.nick = 'alice';
1720
+ runtime.calls.length = 0;
1721
+
1722
+ await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
1723
+ await actor.receiveTextFrame('AUTHENTICATE +\r\n');
1724
+
1725
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1726
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1727
+ expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
1728
+ expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
1729
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
1730
+ expect(accounts.calls).toEqual([
1731
+ { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
1732
+ ]);
1733
+ expect(conn.account).toBe('alice');
1734
+ });
1735
+
1736
+ it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
1737
+ const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1738
+ const { actor, runtime, conn } = makeActor({
1739
+ conn: saslReadyConn(),
1740
+ accounts,
1741
+ // No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
1742
+ });
1743
+ conn.nick = 'alice';
1744
+ runtime.calls.length = 0;
1745
+
1746
+ await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
1747
+
1748
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1749
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
1750
+ expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
1751
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
1752
+ expect(conn.account).toBeUndefined();
1753
+ });
1754
+ });
1755
+
1756
+ describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
1757
+ it('routes VERSION through versionReducer (351, never 421)', async () => {
1758
+ const { actor, runtime } = makeActor({});
1759
+ runtime.calls.length = 0;
1760
+
1761
+ await actor.receiveTextFrame('VERSION\r\n');
1762
+
1763
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1764
+ expect(sends).toHaveLength(1);
1765
+ const lines = sends[0]?.args[1] as RawLine[];
1766
+ expect(lines[0]?.text).toContain(' 351 ');
1767
+ expect(lines[0]?.text).not.toContain(' 421 ');
1768
+ });
1769
+
1770
+ it('routes TIME through timeReducer (391, never 421)', async () => {
1771
+ const { actor, runtime } = makeActor({});
1772
+ runtime.calls.length = 0;
1773
+
1774
+ await actor.receiveTextFrame('TIME\r\n');
1775
+
1776
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1777
+ expect(sends).toHaveLength(1);
1778
+ const lines = sends[0]?.args[1] as RawLine[];
1779
+ expect(lines[0]?.text).toContain(' 391 ');
1780
+ expect(lines[0]?.text).not.toContain(' 421 ');
1781
+ });
1782
+
1783
+ it('routes ADMIN through adminReducer (256, never 421)', async () => {
1784
+ const { actor, runtime } = makeActor({});
1785
+ runtime.calls.length = 0;
1786
+
1787
+ await actor.receiveTextFrame('ADMIN\r\n');
1788
+
1789
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1790
+ expect(sends).toHaveLength(1);
1791
+ const lines = sends[0]?.args[1] as RawLine[];
1792
+ expect(lines[0]?.text).toContain(' 256 ');
1793
+ expect(lines[0]?.text).not.toContain(' 421 ');
1794
+ });
1795
+
1796
+ it('routes INFO through infoReducer (371 + 374, never 421)', async () => {
1797
+ const { actor, runtime } = makeActor({});
1798
+ runtime.calls.length = 0;
1799
+
1800
+ await actor.receiveTextFrame('INFO\r\n');
1801
+
1802
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1803
+ expect(sends).toHaveLength(1);
1804
+ const lines = sends[0]?.args[1] as RawLine[];
1805
+ expect(lines.some((l) => l.text.includes(' 371 '))).toBe(true);
1806
+ expect(lines.some((l) => l.text.includes(' 374 '))).toBe(true);
1807
+ expect(lines.every((l) => !l.text.includes(' 421 '))).toBe(true);
1808
+ });
1809
+
1810
+ it('routes USERHOST resolving online nicks (302, never 421)', async () => {
1811
+ const { actor, runtime, conn } = makeActor({});
1812
+ const bobState = createConnection({ id: 'c2', connectedSince: 0 });
1813
+ bobState.nick = 'bob';
1814
+ bobState.user = 'bobuser';
1815
+ bobState.host = 'bobhost';
1816
+ bobState.registration = 'registered';
1817
+ runtime.addConnection(conn);
1818
+ runtime.addConnection(bobState);
1819
+ runtime.calls.length = 0;
1820
+
1821
+ await actor.receiveTextFrame('USERHOST bob ghost\r\n');
1822
+
1823
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1824
+ expect(sends).toHaveLength(1);
1825
+ const lines = sends[0]?.args[1] as RawLine[];
1826
+ expect(lines[0]?.text).toContain(' 302 ');
1827
+ expect(lines[0]?.text).toContain('bob=+bobuser@bobhost');
1828
+ // Offline nick `ghost` is omitted from the reply.
1829
+ expect(lines[0]?.text).not.toContain('ghost');
1830
+ expect(lines[0]?.text).not.toContain(' 421 ');
1831
+ });
1832
+
1833
+ it('USERHOST marks away users with - and opers with *', async () => {
1834
+ const { actor, runtime, conn } = makeActor({});
1835
+ const bobState = createConnection({ id: 'c2', connectedSince: 0 });
1836
+ bobState.nick = 'bob';
1837
+ bobState.user = 'bobuser';
1838
+ bobState.host = 'bobhost';
1839
+ bobState.away = 'brb';
1840
+ bobState.userModes.oper = true;
1841
+ bobState.registration = 'registered';
1842
+ runtime.addConnection(conn);
1843
+ runtime.addConnection(bobState);
1844
+ runtime.calls.length = 0;
1845
+
1846
+ await actor.receiveTextFrame('USERHOST bob\r\n');
1847
+
1848
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1849
+ const lines = sends[0]?.args[1] as RawLine[];
1850
+ expect(lines[0]?.text).toContain('bob*=-bobuser@bobhost');
1851
+ });
1852
+
1853
+ it('routes ISON resolving online nicks (303, never 421)', async () => {
1854
+ const { actor, runtime, conn } = makeActor({});
1855
+ const bobState = createConnection({ id: 'c2', connectedSince: 0 });
1856
+ bobState.nick = 'bob';
1857
+ bobState.registration = 'registered';
1858
+ runtime.addConnection(conn);
1859
+ runtime.addConnection(bobState);
1860
+ runtime.calls.length = 0;
1861
+
1862
+ await actor.receiveTextFrame('ISON bob ghost\r\n');
1863
+
1864
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1865
+ expect(sends).toHaveLength(1);
1866
+ const lines = sends[0]?.args[1] as RawLine[];
1867
+ expect(lines[0]?.text).toContain(' 303 ');
1868
+ expect(lines[0]?.text).toContain('bob');
1869
+ expect(lines[0]?.text).not.toContain('ghost');
1870
+ expect(lines[0]?.text).not.toContain(' 421 ');
1871
+ });
1872
+
1873
+ it('ISON matches case-insensitively and reports the registered spelling', async () => {
1874
+ const { actor, runtime, conn } = makeActor({});
1875
+ const bobState = createConnection({ id: 'c2', connectedSince: 0 });
1876
+ bobState.nick = 'Bob';
1877
+ bobState.registration = 'registered';
1878
+ runtime.addConnection(conn);
1879
+ runtime.addConnection(bobState);
1880
+ runtime.calls.length = 0;
1881
+
1882
+ await actor.receiveTextFrame('ISON BOB\r\n');
1883
+
1884
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1885
+ const lines = sends[0]?.args[1] as RawLine[];
1886
+ expect(lines[0]?.text).toContain(':Bob');
1887
+ });
1888
+ });