serverless-ircd 0.8.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -908,12 +908,13 @@ describe('ConnectionActor — additional routing', () => {
908
908
  expect(lines[0]?.text).toContain('462');
909
909
  });
910
910
 
911
- it('routes PASS to passReducer (stashes the attempt)', async () => {
911
+ it('routes PASS to passReducer (bare post-registration PASS is a no-op)', async () => {
912
912
  const { actor, runtime } = makeActor({});
913
913
  await actor.receiveTextFrame('PASS sekrat\r\n');
914
- // 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).
915
916
  const sends = runtime.calls.filter((c) => c.method === 'send');
916
- expect(sends).toHaveLength(1);
917
+ expect(sends).toHaveLength(0);
917
918
  });
918
919
 
919
920
  it('routes PONG to pongReducer (no effects, lastSeen refreshed)', async () => {
@@ -4261,6 +4262,39 @@ describe('ConnectionActor — draft/read-marker MARKREAD verb', () => {
4261
4262
  const lines = sends[0]?.args[1] as RawLine[];
4262
4263
  expect(lines[0]?.text).toBe(':irc.example.com 421 alice MARKREAD :Unknown command');
4263
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 ');
4297
+ });
4264
4298
  });
4265
4299
 
4266
4300
  describe('ConnectionActor — draft/multiline BATCH to a nick target', () => {
@@ -4978,3 +5012,331 @@ describe('ConnectionActor — OperServ routing', () => {
4978
5012
  );
4979
5013
  });
4980
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
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.8.0",
3
+ "version": "0.9.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
 
@@ -191,6 +191,25 @@ describe('InMemoryClientHarness.waitForNumeric', () => {
191
191
  expect(result).toContain('004');
192
192
  await h.close();
193
193
  });
194
+
195
+ it('ignores received lines that carry no :server prefix when scanning', async () => {
196
+ // A PONG reply is emitted without a `:server` prefix (`PONG :tok`, not
197
+ // `:srv PONG :tok`). waitForNumeric must scan such a line without
198
+ // false-matching: numericEquals takes its no-prefix branch (start=0),
199
+ // rejects the line, and keeps scanning until the real numeric lands.
200
+ const h = new InMemoryHarness();
201
+ const c = await h.spawnClient({ nick: 'alice' });
202
+ await c.waitForNumeric('001');
203
+ // Isolate a bare (no-prefix) line first, then drive a prefixed numeric
204
+ // after it, so the scan MUST walk past the PONG to reach 375.
205
+ c.received.length = 0;
206
+ await c.send('PING :bare');
207
+ await c.waitForLine((l) => l === 'PONG :bare');
208
+ await c.send('MOTD');
209
+ const result = await c.waitForNumeric('375');
210
+ expect(result).toContain('375');
211
+ await h.close();
212
+ });
194
213
  });
195
214
 
196
215
  describe('InMemoryClientHarness.close', () => {
@@ -7,7 +7,12 @@ export default defineConfig({
7
7
  provider: 'v8',
8
8
  all: false,
9
9
  include: ['src/**/*.ts'],
10
- exclude: ['src/**/index.ts'],
10
+ // harness.ts exports types/interfaces only — it compiles to a bare
11
+ // `export {}` with zero runtime statements, so v8 reports it as a
12
+ // 0/0 (0%-statements) file that drags the file list down. Excluding
13
+ // it keeps the report honest: only modules with runtime code are
14
+ // scored. index.ts is excluded as the pure re-export barrel.
15
+ exclude: ['src/**/index.ts', 'src/harness.ts'],
11
16
  reporter: ['text', 'html', 'json-summary'],
12
17
  thresholds: {
13
18
  lines: 90,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/load-test",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Synthetic WebSocket IRC client farm for load testing ServerlessIRCd (concurrency, latency p50/p95/p99, drop rate)",
6
6
  "license": "BSD-3-Clause",
@@ -134,10 +134,10 @@ export class IrcLoadClient {
134
134
  const t0 = Date.now();
135
135
  this.ws = new this.WebSocketCtor(this.config.target, [...WS_SUBPROTOCOLS]);
136
136
  const timer = setTimeout(() => {
137
- if (!this.settled) {
138
- this.failWaiters(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
139
- reject(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
140
- }
137
+ // The timer is always cleared by the open/error handlers before
138
+ // teardown could run, so `settled` is necessarily false here.
139
+ this.failWaiters(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
140
+ reject(new Error(`connect timeout (${this.config.connectTimeoutMs}ms)`));
141
141
  }, this.config.connectTimeoutMs);
142
142
 
143
143
  this.ws.once('open', () => {
@@ -245,13 +245,11 @@ export class IrcLoadClient {
245
245
  if (line.length === 0) continue;
246
246
  // Dispatch to the first matching waiter (FIFO preserves phase order).
247
247
  const idx = this.waiters.findIndex((w) => w.predicate(line));
248
- if (idx >= 0) {
249
- const waiter = this.waiters[idx];
250
- if (waiter !== undefined) {
251
- this.waiters.splice(idx, 1);
252
- clearTimeout(waiter.timer);
253
- waiter.resolve(line);
254
- }
248
+ const waiter = idx >= 0 ? this.waiters[idx] : undefined;
249
+ if (waiter !== undefined) {
250
+ this.waiters.splice(idx, 1);
251
+ clearTimeout(waiter.timer);
252
+ waiter.resolve(line);
255
253
  }
256
254
  }
257
255
  }
@@ -282,8 +280,10 @@ export class IrcLoadClient {
282
280
  ): Promise<string> {
283
281
  return new Promise<string>((resolve, reject) => {
284
282
  const timer = setTimeout(() => {
283
+ // The timer is always cleared when the waiter is removed
284
+ // (onMessage/failWaiters), so the waiter is still present here.
285
285
  const idx = this.waiters.findIndex((w) => w.resolve === resolve);
286
- if (idx >= 0) this.waiters.splice(idx, 1);
286
+ this.waiters.splice(idx, 1);
287
287
  reject(new Error(`timed out waiting for ${label} (${timeoutMs}ms)`));
288
288
  }, timeoutMs);
289
289
  this.waiters.push({ predicate, resolve, reject, phase, label, timer });
@@ -301,7 +301,7 @@ export class IrcLoadClient {
301
301
 
302
302
  /** Idempotently closes the socket and drains waiters. */
303
303
  private teardown(): void {
304
- if (this.settled) return;
304
+ // teardown is only ever called once from run()'s finally.
305
305
  this.settled = true;
306
306
  this.failWaiters(new Error('client teardown'));
307
307
  try {