serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,5 +1,4 @@
1
1
  import {
2
- type AccountStore,
3
2
  type AwayStore,
4
3
  CapturingLogger,
5
4
  type ChanName,
@@ -26,8 +25,6 @@ import {
26
25
  type NickHistoryStore,
27
26
  Numerics,
28
27
  type RawLine,
29
- type SaslPayload,
30
- type SaslResult,
31
28
  SequentialIdFactory,
32
29
  type ServerConfig,
33
30
  type ServerStats,
@@ -219,7 +216,6 @@ function makeActor(opts: {
219
216
  logger?: Logger;
220
217
  traceId?: string;
221
218
  messages?: MessageStore;
222
- accounts?: AccountStore;
223
219
  mtlsIdentity?: MtlsIdentityProvider;
224
220
  history?: NickHistoryStore;
225
221
  stats?: ServerStats;
@@ -280,9 +276,6 @@ function makeActor(opts: {
280
276
  if (opts.messages !== undefined) {
281
277
  finalOpts.messages = opts.messages;
282
278
  }
283
- if (opts.accounts !== undefined) {
284
- finalOpts.accounts = opts.accounts;
285
- }
286
279
  if (opts.mtlsIdentity !== undefined) {
287
280
  finalOpts.mtlsIdentity = opts.mtlsIdentity;
288
281
  }
@@ -908,12 +901,13 @@ describe('ConnectionActor — additional routing', () => {
908
901
  expect(lines[0]?.text).toContain('462');
909
902
  });
910
903
 
911
- it('routes PASS to passReducer (stashes the attempt)', async () => {
904
+ it('routes PASS to passReducer (bare post-registration PASS is a no-op)', async () => {
912
905
  const { actor, runtime } = makeActor({});
913
906
  await actor.receiveTextFrame('PASS sekrat\r\n');
914
- // passReducer on an already-registered conn emits a 462 Send.
907
+ // passReducer on a registered conn with no AccountStore treats a bare
908
+ // server-password PASS as a silent no-op (no 462, no disconnect).
915
909
  const sends = runtime.calls.filter((c) => c.method === 'send');
916
- expect(sends).toHaveLength(1);
910
+ expect(sends).toHaveLength(0);
917
911
  });
918
912
 
919
913
  it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
@@ -2128,17 +2122,7 @@ describe('ConnectionActor — CHATHISTORY', () => {
2128
2122
  // SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
2129
2123
  // ===========================================================================
2130
2124
 
2131
- describe('ConnectionActor — SASL AccountStore plumbing', () => {
2132
- /** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
2133
- class FakeAccountStore implements AccountStore {
2134
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
2135
- constructor(private readonly result: SaslResult) {}
2136
- verify(mech: string, payload: SaslPayload): SaslResult {
2137
- this.calls.push({ mech, payload });
2138
- return this.result;
2139
- }
2140
- }
2141
-
2125
+ describe('ConnectionActor — SASL ServicesStore plumbing', () => {
2142
2126
  /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
2143
2127
  function plainPayload(authcid: string, password: string): string {
2144
2128
  return encodeBase64(`\0${authcid}\0${password}`);
@@ -2153,11 +2137,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2153
2137
  };
2154
2138
  }
2155
2139
 
2156
- it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
2157
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2140
+ it('completes SASL PLAIN with 900/903 when a bound ServicesStore accepts creds', async () => {
2141
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2142
+ services.registerNick('alice', 'secret', 'alice@example.com');
2158
2143
  const { actor, runtime, conn } = makeActor({
2159
2144
  conn: saslReadyConn(),
2160
- accounts,
2145
+ services,
2161
2146
  });
2162
2147
  conn.nick = 'alice';
2163
2148
  runtime.calls.length = 0;
@@ -2172,18 +2157,14 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2172
2157
  expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
2173
2158
  // Critical: never 904 for valid creds.
2174
2159
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2175
- // The AccountStore was consulted with the parsed PLAIN credentials.
2176
- expect(accounts.calls).toEqual([
2177
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
2178
- ]);
2179
2160
  // Account name recorded on the connection for extended-join/account-tag.
2180
2161
  expect(conn.account).toBe('alice');
2181
2162
  });
2182
2163
 
2183
- it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
2164
+ it('emits 904 ERR_SASLFAIL when no ServicesStore is bound (regression guard)', async () => {
2184
2165
  const { actor, runtime, conn } = makeActor({
2185
2166
  conn: saslReadyConn(),
2186
- // No `accounts` option → ctx.accounts is undefined.
2167
+ // No `services` option → ctx.services is undefined.
2187
2168
  });
2188
2169
  conn.nick = 'alice';
2189
2170
  runtime.calls.length = 0;
@@ -2201,11 +2182,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2201
2182
  expect(conn.account).toBeUndefined();
2202
2183
  });
2203
2184
 
2204
- it('emits 904 when a bound AccountStore rejects the credentials', async () => {
2205
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
2185
+ it('emits 904 when a bound ServicesStore rejects the credentials', async () => {
2186
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2187
+ services.registerNick('alice', 'secret', 'alice@example.com');
2206
2188
  const { actor, runtime, conn } = makeActor({
2207
2189
  conn: saslReadyConn(),
2208
- accounts,
2190
+ services,
2209
2191
  });
2210
2192
  conn.nick = 'alice';
2211
2193
  runtime.calls.length = 0;
@@ -2222,16 +2204,6 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2222
2204
  });
2223
2205
 
2224
2206
  describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2225
- /** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
2226
- class FakeAccountStore implements AccountStore {
2227
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
2228
- constructor(private readonly result: SaslResult) {}
2229
- verify(mech: string, payload: SaslPayload): SaslResult {
2230
- this.calls.push({ mech, payload });
2231
- return this.result;
2232
- }
2233
- }
2234
-
2235
2207
  /** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
2236
2208
  class FakeMtlsProvider implements MtlsIdentityProvider {
2237
2209
  constructor(private readonly identity: string | undefined) {}
@@ -2249,11 +2221,13 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2249
2221
  }
2250
2222
 
2251
2223
  it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
2252
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2224
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2225
+ services.registerNick('alice', 'secret', 'alice@example.com');
2226
+ services.addCertFP('alice', 'CN=alice');
2253
2227
  const mtlsIdentity = new FakeMtlsProvider('CN=alice');
2254
2228
  const { actor, runtime, conn } = makeActor({
2255
2229
  conn: saslReadyConn(),
2256
- accounts,
2230
+ services,
2257
2231
  mtlsIdentity,
2258
2232
  });
2259
2233
  conn.nick = 'alice';
@@ -2267,17 +2241,16 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2267
2241
  expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
2268
2242
  expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
2269
2243
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2270
- expect(accounts.calls).toEqual([
2271
- { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
2272
- ]);
2273
2244
  expect(conn.account).toBe('alice');
2274
2245
  });
2275
2246
 
2276
2247
  it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
2277
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2248
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2249
+ services.registerNick('alice', 'secret', 'alice@example.com');
2250
+ services.addCertFP('alice', 'CN=alice');
2278
2251
  const { actor, runtime, conn } = makeActor({
2279
2252
  conn: saslReadyConn(),
2280
- accounts,
2253
+ services,
2281
2254
  // No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
2282
2255
  });
2283
2256
  conn.nick = 'alice';
@@ -4261,6 +4234,39 @@ describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
4261
4234
  const lines = sends[0]?.args[1] as RawLine[];
4262
4235
  expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
4263
4236
  });
4237
+
4238
+ it('silently ignores a nick-target MARKREAD (read markers are channel-scoped)', async () => {
4239
+ const store = new InMemoryMessageStore();
4240
+ const { actor, runtime, conn } = makeActor({ messages: store });
4241
+ conn.caps = new Set<string>(['draft/read-marker']);
4242
+ runtime.calls.length = 0;
4243
+
4244
+ // A nick (non-channel) target falls through to the `return []` arm — the
4245
+ // deployment has no nick-scoped read markers, so the verb is a no-op
4246
+ // rather than a 421 or a FAIL.
4247
+ await actor.receiveTextFrame(`MARKREAD alice ${TS(2_000)}\r\n`);
4248
+
4249
+ expect(runtime.calls).toHaveLength(0);
4250
+ });
4251
+
4252
+ it('routes a parameterless MARKREAD through the reducer (NEED_MORE_PARAMS, never 421 fallthrough)', async () => {
4253
+ const store = new InMemoryMessageStore();
4254
+ const { actor, runtime, conn } = makeActor({ messages: store });
4255
+ conn.caps = new Set<string>(['draft/read-marker']);
4256
+ runtime.calls.length = 0;
4257
+
4258
+ // No params at all → `msg.params[0] ?? ''` falls back to the empty
4259
+ // string, which is routed into the reducer so it can FAIL
4260
+ // NEED_MORE_PARAMS rather than hitting the generic 421 fallthrough.
4261
+ await actor.receiveTextFrame('MARKREAD\r\n');
4262
+
4263
+ const sends = runtime.calls.filter((c) => c.method === 'send');
4264
+ expect(sends).toHaveLength(1);
4265
+ const lines = sends[0]?.args[1] as RawLine[];
4266
+ expect(lines[0]?.text).toContain('NEED_MORE_PARAMS');
4267
+ // Critical: never the generic 421 fallthrough for a cap-enabled client.
4268
+ expect(lines[0]?.text).not.toContain(' 421 ');
4269
+ });
4264
4270
  });
4265
4271
 
4266
4272
  describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
@@ -4978,3 +4984,331 @@ describe('ConnectionActor — OperServ routing', () => {
4978
4984
  );
4979
4985
  });
4980
4986
  });
4987
+
4988
+ // ============================================================================
4989
+ // echo-message — services PRIVMSG echo (IRCv3)
4990
+ //
4991
+ // When a client that negotiated `echo-message` sends `PRIVMSG <ServiceNick>`
4992
+ // the server must echo the sender's own PRIVMSG line back, BEFORE the service
4993
+ // reducer's NOTICE reply — matching how every other PRIVMSG renders (your
4994
+ // outgoing line first, then any reply). Without this echo,
4995
+ // modern IRCv3 clients optimistically show the outgoing message as "sending"
4996
+ // and flip it to "failed" when no delivery confirmation arrives.
4997
+ // ============================================================================
4998
+
4999
+ describe('ConnectionActor — echo-message on service PRIVMSG', () => {
5000
+ function makeOnlineConn(id: string, nick: string): ConnectionState {
5001
+ const c = createConnection({ id, connectedSince: 0 });
5002
+ c.nick = nick;
5003
+ c.user = nick;
5004
+ c.host = `${nick}.org`;
5005
+ c.realname = nick;
5006
+ c.registration = 'registered';
5007
+ return c;
5008
+ }
5009
+
5010
+ it('echoes the sender PRIVMSG back when echo-message is negotiated (NickServ)', async () => {
5011
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5012
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5013
+ const { actor, runtime } = makeActor({
5014
+ services,
5015
+ conn: { caps: new Set<string>(['echo-message']) },
5016
+ });
5017
+ runtime.calls.length = 0;
5018
+
5019
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5020
+
5021
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5022
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5023
+ expect(allText).toContain(':alice!alice@example.com PRIVMSG NickServ :IDENTIFY hunter2');
5024
+ });
5025
+
5026
+ it('does NOT echo when echo-message is not negotiated', async () => {
5027
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5028
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5029
+ const { actor, runtime } = makeActor({ services });
5030
+ runtime.calls.length = 0;
5031
+
5032
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5033
+
5034
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5035
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5036
+ for (const t of allText) {
5037
+ expect(t).not.toMatch(/PRIVMSG NickServ :IDENTIFY hunter2$/);
5038
+ }
5039
+ });
5040
+
5041
+ it('emits the echo BEFORE the NickServ NOTICE reply (ordering)', async () => {
5042
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5043
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5044
+ const { actor, runtime } = makeActor({
5045
+ services,
5046
+ conn: { caps: new Set<string>(['echo-message']) },
5047
+ });
5048
+ runtime.calls.length = 0;
5049
+
5050
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5051
+
5052
+ const sendCalls = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5053
+ const findIdx = (needle: string): number =>
5054
+ sendCalls.findIndex((c) => (c.args[1] as RawLine[]).some((l) => l.text.includes(needle)));
5055
+ const noticeIdx = findIdx('NickServ!NickServ@services NOTICE');
5056
+ const echoIdx = findIdx('PRIVMSG NickServ :IDENTIFY hunter2');
5057
+ expect(noticeIdx).toBeGreaterThanOrEqual(0);
5058
+ expect(echoIdx).toBeGreaterThanOrEqual(0);
5059
+ expect(echoIdx).toBeLessThan(noticeIdx);
5060
+ });
5061
+
5062
+ it('echoes the sender PRIVMSG back for ChanServ (shared fix, not NickServ-specific)', async () => {
5063
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5064
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5065
+ const { actor, runtime } = makeActor({
5066
+ services,
5067
+ conn: { caps: new Set<string>(['echo-message']) },
5068
+ });
5069
+ runtime.calls.length = 0;
5070
+
5071
+ await actor.receiveTextFrame('PRIVMSG ChanServ :HELP\r\n');
5072
+
5073
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5074
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5075
+ expect(allText).toContain(':alice!alice@example.com PRIVMSG ChanServ :HELP');
5076
+ });
5077
+
5078
+ it('preserves the sender target casing in the echo', async () => {
5079
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5080
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5081
+ const { actor, runtime } = makeActor({
5082
+ services,
5083
+ conn: { caps: new Set<string>(['echo-message']) },
5084
+ });
5085
+ runtime.calls.length = 0;
5086
+
5087
+ await actor.receiveTextFrame('PRIVMSG nickserv :IDENTIFY hunter2\r\n');
5088
+
5089
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5090
+ const allText = toAlice.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5091
+ expect(allText).toContain(':alice!alice@example.com PRIVMSG nickserv :IDENTIFY hunter2');
5092
+ });
5093
+
5094
+ it('does NOT intercept PRIVMSG to a non-service nick when services are bound', async () => {
5095
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5096
+ const { actor, runtime } = makeActor({ services });
5097
+ runtime.addConnection(makeOnlineConn('c2', 'bob'));
5098
+ runtime.calls.length = 0;
5099
+
5100
+ await actor.receiveTextFrame('PRIVMSG bob :hi\r\n');
5101
+
5102
+ // With services bound, a non-service nick still routes through the normal
5103
+ // user-target path (sendToNick), never the service reducer or echo path.
5104
+ const toNick = runtime.calls.filter((c) => c.method === 'sendToNick');
5105
+ expect(toNick).toHaveLength(1);
5106
+ });
5107
+
5108
+ it('echoes with empty text when the PRIVMSG carries no trailing parameter', async () => {
5109
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5110
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5111
+ const { actor, runtime } = makeActor({
5112
+ services,
5113
+ conn: { caps: new Set<string>(['echo-message']) },
5114
+ });
5115
+ runtime.calls.length = 0;
5116
+
5117
+ // No `:text` argument → `singleParamMsg.params[1] ?? ''` falls back to an
5118
+ // empty string in the echo line.
5119
+ await actor.receiveTextFrame('PRIVMSG NickServ\r\n');
5120
+
5121
+ const toAlice = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5122
+ const echo = toAlice
5123
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
5124
+ .find((t) => t.endsWith('PRIVMSG NickServ :'));
5125
+ expect(echo).toBe(':alice!alice@example.com PRIVMSG NickServ :');
5126
+ });
5127
+
5128
+ it('falls back to "?" hostmask in the echo when the sender has no nick', async () => {
5129
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5130
+ const { actor, runtime, conn } = makeActor({
5131
+ services,
5132
+ conn: { caps: new Set<string>(['echo-message']) },
5133
+ });
5134
+ // A connection that negotiated echo-message but has no NICK/USER/HOST —
5135
+ // exercises the `hostmaskOf(state) ?? state.nick ?? '?'` fallback chain
5136
+ // in the service-PRIVMSG echo. `delete` is required because
5137
+ // exactOptionalPropertyTypes forbids `= undefined`.
5138
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
5139
+ delete conn.nick;
5140
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
5141
+ delete conn.user;
5142
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes requires delete
5143
+ delete conn.host;
5144
+ runtime.calls.length = 0;
5145
+
5146
+ await actor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5147
+
5148
+ const toSender = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5149
+ const echo = toSender
5150
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text))
5151
+ .find((t) => t.includes('PRIVMSG NickServ :IDENTIFY hunter2'));
5152
+ // Both `??` arms fire: hostmaskOf returns undefined (no nick), then
5153
+ // state.nick is undefined, so the final '?' fallback is used.
5154
+ expect(echo).toBe(':? PRIVMSG NickServ :IDENTIFY hunter2');
5155
+ });
5156
+ });
5157
+
5158
+ // ============================================================================
5159
+ // Service shortcut commands — NICKSERV/NS/CHANSERV/CS/HOSTSERV/HS/MEMOSERV/MS/
5160
+ // OPERSERV/OS (Atheme/Anope-convention aliases rewritten to
5161
+ // PRIVMSG <ServiceNick> :<joined args> by the dispatcher).
5162
+ // ============================================================================
5163
+
5164
+ describe('ConnectionActor — service shortcut commands', () => {
5165
+ it('NICKSERV IDENTIFY behaves identically to PRIVMSG NickServ :IDENTIFY (never 421)', async () => {
5166
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5167
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5168
+ const { actor, runtime, conn } = makeActor({ services });
5169
+ runtime.calls.length = 0;
5170
+
5171
+ await actor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
5172
+
5173
+ expect(conn.account).toBe('alice');
5174
+ expect(conn.userModes.registered).toBe(true);
5175
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5176
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5177
+ expect(allText).toContain(
5178
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
5179
+ );
5180
+ // Critical: the shortcut must reach the reducer, never the 421 fallthrough.
5181
+ for (const t of allText) {
5182
+ expect(t).not.toContain(' 421 ');
5183
+ }
5184
+ });
5185
+
5186
+ // Each alias must resolve to its service. Sending the bare alias (no args)
5187
+ // reaches the reducer, which emits a help NOTICE sourced from the matching
5188
+ // pseudo-client — so the NOTICE source nick identifies the routed service.
5189
+ const aliasToService: ReadonlyArray<[string, string]> = [
5190
+ ['NICKSERV', 'NickServ'],
5191
+ ['NS', 'NickServ'],
5192
+ ['CHANSERV', 'ChanServ'],
5193
+ ['CS', 'ChanServ'],
5194
+ ['HOSTSERV', 'HostServ'],
5195
+ ['HS', 'HostServ'],
5196
+ ['MEMOSERV', 'MemoServ'],
5197
+ ['MS', 'MemoServ'],
5198
+ ['OPERSERV', 'OperServ'],
5199
+ ['OS', 'OperServ'],
5200
+ ];
5201
+
5202
+ for (const [alias, service] of aliasToService) {
5203
+ it(`${alias} (no args) routes to ${service} via the help NOTICE`, async () => {
5204
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5205
+ const { actor, runtime } = makeActor({ services });
5206
+ runtime.calls.length = 0;
5207
+
5208
+ await actor.receiveTextFrame(`${alias}\r\n`);
5209
+
5210
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5211
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5212
+ // The help NOTICE source hostmask identifies which service was routed.
5213
+ expect(
5214
+ allText.some((t) => t.startsWith(`:${service}!${service}@services NOTICE alice :`)),
5215
+ ).toBe(true);
5216
+ // And it never produces a 421.
5217
+ for (const t of allText) {
5218
+ expect(t).not.toContain(' 421 ');
5219
+ }
5220
+ });
5221
+ }
5222
+
5223
+ it('aliases are case-insensitive (ns lower-case maps to NickServ)', async () => {
5224
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5225
+ const { actor, runtime } = makeActor({ services });
5226
+ runtime.calls.length = 0;
5227
+
5228
+ await actor.receiveTextFrame('ns\r\n');
5229
+
5230
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5231
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5232
+ expect(allText.some((t) => t.startsWith(':NickServ!NickServ@services NOTICE alice :'))).toBe(
5233
+ true,
5234
+ );
5235
+ });
5236
+
5237
+ it('joins multi-word args into the PRIVMSG trailing body (NICKSERV REGISTER pw email)', async () => {
5238
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5239
+ const { actor, runtime } = makeActor({ services });
5240
+ runtime.calls.length = 0;
5241
+
5242
+ // Three trailing tokens must be joined and delivered as the body, exactly
5243
+ // mirroring `PRIVMSG NickServ :REGISTER hunter2 alice@example.com`.
5244
+ await actor.receiveTextFrame('NICKSERV REGISTER hunter2 alice@example.com\r\n');
5245
+
5246
+ expect(services.isRegisteredNick('alice')).toBe(true);
5247
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
5248
+ const allText = sends.flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5249
+ expect(allText).toContain(
5250
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
5251
+ );
5252
+ });
5253
+
5254
+ it('joins a colon-trailing arg shape into the PRIVMSG body (NS :IDENTIFY hunter2)', async () => {
5255
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5256
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
5257
+ const { actor, conn } = makeActor({ services });
5258
+
5259
+ // `NS :IDENTIFY hunter2` parses to params=['IDENTIFY hunter2']; joining
5260
+ // reproduces the body `IDENTIFY hunter2` so the identify succeeds.
5261
+ await actor.receiveTextFrame('NS :IDENTIFY hunter2\r\n');
5262
+
5263
+ expect(conn.account).toBe('alice');
5264
+ });
5265
+
5266
+ it('shortcut with no args is equivalent to PRIVMSG <ServiceNick> (help NOTICE)', async () => {
5267
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
5268
+ const { actor: a1, runtime: r1 } = makeActor({ services });
5269
+ r1.calls.length = 0;
5270
+ await a1.receiveTextFrame('CS\r\n');
5271
+ const shortcutText = r1.calls
5272
+ .filter((c) => c.method === 'send' && c.args[0] === 'c1')
5273
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5274
+
5275
+ const { actor: a2, runtime: r2 } = makeActor({ services });
5276
+ r2.calls.length = 0;
5277
+ await a2.receiveTextFrame('PRIVMSG ChanServ :\r\n');
5278
+ const privmsgText = r2.calls
5279
+ .filter((c) => c.method === 'send' && c.args[0] === 'c1')
5280
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5281
+
5282
+ expect(shortcutText).toEqual(privmsgText);
5283
+ });
5284
+
5285
+ it('when services are unavailable, the shortcut matches a direct PRIVMSG to the service (401, never 421)', async () => {
5286
+ // No services store bound → the rewritten PRIVMSG NickServ falls through
5287
+ // to the normal user-target path (sendToNick + 401), exactly like a
5288
+ // direct PRIVMSG NickServ. The shortcut must NOT produce a bare 421.
5289
+ const { actor: shortcutActor, runtime: shortcutRuntime } = makeActor({});
5290
+ shortcutRuntime.calls.length = 0;
5291
+ await shortcutActor.receiveTextFrame('NICKSERV IDENTIFY hunter2\r\n');
5292
+
5293
+ const shortcutToNick = shortcutRuntime.calls.filter((c) => c.method === 'sendToNick');
5294
+ expect(shortcutToNick).toHaveLength(1);
5295
+ const shortcutNotFound = shortcutToNick[0]?.args[3] as RawLine[] | undefined;
5296
+ expect(shortcutNotFound?.[0]?.text).toContain('401');
5297
+
5298
+ // Equivalence: a direct PRIVMSG NickServ with no store produces the same
5299
+ // 401 path, confirming the shortcut delegates identically.
5300
+ const { actor: directActor, runtime: directRuntime } = makeActor({});
5301
+ directRuntime.calls.length = 0;
5302
+ await directActor.receiveTextFrame('PRIVMSG NickServ :IDENTIFY hunter2\r\n');
5303
+ const directToNick = directRuntime.calls.filter((c) => c.method === 'sendToNick');
5304
+ expect(directToNick).toHaveLength(1);
5305
+
5306
+ // Critical: neither path emits a bare 421 for the shortcut verb.
5307
+ const allShortcutText = shortcutRuntime.calls
5308
+ .filter((c) => c.method === 'send' && c.args[0] === 'c1')
5309
+ .flatMap((c) => (c.args[1] as RawLine[]).map((l) => l.text));
5310
+ for (const t of allShortcutText) {
5311
+ expect(t).not.toContain(' 421 ');
5312
+ }
5313
+ });
5314
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -263,12 +263,13 @@ class InMemoryClientHarness implements ClientHarness {
263
263
  }
264
264
 
265
265
  async feed(chunk: string): Promise<void> {
266
- /* istanbul ignore next -- defensive: bindActor is called by the
266
+ /* istanbul ignore start -- defensive: bindActor is called by the
267
267
  harness before the client is returned, so the unbound-actor branch
268
268
  is unreachable through the public API. */
269
269
  if (this.actor === undefined) {
270
270
  throw new Error(`client ${this.id} has no bound actor`);
271
271
  }
272
+ /* istanbul ignore stop */
272
273
  await this.actor.receive(chunk);
273
274
  }
274
275
 
@@ -305,16 +306,18 @@ class InMemoryClientHarness implements ClientHarness {
305
306
  this.closed = true;
306
307
  // Best-effort QUIT so peers see the part broadcast; the runtime's
307
308
  // disconnect handler is wired by the harness to unregister.
309
+ /* istanbul ignore start -- defensive: spawnClient binds the actor
310
+ before returning the client, so `this.actor` is never undefined
311
+ here. The guard exists purely as a teardown safety net. */
308
312
  if (this.actor !== undefined) {
309
313
  try {
310
314
  await this.actor.receive('QUIT :client closed\r\n');
311
- /* istanbul ignore next -- defensive: receive swallows
312
- every error internally; the catch is purely a teardown safety
313
- net for catastrophic runtime failures. */
314
315
  } catch {
315
- // no-op
316
+ // no-op -- receive swallows every error internally; this catch is
317
+ // purely a teardown safety net for catastrophic runtime failures.
316
318
  }
317
319
  }
320
+ /* istanbul ignore stop */
318
321
  }
319
322
  }
320
323
 
@@ -182,7 +182,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
182
182
  await alice.waitForNumeric('366');
183
183
  const namesLine = alice.received.find((l) => numericOf(l) === '353');
184
184
  expect(namesLine).toBeDefined();
185
- expect(namesLine ?? '').toContain('@alice');
185
+ // namesLine is still `string | undefined` to TS (expect() does not
186
+ // narrow), so we route it back through expect() rather than a
187
+ // non-null assertion — toContain fails loudly if it were undefined.
188
+ expect(namesLine).toContain('@alice');
186
189
  await harness.close();
187
190
  });
188
191
 
@@ -246,7 +249,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
246
249
  await alice.send('JOIN #room');
247
250
  await bob.send('JOIN #room');
248
251
  await untilAliceSeesBobJoin(alice, '#room');
249
- alice.received.length = 0;
252
+ // NOTE: alice.received is intentionally NOT cleared here. Keeping
253
+ // the registration + JOIN burst makes the no-self-echo assertion
254
+ // below non-vacuous: it scans real traffic and confirms the
255
+ // sender never receives her own PRIVMSG among any of it.
250
256
  bob.received.length = 0;
251
257
  await alice.send('PRIVMSG #room :hello team');
252
258
  await bob.waitForLine((l) => l.includes('PRIVMSG #room :hello team'));
@@ -453,7 +459,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
453
459
  await alice.send('JOIN #notice');
454
460
  await bob.send('JOIN #notice');
455
461
  await untilAliceSeesBobJoin(alice, '#notice');
456
- alice.received.length = 0;
462
+ // NOTE: alice.received is intentionally NOT cleared here. Keeping
463
+ // the registration + JOIN burst makes the NOTICE-no-error filter
464
+ // below non-vacuous: it scans real numeric traffic (353/366/…)
465
+ // and confirms none of it is a NOTICE error code.
457
466
  bob.received.length = 0;
458
467
  await alice.send('NOTICE #notice :quiet note');
459
468
  await bob.waitForLine((l) => l.includes('NOTICE #notice :quiet note'));
@@ -469,13 +478,19 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
469
478
  await harness.close();
470
479
  });
471
480
 
472
- it('scenario 27: PASS after registration is rejected with 462', async () => {
481
+ it('scenario 27: bare PASS after registration is a silent no-op (no 462)', async () => {
473
482
  harness = await factory.create();
474
483
  const c = await harness.spawnClient({ nick: 'alice' });
475
484
  await c.waitForNumeric('001');
476
485
  c.received.length = 0;
477
- await c.send('PASS hunter2');
478
- await c.waitForNumeric('462');
486
+ // A bare server-password PASS is meaningless once registration has
487
+ // completed: it must be ignored (no 462, no disconnect). Send a
488
+ // PING in the same frame so ordering is guaranteed — by the time
489
+ // the PONG arrives, any 462 from the PASS would already be queued.
490
+ await c.send('PASS hunter2\r\nPING :post-pass');
491
+ await c.waitForLine((l) => l === 'PONG :post-pass');
492
+ const errAlreadyRegistered = c.received.filter((l) => numericOf(l) === '462');
493
+ expect(errAlreadyRegistered).toEqual([]);
479
494
  await harness.close();
480
495
  });
481
496