serverless-ircd 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -16,7 +16,7 @@ import {
16
16
  InMemoryMessageStore,
17
17
  InMemoryMonitorStore,
18
18
  InMemoryNickHistoryStore,
19
- InMemoryReadMarkerStore,
19
+ InMemoryServicesStore,
20
20
  LogLevel,
21
21
  type Logger,
22
22
  type MessageStore,
@@ -26,18 +26,19 @@ import {
26
26
  type NickHistoryStore,
27
27
  Numerics,
28
28
  type RawLine,
29
- type ReadMarkerStore,
30
29
  type SaslPayload,
31
30
  type SaslResult,
32
31
  SequentialIdFactory,
33
32
  type ServerConfig,
34
33
  type ServerStats,
35
34
  type ServerStatsSnapshot,
35
+ type ServicesStore,
36
36
  type StoredMessage,
37
37
  applyChannelDelta as applyDelta,
38
38
  createChannel,
39
39
  createConnection,
40
40
  encodeBase64,
41
+ formatServerTime,
41
42
  isChannelTarget,
42
43
  toChannelSnapshot,
43
44
  toSnapshot,
@@ -228,7 +229,7 @@ function makeActor(opts: {
228
229
  secure?: boolean;
229
230
  monitor?: MonitorStore;
230
231
  away?: AwayStore;
231
- readMarkers?: ReadMarkerStore;
232
+ services?: ServicesStore;
232
233
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
233
234
  const clock = opts.clock ?? new FakeClock(1_000);
234
235
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -306,8 +307,8 @@ function makeActor(opts: {
306
307
  if (opts.away !== undefined) {
307
308
  finalOpts.away = opts.away;
308
309
  }
309
- if (opts.readMarkers !== undefined) {
310
- finalOpts.readMarkers = opts.readMarkers;
310
+ if (opts.services !== undefined) {
311
+ finalOpts.services = opts.services;
311
312
  }
312
313
  const actor = new ConnectionActor(finalOpts);
313
314
  return { actor, runtime, conn };
@@ -907,12 +908,13 @@ describe('ConnectionActor — additional routing', () => {
907
908
  expect(lines[0]?.text).toContain('462');
908
909
  });
909
910
 
910
- it('routes PASS to passReducer (stashes the attempt)', async () => {
911
+ it('routes PASS to passReducer (bare post-registration PASS is a no-op)', async () => {
911
912
  const { actor, runtime } = makeActor({});
912
913
  await actor.receiveTextFrame('PASS sekrat\r\n');
913
- // passReducer on an already-registered conn emits a 462 Send.
914
+ // passReducer on a registered conn with no AccountStore treats a bare
915
+ // server-password PASS as a silent no-op (no 462, no disconnect).
914
916
  const sends = runtime.calls.filter((c) => c.method === 'send');
915
- expect(sends).toHaveLength(1);
917
+ expect(sends).toHaveLength(0);
916
918
  });
917
919
 
918
920
  it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
@@ -1229,9 +1231,13 @@ describe('ConnectionActor — additional routing', () => {
1229
1231
  await actor.receiveTextFrame('OPER alice secret\r\n');
1230
1232
 
1231
1233
  const sends = runtime.calls.filter((c) => c.method === 'send');
1232
- expect(sends).toHaveLength(1);
1233
- const lines = sends[0]?.args[1] as RawLine[];
1234
- expect(lines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
1234
+ // OPER success emits two Send effects: the 381 numeric, then a
1235
+ // `:<hostmask> MODE <nick> +o` echo (mirrors the user MODE echo).
1236
+ expect(sends).toHaveLength(2);
1237
+ const firstLines = sends[0]?.args[1] as RawLine[];
1238
+ expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
1239
+ const secondLines = sends[1]?.args[1] as RawLine[];
1240
+ expect(secondLines[0]?.text).toBe(':alice!alice@example.com MODE alice +o');
1235
1241
  expect(conn.userModes.oper).toBe(true);
1236
1242
  // Critical: never 421 for OPER.
1237
1243
  for (const s of sends) {
@@ -1307,6 +1313,7 @@ describe('ConnectionActor — additional routing', () => {
1307
1313
  wallops: false,
1308
1314
  serverNotices: false,
1309
1315
  tls: false,
1316
+ registered: false,
1310
1317
  },
1311
1318
  },
1312
1319
  });
@@ -1359,6 +1366,7 @@ describe('ConnectionActor — additional routing', () => {
1359
1366
  wallops: false,
1360
1367
  serverNotices: false,
1361
1368
  tls: false,
1369
+ registered: false,
1362
1370
  },
1363
1371
  },
1364
1372
  });
@@ -1387,6 +1395,7 @@ describe('ConnectionActor — additional routing', () => {
1387
1395
  wallops: false,
1388
1396
  serverNotices: false,
1389
1397
  tls: false,
1398
+ registered: false,
1390
1399
  },
1391
1400
  },
1392
1401
  configSource: 'KV',
@@ -1456,6 +1465,7 @@ describe('ConnectionActor — additional routing', () => {
1456
1465
  wallops: false,
1457
1466
  serverNotices: false,
1458
1467
  tls: false,
1468
+ registered: false,
1459
1469
  },
1460
1470
  },
1461
1471
  configSource: 'KV',
@@ -1495,6 +1505,7 @@ describe('ConnectionActor — additional routing', () => {
1495
1505
  wallops: false,
1496
1506
  serverNotices: false,
1497
1507
  tls: false,
1508
+ registered: false,
1498
1509
  },
1499
1510
  },
1500
1511
  configSource: 'KV',
@@ -1528,6 +1539,7 @@ describe('ConnectionActor — additional routing', () => {
1528
1539
  wallops: false,
1529
1540
  serverNotices: false,
1530
1541
  tls: false,
1542
+ registered: false,
1531
1543
  },
1532
1544
  },
1533
1545
  configSource: 'KV',
@@ -4020,9 +4032,9 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
4020
4032
  expect(away.get('alice')).toBeUndefined();
4021
4033
  });
4022
4034
 
4023
- it('persists a +draft/read-marker TAGMSG to the bound ReadMarkerStore for an identified member', async () => {
4024
- const readMarkers = new InMemoryReadMarkerStore();
4025
- const { actor, runtime, conn } = makeActor({ readMarkers });
4035
+ it('persists a +draft/read-marker TAGMSG to the bound ServicesStore for an identified member', async () => {
4036
+ const services = new InMemoryServicesStore();
4037
+ const { actor, runtime, conn } = makeActor({ services });
4026
4038
  conn.account = 'alice';
4027
4039
  conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4028
4040
  await setupMultilineChannel(runtime, [{ conn: 'c1', nick: 'alice' }]);
@@ -4030,7 +4042,258 @@ describe('ConnectionActor — ctx store plumbing (draft/pre-away, draft/read-mar
4030
4042
 
4031
4043
  await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
4032
4044
 
4033
- expect(readMarkers.get('alice', '#foo')).toBe('msg-42');
4045
+ expect(services.getLastReadMarker('alice', '#foo')).toBe('msg-42');
4046
+ });
4047
+ });
4048
+
4049
+ // ---------------------------------------------------------------------------
4050
+ // draft/read-marker — account-scoped fanout (spec compliance)
4051
+ //
4052
+ // The ircv3 draft/read-marker spec mandates that a read marker is private to
4053
+ // the originator's account: the mark TAGMSG is delivered only to the
4054
+ // originator's own connections, never to other accounts in the channel. The
4055
+ // reducer annotates the Broadcast with the originator account; dispatch
4056
+ // restricts the recipient set accordingly. These end-to-end tests drive the
4057
+ // full actor → reducer → dispatch → FakeRuntime path.
4058
+ // ---------------------------------------------------------------------------
4059
+
4060
+ describe('ConnectionActor — draft/read-marker account-scoped fanout', () => {
4061
+ function addIdentifiedMember(
4062
+ runtime: FakeRuntime,
4063
+ connId: ConnId,
4064
+ nick: string,
4065
+ account: string | undefined,
4066
+ caps: ReadonlyArray<string>,
4067
+ ): ConnectionState {
4068
+ const state = createConnection({ id: connId, connectedSince: 0 });
4069
+ state.nick = nick;
4070
+ state.user = nick;
4071
+ state.host = `${nick}.example`;
4072
+ state.realname = nick;
4073
+ state.registration = 'registered';
4074
+ state.caps = new Set<string>(caps);
4075
+ if (account !== undefined) state.account = account;
4076
+ runtime.addConnection(state);
4077
+ return state;
4078
+ }
4079
+
4080
+ async function seedChannel(
4081
+ runtime: FakeRuntime,
4082
+ members: ReadonlyArray<{ conn: ConnId; nick: string }>,
4083
+ ): Promise<void> {
4084
+ await runtime.applyChannelDelta('#foo', {
4085
+ memberships: members.map((m) => ({ type: 'add' as const, conn: m.conn, nick: m.nick })),
4086
+ });
4087
+ }
4088
+
4089
+ it('delivers the mark ONLY to same-account members; other accounts never receive it', async () => {
4090
+ const { actor, runtime, conn } = makeActor({});
4091
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4092
+ conn.account = 'alice';
4093
+ // c2 = alice's other session (same account); c3 = bob (different account).
4094
+ addIdentifiedMember(runtime, 'c2', 'alice2', 'alice', ['message-tags', 'draft/read-marker']);
4095
+ addIdentifiedMember(runtime, 'c3', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
4096
+ await seedChannel(runtime, [
4097
+ { conn: 'c1', nick: 'alice' },
4098
+ { conn: 'c2', nick: 'alice2' },
4099
+ { conn: 'c3', nick: 'bob' },
4100
+ ]);
4101
+ runtime.calls.length = 0;
4102
+
4103
+ await actor.receiveTextFrame('@+draft/read-marker=msg-42 TAGMSG #foo\r\n');
4104
+
4105
+ // alice's other session (c2) receives the mark...
4106
+ expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-42'))).toBe(
4107
+ true,
4108
+ );
4109
+ // ...bob (c3, different account) never does.
4110
+ expect(sentTo(runtime, 'c3')).toEqual([]);
4111
+ });
4112
+
4113
+ it("delivers the mark to the originator's other session on the same account (multi-device sync)", async () => {
4114
+ const { actor, runtime, conn } = makeActor({});
4115
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4116
+ conn.account = 'alice';
4117
+ addIdentifiedMember(runtime, 'c2', 'alice-laptop', 'alice', [
4118
+ 'message-tags',
4119
+ 'draft/read-marker',
4120
+ ]);
4121
+ await seedChannel(runtime, [
4122
+ { conn: 'c1', nick: 'alice' },
4123
+ { conn: 'c2', nick: 'alice-laptop' },
4124
+ ]);
4125
+ runtime.calls.length = 0;
4126
+
4127
+ await actor.receiveTextFrame('@+draft/read-marker=msg-99 TAGMSG #foo\r\n');
4128
+
4129
+ expect(sentTo(runtime, 'c2').some((l) => l.text.includes('+draft/read-marker=msg-99'))).toBe(
4130
+ true,
4131
+ );
4132
+ });
4133
+
4134
+ it('does NOT fan out a mark from an unidentified originator to anyone', async () => {
4135
+ const { actor, runtime, conn } = makeActor({});
4136
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4137
+ // originator unidentified (no account).
4138
+ addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
4139
+ addIdentifiedMember(runtime, 'c3', 'carol', undefined, ['message-tags', 'draft/read-marker']);
4140
+ await seedChannel(runtime, [
4141
+ { conn: 'c1', nick: 'alice' },
4142
+ { conn: 'c2', nick: 'bob' },
4143
+ { conn: 'c3', nick: 'carol' },
4144
+ ]);
4145
+ runtime.calls.length = 0;
4146
+
4147
+ await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
4148
+
4149
+ // No recipient receives the mark (session-local only).
4150
+ expect(sentTo(runtime, 'c2')).toEqual([]);
4151
+ expect(sentTo(runtime, 'c3')).toEqual([]);
4152
+ });
4153
+
4154
+ it('self-echoes the mark to an unidentified originator only when echo-message is negotiated', async () => {
4155
+ const { actor, runtime, conn } = makeActor({});
4156
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker', 'echo-message']);
4157
+ // originator unidentified (no account).
4158
+ addIdentifiedMember(runtime, 'c2', 'bob', 'bob', ['message-tags', 'draft/read-marker']);
4159
+ await seedChannel(runtime, [
4160
+ { conn: 'c1', nick: 'alice' },
4161
+ { conn: 'c2', nick: 'bob' },
4162
+ ]);
4163
+ runtime.calls.length = 0;
4164
+
4165
+ await actor.receiveTextFrame('@+draft/read-marker=msg-1 TAGMSG #foo\r\n');
4166
+
4167
+ // No fanout to bob...
4168
+ expect(sentTo(runtime, 'c2')).toEqual([]);
4169
+ // ...but the originator sees its own mark reflected back via echo-message.
4170
+ expect(sentTo(runtime, 'c1').some((l) => l.text.includes('+draft/read-marker=msg-1'))).toBe(
4171
+ true,
4172
+ );
4173
+ });
4174
+
4175
+ it('keeps the +draft/read-marker tag for a draft/read-marker-only same-account peer via the filterClientTags whitelist', async () => {
4176
+ const { actor, runtime, conn } = makeActor({});
4177
+ conn.caps = new Set<string>(['message-tags', 'draft/read-marker']);
4178
+ conn.account = 'alice';
4179
+ // c2 announced ONLY draft/read-marker (not message-tags). The whitelist
4180
+ // in filterClientTags keeps the +draft/read-marker client tag for it.
4181
+ addIdentifiedMember(runtime, 'c2', 'alice-min', 'alice', ['draft/read-marker']);
4182
+ await seedChannel(runtime, [
4183
+ { conn: 'c1', nick: 'alice' },
4184
+ { conn: 'c2', nick: 'alice-min' },
4185
+ ]);
4186
+ runtime.calls.length = 0;
4187
+
4188
+ await actor.receiveTextFrame('@+draft/read-marker=msg-5 TAGMSG #foo\r\n');
4189
+
4190
+ const lines = sentTo(runtime, 'c2');
4191
+ expect(lines).toHaveLength(1);
4192
+ // The tag survives the whitelist for a draft/read-marker-only peer.
4193
+ expect(lines[0]?.text).toBe('@+draft/read-marker=msg-5 :alice!alice@example.com TAGMSG #foo');
4194
+ });
4195
+ });
4196
+
4197
+ // ---------------------------------------------------------------------------
4198
+ // draft/read-marker — MARKREAD verb (draft-spec client compatibility)
4199
+ //
4200
+ // The deployment stores read markers by msgid but accepts the draft's
4201
+ // timestamped MARKREAD verb from cap-enabled clients, translating the
4202
+ // timestamp to the most recent retained message at or before it. These
4203
+ // end-to-end tests drive the full actor -> reducer path and assert the verb
4204
+ // is routed (never 421 for a cap-enabled client).
4205
+ // ---------------------------------------------------------------------------
4206
+
4207
+ describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
4208
+ const TS = (ms: number): string => `timestamp=${formatServerTime(ms)}`;
4209
+
4210
+ /** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
4211
+ function seedMessages(store: MessageStore, chan: string, count: number): void {
4212
+ for (let i = 1; i <= count; i++) {
4213
+ const m: StoredMessage = {
4214
+ msgid: `m${i}`,
4215
+ time: i * 1_000,
4216
+ chan: chan.toLowerCase(),
4217
+ command: 'PRIVMSG',
4218
+ nick: 'bob',
4219
+ user: 'bob',
4220
+ host: 'ex.org',
4221
+ text: `msg ${i}`,
4222
+ };
4223
+ store.record(m);
4224
+ }
4225
+ }
4226
+
4227
+ it('routes MARKREAD to the reducer (never 421) and echoes the resolved timestamp', async () => {
4228
+ const store = new InMemoryMessageStore();
4229
+ const { actor, runtime, conn } = makeActor({ messages: store });
4230
+ conn.caps = new Set<string>(['draft/read-marker']);
4231
+ await runtime.applyChannelDelta('#foo', {
4232
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
4233
+ });
4234
+ seedMessages(store, '#foo', 3); // m1=1s, m2=2s, m3=3s
4235
+ runtime.calls.length = 0;
4236
+
4237
+ await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
4238
+
4239
+ const sends = runtime.calls.filter((c) => c.method === 'send');
4240
+ expect(sends).toHaveLength(1);
4241
+ const lines = sends[0]?.args[1] as RawLine[];
4242
+ expect(lines[0]?.text).toBe(`:irc.example.com MARKREAD #foo ${TS(2_000)}`);
4243
+ // Critical: never 421 for a cap-enabled MARKREAD.
4244
+ expect(lines[0]?.text).not.toContain(' 421 ');
4245
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
4246
+ });
4247
+
4248
+ it('still emits 421 for MARKREAD when the cap is not negotiated (reducer cap-gating)', async () => {
4249
+ const store = new InMemoryMessageStore();
4250
+ const { actor, runtime } = makeActor({ messages: store });
4251
+ // No draft/read-marker cap.
4252
+ await runtime.applyChannelDelta('#foo', {
4253
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
4254
+ });
4255
+ seedMessages(store, '#foo', 3);
4256
+ runtime.calls.length = 0;
4257
+
4258
+ await actor.receiveTextFrame(`MARKREAD #foo ${TS(2_000)}\r\n`);
4259
+
4260
+ const sends = runtime.calls.filter((c) => c.method === 'send');
4261
+ expect(sends).toHaveLength(1);
4262
+ const lines = sends[0]?.args[1] as RawLine[];
4263
+ expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
4264
+ });
4265
+
4266
+ it('silently ignores a nick-target MARKREAD (read markers are channel-scoped)', async () => {
4267
+ const store = new InMemoryMessageStore();
4268
+ const { actor, runtime, conn } = makeActor({ messages: store });
4269
+ conn.caps = new Set<string>(['draft/read-marker']);
4270
+ runtime.calls.length = 0;
4271
+
4272
+ // A nick (non-channel) target falls through to the `return []` arm — the
4273
+ // deployment has no nick-scoped read markers, so the verb is a no-op
4274
+ // rather than a 421 or a FAIL.
4275
+ await actor.receiveTextFrame(`MARKREAD alice ${TS(2_000)}\r\n`);
4276
+
4277
+ expect(runtime.calls).toHaveLength(0);
4278
+ });
4279
+
4280
+ it('routes a parameterless MARKREAD through the reducer (NEED_MORE_PARAMS, never 421 fallthrough)', async () => {
4281
+ const store = new InMemoryMessageStore();
4282
+ const { actor, runtime, conn } = makeActor({ messages: store });
4283
+ conn.caps = new Set<string>(['draft/read-marker']);
4284
+ runtime.calls.length = 0;
4285
+
4286
+ // No params at all → `msg.params[0] ?? ''` falls back to the empty
4287
+ // string, which is routed into the reducer so it can FAIL
4288
+ // NEED_MORE_PARAMS rather than hitting the generic 421 fallthrough.
4289
+ await actor.receiveTextFrame('MARKREAD\r\n');
4290
+
4291
+ const sends = runtime.calls.filter((c) => c.method === 'send');
4292
+ expect(sends).toHaveLength(1);
4293
+ const lines = sends[0]?.args[1] as RawLine[];
4294
+ expect(lines[0]?.text).toContain('NEED_MORE_PARAMS');
4295
+ // Critical: never the generic 421 fallthrough for a cap-enabled client.
4296
+ expect(lines[0]?.text).not.toContain(' 421 ');
4034
4297
  });
4035
4298
  });
4036
4299
 
@@ -4367,3 +4630,713 @@ describe('ConnectionActor — draft/multiline chathistory recording', () => {
4367
4630
  expect(runtime.calls.some((c) => c.method === 'getChannelSnapshot')).toBe(true);
4368
4631
  });
4369
4632
  });
4633
+
4634
+ // ============================================================================
4635
+ // NickServ routing — PRIVMSG NickServ :<command>
4636
+ // ============================================================================
4637
+
4638
+ describe('ConnectionActor — NickServ routing', () => {
4639
+ it('routes PRIVMSG NickServ :IDENTIFY to the NickServ reducer and sets +r', async () => {
4640
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4641
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4642
+ const { actor, runtime, conn } = makeActor({ services });
4643
+ runtime.calls.length = 0;
4644
+
4645
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
4646
+
4647
+ expect(conn.account).toBe('alice');
4648
+ expect(conn.userModes.registered).toBe(true);
4649
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4650
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4651
+ expect(allText).toContain(
4652
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
4653
+ );
4654
+ });
4655
+
4656
+ it('routes PRIVMSG NickServ :REGISTER and confirms via NOTICE', async () => {
4657
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4658
+ const { actor, runtime } = makeActor({ services });
4659
+ runtime.calls.length = 0;
4660
+
4661
+ await actor.receiveTextFrame('PRIVMSG NickServ :REGISTER hunter2 alice@example.com\r\n');
4662
+
4663
+ expect(services.isRegisteredNick('alice')).toBe(true);
4664
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4665
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4666
+ expect(allText).toContain(
4667
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
4668
+ );
4669
+ });
4670
+
4671
+ it('does NOT route NOTICE NickServ to the NickServ reducer (RFC: NOTICE is silent)', async () => {
4672
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4673
+ const { actor, runtime, conn } = makeActor({ services });
4674
+ runtime.calls.length = 0;
4675
+
4676
+ await actor.receiveTextFrame('NOTICE NickServ :IDENTIFY hunter2\r\n');
4677
+
4678
+ // NOTICE to NickServ falls through to the normal user-target path (no
4679
+ // NickServ NOTICE emitted, no state change).
4680
+ expect(conn.account).toBeUndefined();
4681
+ const nickservNotices = runtime.calls.filter(
4682
+ (c) =>
4683
+ c.method === 'send' &&
4684
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('NickServ!NickServ@services')),
4685
+ );
4686
+ expect(nickservNotices).toHaveLength(0);
4687
+ });
4688
+
4689
+ it('treats PRIVMSG NickServ as a normal nick-target when no services store is bound', async () => {
4690
+ const { actor, runtime, conn } = makeActor({});
4691
+ runtime.calls.length = 0;
4692
+
4693
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
4694
+
4695
+ // Without a services store, NickServ is just a nick target → the normal
4696
+ // user-target reducer emits a SendToNick with a 401 fallback.
4697
+ expect(conn.account).toBeUndefined();
4698
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4699
+ expect(toNick).toHaveLength(1);
4700
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4701
+ expect(notFound?.[0]?.text).toContain('401');
4702
+ });
4703
+
4704
+ it('routes a case-insensitive PRIVMSG nickserv :… to the NickServ reducer', async () => {
4705
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4706
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4707
+ const { actor, conn } = makeActor({ services });
4708
+
4709
+ await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
4710
+
4711
+ expect(conn.account).toBe('alice');
4712
+ });
4713
+ });
4714
+
4715
+ // ============================================================================
4716
+ // ChanServ routing — PRIVMSG ChanServ :<command>
4717
+ // ============================================================================
4718
+
4719
+ describe('ConnectionActor — ChanServ routing', () => {
4720
+ it('routes PRIVMSG ChanServ :REGISTER and applies the +r ApplyChannelDelta', async () => {
4721
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4722
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4723
+ const { actor, runtime, conn } = makeActor({ services });
4724
+ conn.account = 'alice';
4725
+ conn.userModes.registered = true;
4726
+ runtime.calls.length = 0;
4727
+
4728
+ await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
4729
+
4730
+ expect(services.getChannelFounder('#home')).toBe('alice');
4731
+ const applyDeltas = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
4732
+ expect(applyDeltas.length).toBeGreaterThan(0);
4733
+ });
4734
+
4735
+ it('routes PRIVMSG ChanServ :SET KEEPTOPIC #home ON and persists', async () => {
4736
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4737
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4738
+ services.registerChannel('#home', 'alice');
4739
+ const { actor, conn } = makeActor({ services });
4740
+ conn.account = 'alice';
4741
+ conn.userModes.registered = true;
4742
+
4743
+ await actor.receiveTextFrame('PRIVMSG ChanServ :SET KEEPTOPIC #home ON\r\n');
4744
+
4745
+ expect(services.getChannel('#home')?.keepTopic).toBe(true);
4746
+ });
4747
+
4748
+ it('does NOT route NOTICE ChanServ to the ChanServ reducer', async () => {
4749
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4750
+ const { actor, runtime } = makeActor({ services });
4751
+ runtime.calls.length = 0;
4752
+
4753
+ await actor.receiveTextFrame('NOTICE ChanServ :REGISTER #home\r\n');
4754
+
4755
+ const chanservNotices = runtime.calls.filter(
4756
+ (c) =>
4757
+ c.method === 'send' &&
4758
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('ChanServ!ChanServ@services')),
4759
+ );
4760
+ expect(chanservNotices).toHaveLength(0);
4761
+ });
4762
+
4763
+ it('treats PRIVMSG ChanServ as a normal nick-target when no services store is bound', async () => {
4764
+ const { actor, runtime } = makeActor({});
4765
+ runtime.calls.length = 0;
4766
+
4767
+ await actor.receiveTextFrame('PRIVMSG ChanServ :REGISTER #home\r\n');
4768
+
4769
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4770
+ expect(toNick).toHaveLength(1);
4771
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4772
+ expect(notFound?.[0]?.text).toContain('401');
4773
+ });
4774
+
4775
+ it('routes a case-insensitive PRIVMSG chanserv :… to the ChanServ reducer', async () => {
4776
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4777
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4778
+ const { actor, conn } = makeActor({ services });
4779
+ conn.account = 'alice';
4780
+ conn.userModes.registered = true;
4781
+
4782
+ await actor.receiveTextFrame('PRIVMSG chanserv :REGISTER #home\r\n');
4783
+
4784
+ expect(services.getChannelFounder('#home')).toBe('alice');
4785
+ });
4786
+ });
4787
+
4788
+ // ============================================================================
4789
+ // MemoServ routing — PRIVMSG MemoServ :<command>
4790
+ // ============================================================================
4791
+
4792
+ describe('ConnectionActor — MemoServ routing', () => {
4793
+ it('routes PRIVMSG MemoServ :SEND and records the memo against the recipient', async () => {
4794
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4795
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4796
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
4797
+ const { actor, conn } = makeActor({ services });
4798
+ conn.account = 'alice';
4799
+ conn.userModes.registered = true;
4800
+
4801
+ await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi from actor\r\n');
4802
+
4803
+ const memos = services.listMemos('bob');
4804
+ expect(memos).toHaveLength(1);
4805
+ expect(memos[0]?.body).toBe('Hi from actor');
4806
+ });
4807
+
4808
+ it('delivers a queued memo via MemoServ NOTICE on NickServ IDENTIFY', async () => {
4809
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4810
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4811
+ services.recordMemo({ from: 'bob', to: 'alice', body: 'queued', sentAt: 1_000 });
4812
+ const { actor, runtime } = makeActor({ services });
4813
+ runtime.calls.length = 0;
4814
+
4815
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
4816
+
4817
+ const memoNotices = runtime.calls.filter(
4818
+ (c) =>
4819
+ c.method === 'send' &&
4820
+ (c.args[1] as RawLine[]).some((l) =>
4821
+ l.text.includes('MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] queued'),
4822
+ ),
4823
+ );
4824
+ expect(memoNotices).toHaveLength(1);
4825
+ });
4826
+
4827
+ it('does NOT route NOTICE MemoServ to the MemoServ reducer', async () => {
4828
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4829
+ const { actor, runtime } = makeActor({ services });
4830
+ runtime.calls.length = 0;
4831
+
4832
+ await actor.receiveTextFrame('NOTICE MemoServ :SEND bob Hi\r\n');
4833
+
4834
+ const memoservNotices = runtime.calls.filter(
4835
+ (c) =>
4836
+ c.method === 'send' &&
4837
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('MemoServ!MemoServ@services')),
4838
+ );
4839
+ expect(memoservNotices).toHaveLength(0);
4840
+ });
4841
+
4842
+ it('treats PRIVMSG MemoServ as a normal nick-target when no services store is bound', async () => {
4843
+ const { actor, runtime } = makeActor({});
4844
+ runtime.calls.length = 0;
4845
+
4846
+ await actor.receiveTextFrame('PRIVMSG MemoServ :SEND bob Hi\r\n');
4847
+
4848
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4849
+ expect(toNick).toHaveLength(1);
4850
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4851
+ expect(notFound?.[0]?.text).toContain('401');
4852
+ });
4853
+
4854
+ it('routes a case-insensitive PRIVMSG memoserv :… to the MemoServ reducer', async () => {
4855
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4856
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
4857
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
4858
+ const { actor, conn } = makeActor({ services });
4859
+ conn.account = 'alice';
4860
+ conn.userModes.registered = true;
4861
+
4862
+ await actor.receiveTextFrame('PRIVMSG memoserv :SEND bob lower-case\r\n');
4863
+
4864
+ expect(services.listMemos('bob')[0]?.body).toBe('lower-case');
4865
+ });
4866
+ });
4867
+
4868
+ // ============================================================================
4869
+ // OperServ routing — PRIVMSG OperServ :<command> (oper-gated management)
4870
+ // ============================================================================
4871
+
4872
+ describe('ConnectionActor — OperServ routing', () => {
4873
+ it('routes PRIVMSG OperServ :AKILL LIST from an oper to the OperServ reducer (not 401)', async () => {
4874
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4875
+ const { actor, runtime, conn } = makeActor({ services });
4876
+ conn.userModes.oper = true;
4877
+ runtime.calls.length = 0;
4878
+
4879
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
4880
+
4881
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4882
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4883
+ expect(allText).toContain(
4884
+ ':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
4885
+ );
4886
+ // Critical: never 401 for a routed OperServ PRIVMSG.
4887
+ for (const t of allText) {
4888
+ expect(t).not.toContain(' 401 ');
4889
+ }
4890
+ });
4891
+
4892
+ it('round-trips oper AKILL ADD / DEL via PRIVMSG OperServ', async () => {
4893
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4894
+ const { actor, runtime, conn } = makeActor({ services });
4895
+ conn.userModes.oper = true;
4896
+ runtime.calls.length = 0;
4897
+
4898
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
4899
+ expect(services.listAkills()).toHaveLength(1);
4900
+ const addSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4901
+ const addText = addSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4902
+ expect(addText).toContain(
4903
+ ':OperServ!OperServ@services NOTICE alice :AKILL *bad@host added (reason: flooding).',
4904
+ );
4905
+
4906
+ runtime.calls.length = 0;
4907
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL DEL *bad@host\r\n');
4908
+ expect(services.listAkills()).toHaveLength(0);
4909
+ const delSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4910
+ const delText = delSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4911
+ expect(delText).toContain(':OperServ!OperServ@services NOTICE alice :AKILL *bad@host removed.');
4912
+ });
4913
+
4914
+ it('round-trips oper JUPE / UNJUPE via PRIVMSG OperServ', async () => {
4915
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4916
+ const { actor, runtime, conn } = makeActor({ services });
4917
+ conn.userModes.oper = true;
4918
+ runtime.calls.length = 0;
4919
+
4920
+ await actor.receiveTextFrame('PRIVMSG OperServ :JUPE malkovich\r\n');
4921
+ expect(services.isJuped('malkovich')).toBe(true);
4922
+ const jupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4923
+ const jupeText = jupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4924
+ expect(jupeText).toContain(':OperServ!OperServ@services NOTICE alice :JUPE malkovich added.');
4925
+
4926
+ runtime.calls.length = 0;
4927
+ await actor.receiveTextFrame('PRIVMSG OperServ :UNJUPE malkovich\r\n');
4928
+ expect(services.isJuped('malkovich')).toBe(false);
4929
+ const unjupeSends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4930
+ const unjupeText = unjupeSends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4931
+ expect(unjupeText).toContain(
4932
+ ':OperServ!OperServ@services NOTICE alice :JUPE malkovich removed.',
4933
+ );
4934
+ });
4935
+
4936
+ it('round-trips oper RAW via PRIVMSG OperServ', async () => {
4937
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4938
+ const { actor, runtime, conn } = makeActor({ services });
4939
+ conn.userModes.oper = true;
4940
+ runtime.calls.length = 0;
4941
+
4942
+ await actor.receiveTextFrame('PRIVMSG OperServ :RAW :srv.example.com STATS u\r\n');
4943
+
4944
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4945
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4946
+ expect(allText).toContain(
4947
+ ':OperServ!OperServ@services NOTICE alice :RAW line accepted and logged.',
4948
+ );
4949
+ });
4950
+
4951
+ it('rejects a non-oper PRIVMSG OperServ :AKILL ADD with permission-denied NOTICE (not 401, no state change)', async () => {
4952
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4953
+ const { actor, runtime } = makeActor({ services });
4954
+ // conn.userModes.oper left false
4955
+ runtime.calls.length = 0;
4956
+
4957
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL ADD *bad@host flooding\r\n');
4958
+
4959
+ expect(services.listAkills()).toHaveLength(0);
4960
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
4961
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
4962
+ expect(allText).toContain(
4963
+ ':OperServ!OperServ@services NOTICE alice :Permission denied — oper privileges required.',
4964
+ );
4965
+ for (const t of allText) {
4966
+ expect(t).not.toContain(' 401 ');
4967
+ }
4968
+ });
4969
+
4970
+ it('does NOT route NOTICE OperServ to the OperServ reducer (PRIVMSG-only interception)', async () => {
4971
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
4972
+ const { actor, runtime, conn } = makeActor({ services });
4973
+ conn.userModes.oper = true;
4974
+ runtime.calls.length = 0;
4975
+
4976
+ await actor.receiveTextFrame('NOTICE OperServ :AKILL LIST\r\n');
4977
+
4978
+ // NOTICE to OperServ falls through to the normal user-target path (no
4979
+ // OperServ NOTICE emitted, no state change).
4980
+ const operservNotices = runtime.calls.filter(
4981
+ (c) =>
4982
+ c.method === 'send' &&
4983
+ (c.args[1] as RawLine[]).some((l) => l.text.includes('OperServ!OperServ@services')),
4984
+ );
4985
+ expect(operservNotices).toHaveLength(0);
4986
+ });
4987
+
4988
+ it('treats PRIVMSG OperServ as a normal nick-target when no services store is bound', async () => {
4989
+ const { actor, runtime } = makeActor({});
4990
+ runtime.calls.length = 0;
4991
+
4992
+ await actor.receiveTextFrame('PRIVMSG OperServ :AKILL LIST\r\n');
4993
+
4994
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
4995
+ expect(toNick).toHaveLength(1);
4996
+ const notFound = toNick[0]?.args[3] as RawLine[] | undefined;
4997
+ expect(notFound?.[0]?.text).toContain('401');
4998
+ });
4999
+
5000
+ it('routes a case-insensitive PRIVMSG operserv :… to the OperServ reducer', async () => {
5001
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5002
+ const { actor, runtime, conn } = makeActor({ services });
5003
+ conn.userModes.oper = true;
5004
+ runtime.calls.length = 0;
5005
+
5006
+ await actor.receiveTextFrame('PRIVMSG operserv :AKILL LIST\r\n');
5007
+
5008
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5009
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5010
+ expect(allText).toContain(
5011
+ ':OperServ!OperServ@services NOTICE alice :No AKILLs are currently set.',
5012
+ );
5013
+ });
5014
+ });
5015
+
5016
+ // ============================================================================
5017
+ // echo-message — services PRIVMSG echo (IRCv3)
5018
+ //
5019
+ // When a client that negotiated `echo-message` sends `PRIVMSG <ServiceNick>`
5020
+ // the server must echo the sender's own PRIVMSG line back, BEFORE the service
5021
+ // reducer's NOTICE reply — matching how every other PRIVMSG renders (your
5022
+ // outgoing line first, then any reply). Without this echo,
5023
+ // modern IRCv3 clients optimistically show the outgoing message as "sending"
5024
+ // and flip it to "failed" when no delivery confirmation arrives.
5025
+ // ============================================================================
5026
+
5027
+ describe('ConnectionActor — echo-message on service PRIVMSG', () => {
5028
+ function makeOnlineConn(id: string, nick: string): ConnectionState {
5029
+ const c = createConnection({ id, connectedSince: 0 });
5030
+ c.nick = nick;
5031
+ c.user = nick;
5032
+ c.host = `${nick}.org`;
5033
+ c.realname = nick;
5034
+ c.registration = 'registered';
5035
+ return c;
5036
+ }
5037
+
5038
+ it('echoes the sender PRIVMSG back when echo-message is negotiated (NickServ)', async () => {
5039
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5040
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5041
+ const { actor, runtime } = makeActor({
5042
+ services,
5043
+ conn: { caps: new Set<string>(['echo-message']) },
5044
+ });
5045
+ runtime.calls.length = 0;
5046
+
5047
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5048
+
5049
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5050
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5051
+ expect(allText).toContain(':alice!alice@example.com PRIVMSG NickServ :IDENTIFY hunter2');
5052
+ });
5053
+
5054
+ it('does NOT echo when echo-message is not negotiated', async () => {
5055
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5056
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5057
+ const { actor, runtime } = makeActor({ services });
5058
+ runtime.calls.length = 0;
5059
+
5060
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5061
+
5062
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5063
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5064
+ for (const t of allText) {
5065
+ expect(t).not.toMatch(/PRIVMSG NickServ :IDENTIFY hunter2$/);
5066
+ }
5067
+ });
5068
+
5069
+ it('emits the echo BEFORE the NickServ NOTICE reply (ordering)', async () => {
5070
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5071
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5072
+ const { actor, runtime } = makeActor({
5073
+ services,
5074
+ conn: { caps: new Set<string>(['echo-message']) },
5075
+ });
5076
+ runtime.calls.length = 0;
5077
+
5078
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5079
+
5080
+ const sendCalls = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5081
+ const findIdx = (needle: string): number =>
5082
+ sendCalls.findIndex((c) => (c.args[1] as RawLine[]).some((l) => l.text.includes(needle)));
5083
+ const noticeIdx = findIdx('NickServ!NickServ@services NOTICE');
5084
+ const echoIdx = findIdx('PRIVMSG NickServ :IDENTIFY hunter2');
5085
+ expect(noticeIdx).toBeGreaterThanOrEqual(0);
5086
+ expect(echoIdx).toBeGreaterThanOrEqual(0);
5087
+ expect(echoIdx).toBeLessThan(noticeIdx);
5088
+ });
5089
+
5090
+ it('echoes the sender PRIVMSG back for ChanServ (shared fix, not NickServ-specific)', async () => {
5091
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5092
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5093
+ const { actor, runtime } = makeActor({
5094
+ services,
5095
+ conn: { caps: new Set<string>(['echo-message']) },
5096
+ });
5097
+ runtime.calls.length = 0;
5098
+
5099
+ await actor.receiveTextFrame('PRIVMSG ChanServ :HELP\r\n');
5100
+
5101
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5102
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5103
+ expect(allText).toContain(':alice!alice@example.com PRIVMSG ChanServ :HELP');
5104
+ });
5105
+
5106
+ it('preserves the sender target casing in the echo', async () => {
5107
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5108
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5109
+ const { actor, runtime } = makeActor({
5110
+ services,
5111
+ conn: { caps: new Set<string>(['echo-message']) },
5112
+ });
5113
+ runtime.calls.length = 0;
5114
+
5115
+ await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
5116
+
5117
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5118
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5119
+ expect(allText).toContain(':alice!alice@example.com PRIVMSG nickserv :IDENTIFY hunter2');
5120
+ });
5121
+
5122
+ it('does NOT intercept PRIVMSG to a non-service nick when services are bound', async () => {
5123
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5124
+ const { actor, runtime } = makeActor({ services });
5125
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
5126
+ runtime.calls.length = 0;
5127
+
5128
+ await actor.receiveTextFrame('PRIVMSG bob :hi\r\n');
5129
+
5130
+ // With services bound, a non-service nick still routes through the normal
5131
+ // user-target path (sendToNick), never the service reducer or echo path.
5132
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
5133
+ expect(toNick).toHaveLength(1);
5134
+ });
5135
+
5136
+ it('echoes with empty text when the PRIVMSG carries no trailing parameter', async () => {
5137
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5138
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5139
+ const { actor, runtime } = makeActor({
5140
+ services,
5141
+ conn: { caps: new Set<string>(['echo-message']) },
5142
+ });
5143
+ runtime.calls.length = 0;
5144
+
5145
+ // No `:text` argument → `singleParamMsg.params[1] ?? ''` falls back to an
5146
+ // empty string in the echo line.
5147
+ await actor.receiveTextFrame('PRIVMSG NickServ\r\n');
5148
+
5149
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5150
+ const echo = toAlice
5151
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
5152
+ .find((t) => t.endsWith('PRIVMSG NickServ :'));
5153
+ expect(echo).toBe(':alice!alice@example.com PRIVMSG NickServ :');
5154
+ });
5155
+
5156
+ it('falls back to "?" hostmask in the echo when the sender has no nick', async () => {
5157
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5158
+ const { actor, runtime, conn } = makeActor({
5159
+ services,
5160
+ conn: { caps: new Set<string>(['echo-message']) },
5161
+ });
5162
+ // A connection that negotiated echo-message but has no NICK/USER/HOST —
5163
+ // exercises the `hostmaskOf(state) ?? state.nick ?? '?'` fallback chain
5164
+ // in the service-PRIVMSG echo. `delete` is required because
5165
+ // exactOptionalPropertyTypes forbids `= undefined`.
5166
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
5167
+ delete conn.nick;
5168
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
5169
+ delete conn.user;
5170
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
5171
+ delete conn.host;
5172
+ runtime.calls.length = 0;
5173
+
5174
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5175
+
5176
+ const toSender = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5177
+ const echo = toSender
5178
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
5179
+ .find((t) => t.includes('PRIVMSG NickServ :IDENTIFY hunter2'));
5180
+ // Both `??` arms fire: hostmaskOf returns undefined (no nick), then
5181
+ // state.nick is undefined, so the final '?' fallback is used.
5182
+ expect(echo).toBe(':? PRIVMSG NickServ :IDENTIFY hunter2');
5183
+ });
5184
+ });
5185
+
5186
+ // ============================================================================
5187
+ // Service shortcut commands — NICKSERV/NS/CHANSERV/CS/HOSTSERV/HS/MEMOSERV/MS/
5188
+ // OPERSERV/OS (Atheme/Anope-convention aliases rewritten to
5189
+ // PRIVMSG <ServiceNick> :<joined args> by the dispatcher).
5190
+ // ============================================================================
5191
+
5192
+ describe('ConnectionActor — service shortcut commands', () => {
5193
+ it('NICKSERV IDENTIFY behaves identically to PRIVMSG NickServ :IDENTIFY (never 421)', async () => {
5194
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5195
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5196
+ const { actor, runtime, conn } = makeActor({ services });
5197
+ runtime.calls.length = 0;
5198
+
5199
+ await actor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
5200
+
5201
+ expect(conn.account).toBe('alice');
5202
+ expect(conn.userModes.registered).toBe(true);
5203
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5204
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5205
+ expect(allText).toContain(
5206
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
5207
+ );
5208
+ // Critical: the shortcut must reach the reducer, never the 421 fallthrough.
5209
+ for (const t of allText) {
5210
+ expect(t).not.toContain(' 421 ');
5211
+ }
5212
+ });
5213
+
5214
+ // Each alias must resolve to its service. Sending the bare alias (no args)
5215
+ // reaches the reducer, which emits a help NOTICE sourced from the matching
5216
+ // pseudo-client — so the NOTICE source nick identifies the routed service.
5217
+ const aliasToService: ReadonlyArray<[string, string]> = [
5218
+ ['NICKSERV', 'NickServ'],
5219
+ ['NS', 'NickServ'],
5220
+ ['CHANSERV', 'ChanServ'],
5221
+ ['CS', 'ChanServ'],
5222
+ ['HOSTSERV', 'HostServ'],
5223
+ ['HS', 'HostServ'],
5224
+ ['MEMOSERV', 'MemoServ'],
5225
+ ['MS', 'MemoServ'],
5226
+ ['OPERSERV', 'OperServ'],
5227
+ ['OS', 'OperServ'],
5228
+ ];
5229
+
5230
+ for (const [alias, service] of aliasToService) {
5231
+ it(`${alias} (no args) routes to ${service} via the help NOTICE`, async () => {
5232
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5233
+ const { actor, runtime } = makeActor({ services });
5234
+ runtime.calls.length = 0;
5235
+
5236
+ await actor.receiveTextFrame(`${alias}\r\n`);
5237
+
5238
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5239
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5240
+ // The help NOTICE source hostmask identifies which service was routed.
5241
+ expect(
5242
+ allText.some((t) => t.startsWith(`:${service}!${service}@services NOTICE alice :`)),
5243
+ ).toBe(true);
5244
+ // And it never produces a 421.
5245
+ for (const t of allText) {
5246
+ expect(t).not.toContain(' 421 ');
5247
+ }
5248
+ });
5249
+ }
5250
+
5251
+ it('aliases are case-insensitive (ns lower-case maps to NickServ)', async () => {
5252
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5253
+ const { actor, runtime } = makeActor({ services });
5254
+ runtime.calls.length = 0;
5255
+
5256
+ await actor.receiveTextFrame('ns\r\n');
5257
+
5258
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5259
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5260
+ expect(allText.some((t) => t.startsWith(':NickServ!NickServ@services NOTICE alice :'))).toBe(
5261
+ true,
5262
+ );
5263
+ });
5264
+
5265
+ it('joins multi-word args into the PRIVMSG trailing body (NICKSERV REGISTER pw email)', async () => {
5266
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5267
+ const { actor, runtime } = makeActor({ services });
5268
+ runtime.calls.length = 0;
5269
+
5270
+ // Three trailing tokens must be joined and delivered as the body, exactly
5271
+ // mirroring `PRIVMSG NickServ :REGISTER hunter2 alice@example.com`.
5272
+ await actor.receiveTextFrame('NICKSERV REGISTER hunter2 alice@example.com\r\n');
5273
+
5274
+ expect(services.isRegisteredNick('alice')).toBe(true);
5275
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5276
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5277
+ expect(allText).toContain(
5278
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
5279
+ );
5280
+ });
5281
+
5282
+ it('joins a colon-trailing arg shape into the PRIVMSG body (NS :IDENTIFY hunter2)', async () => {
5283
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5284
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5285
+ const { actor, conn } = makeActor({ services });
5286
+
5287
+ // `NS :IDENTIFY hunter2` parses to params=['IDENTIFY hunter2']; joining
5288
+ // reproduces the body `IDENTIFY hunter2` so the identify succeeds.
5289
+ await actor.receiveTextFrame('NS :IDENTIFY hunter2\r\n');
5290
+
5291
+ expect(conn.account).toBe('alice');
5292
+ });
5293
+
5294
+ it('shortcut with no args is equivalent to PRIVMSG <ServiceNick> (help NOTICE)', async () => {
5295
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5296
+ const { actor: a1, runtime: r1 } = makeActor({ services });
5297
+ r1.calls.length = 0;
5298
+ await a1.receiveTextFrame('CS\r\n');
5299
+ const shortcutText = r1.calls
5300
+ .filter((c) => c.method === 'send' && c.args[0] === 'c1')
5301
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5302
+
5303
+ const { actor: a2, runtime: r2 } = makeActor({ services });
5304
+ r2.calls.length = 0;
5305
+ await a2.receiveTextFrame('PRIVMSG ChanServ :\r\n');
5306
+ const privmsgText = r2.calls
5307
+ .filter((c) => c.method === 'send' && c.args[0] === 'c1')
5308
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5309
+
5310
+ expect(shortcutText).toEqual(privmsgText);
5311
+ });
5312
+
5313
+ it('when services are unavailable, the shortcut matches a direct PRIVMSG to the service (401, never 421)', async () => {
5314
+ // No services store bound → the rewritten PRIVMSG NickServ falls through
5315
+ // to the normal user-target path (sendToNick + 401), exactly like a
5316
+ // direct PRIVMSG NickServ. The shortcut must NOT produce a bare 421.
5317
+ const { actor: shortcutActor, runtime: shortcutRuntime } = makeActor({});
5318
+ shortcutRuntime.calls.length = 0;
5319
+ await shortcutActor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
5320
+
5321
+ const shortcutToNick = shortcutRuntime.calls.filter((c) => c.method === 'sendToNick');
5322
+ expect(shortcutToNick).toHaveLength(1);
5323
+ const shortcutNotFound = shortcutToNick[0]?.args[3] as RawLine[] | undefined;
5324
+ expect(shortcutNotFound?.[0]?.text).toContain('401');
5325
+
5326
+ // Equivalence: a direct PRIVMSG NickServ with no store produces the same
5327
+ // 401 path, confirming the shortcut delegates identically.
5328
+ const { actor: directActor, runtime: directRuntime } = makeActor({});
5329
+ directRuntime.calls.length = 0;
5330
+ await directActor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5331
+ const directToNick = directRuntime.calls.filter((c) => c.method === 'sendToNick');
5332
+ expect(directToNick).toHaveLength(1);
5333
+
5334
+ // Critical: neither path emits a bare 421 for the shortcut verb.
5335
+ const allShortcutText = shortcutRuntime.calls
5336
+ .filter((c) => c.method === 'send' && c.args[0] === 'c1')
5337
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5338
+ for (const t of allShortcutText) {
5339
+ expect(t).not.toContain(' 421 ');
5340
+ }
5341
+ });
5342
+ });