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
@@ -139,6 +139,77 @@ describe('hostservReducer — dispatch', () => {
139
139
  expect(sentTexts(out.effects).some((t) => t.includes('Unknown command'))).toBe(true);
140
140
  });
141
141
 
142
+ it('hides oper-only commands from a non-oper on empty body', () => {
143
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
144
+ const conn = identifiedConn('bob');
145
+ const ctx = makeCtx(conn, services);
146
+
147
+ const out = hostservReducer(conn, privmsg(''), ctx);
148
+
149
+ const lines = sentTexts(out.effects);
150
+ expect(lines).toContain(
151
+ ':HostServ!HostServ@services NOTICE bob :Available commands: ON, OFF, REQUEST',
152
+ );
153
+ const helpLine = lines.find((t) => t.includes('Available commands'));
154
+ for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
155
+ expect(helpLine).not.toContain(cmd);
156
+ }
157
+ });
158
+
159
+ it('lists the full command set to an oper on empty body', () => {
160
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
161
+ const conn = operConn('admin');
162
+ const ctx = makeCtx(conn, services);
163
+
164
+ const out = hostservReducer(conn, privmsg(''), ctx);
165
+
166
+ expect(sentTexts(out.effects)).toContain(
167
+ ':HostServ!HostServ@services NOTICE admin :Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
168
+ );
169
+ });
170
+
171
+ it('hides oper-only commands from a non-oper on an unknown subcommand', () => {
172
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
173
+ const conn = identifiedConn('bob');
174
+ const ctx = makeCtx(conn, services);
175
+
176
+ const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
177
+
178
+ const lines = sentTexts(out.effects);
179
+ expect(lines).toContain(
180
+ ':HostServ!HostServ@services NOTICE bob :Unknown command. Available: ON, OFF, REQUEST',
181
+ );
182
+ const unknownLine = lines.find((t) => t.includes('Unknown command'));
183
+ for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
184
+ expect(unknownLine).not.toContain(cmd);
185
+ }
186
+ });
187
+
188
+ it('lists the full command set to an oper on an unknown subcommand', () => {
189
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
190
+ const conn = operConn('admin');
191
+ const ctx = makeCtx(conn, services);
192
+
193
+ const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
194
+
195
+ expect(sentTexts(out.effects)).toContain(
196
+ ':HostServ!HostServ@services NOTICE admin :Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
197
+ );
198
+ });
199
+
200
+ it('still rejects a non-oper who guesses an oper-only command (LIST)', () => {
201
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
202
+ services.requestVhost('alice', 'cool.example.net');
203
+ const conn = identifiedConn('bob');
204
+ const ctx = makeCtx(conn, services);
205
+
206
+ const out = hostservReducer(conn, privmsg('LIST'), ctx);
207
+
208
+ expect(sentTexts(out.effects)).toContain(
209
+ ':HostServ!HostServ@services NOTICE bob :You must be an oper to use LIST.',
210
+ );
211
+ });
212
+
142
213
  it('updates lastSeen to ctx.clock.now()', () => {
143
214
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
144
215
  const conn = identifiedConn('alice');
@@ -1064,6 +1064,83 @@ describe('joinReducer — KEEPTOPIC restore from ServicesStore', () => {
1064
1064
  });
1065
1065
  });
1066
1066
 
1067
+ // ============================================================================
1068
+ // joinReducer — TOPIC numerics (332/333) on JOIN
1069
+ // ============================================================================
1070
+ //
1071
+ // RFC 2812 §3.2.1: after a successful JOIN the server sends 332 RPL_TOPIC
1072
+ // and 333 RPL_TOPICWHOTIME whenever a topic is set, regardless of how the
1073
+ // topic reached the authoritative ChannelState (KEEPTOPIC restore, a
1074
+ // deploy snapshot, or an earlier joiner). These tests pin the behaviour
1075
+ // that was previously missing for every non-KEEPTOPIC-restore code path.
1076
+ // ============================================================================
1077
+
1078
+ describe('joinReducer — TOPIC numerics on JOIN', () => {
1079
+ it('emits 332/333 to the joiner when state.topic is already set (deploy snapshot)', () => {
1080
+ const chan = makeChan('#foo');
1081
+ // Simulate a topic that survived a deploy in storage and was loaded
1082
+ // onto the authoritative ChannelState before this JOIN (empty roster,
1083
+ // no KEEPTOPIC restore path taken).
1084
+ chan.topic = { text: 'persisted topic', setter: 'alice!alice@example.com', setAt: 5_000 };
1085
+ const conn = makeConn('c1', 'bob');
1086
+ const ctx = makeCtx(conn);
1087
+
1088
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1089
+
1090
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1091
+ expect(texts).toContain(':irc.example.com 332 bob #foo :persisted topic');
1092
+ expect(
1093
+ texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 5000')),
1094
+ ).toBe(true);
1095
+ });
1096
+
1097
+ it('emits 332/333 to a second joiner when the topic was set by an earlier joiner', () => {
1098
+ const chan = makeChan('#foo');
1099
+ addMember(chan, 'c0', 'alice', true);
1100
+ // Topic set by an earlier joiner on a non-empty channel.
1101
+ chan.topic = { text: 'hello world', setter: 'alice!alice@example.com', setAt: 1_000 };
1102
+ const conn = makeConn('c1', 'bob');
1103
+ const ctx = makeCtx(conn);
1104
+
1105
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1106
+
1107
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1108
+ expect(texts).toContain(':irc.example.com 332 bob #foo :hello world');
1109
+ expect(
1110
+ texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 1000')),
1111
+ ).toBe(true);
1112
+ });
1113
+
1114
+ it('emits 332/333 after the 353/366 NAMES list (existing emission order)', () => {
1115
+ const chan = makeChan('#foo');
1116
+ chan.topic = { text: 't', setter: 's', setAt: 1 };
1117
+ const conn = makeConn('c1', 'bob');
1118
+ const ctx = makeCtx(conn);
1119
+
1120
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1121
+
1122
+ const send = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1123
+ const idx353 = send.findIndex((t) => t.includes(' 353 '));
1124
+ const idx366 = send.findIndex((t) => t.includes(' 366 '));
1125
+ const idx332 = send.findIndex((t) => t.includes(' 332 '));
1126
+ expect(idx353).toBeGreaterThanOrEqual(0);
1127
+ expect(idx366).toBeGreaterThan(idx353);
1128
+ expect(idx332).toBeGreaterThan(idx366);
1129
+ });
1130
+
1131
+ it('does not emit 332/333 when no topic is set', () => {
1132
+ const chan = makeChan('#foo');
1133
+ const conn = makeConn();
1134
+ const ctx = makeCtx(conn);
1135
+
1136
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1137
+
1138
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1139
+ expect(texts.some((t) => t.includes(' 332 '))).toBe(false);
1140
+ expect(texts.some((t) => t.includes(' 333 '))).toBe(false);
1141
+ });
1142
+ });
1143
+
1067
1144
  // ============================================================================
1068
1145
  // joinReducer — ChanServ ACCESS auto-op / auto-voice
1069
1146
  // ============================================================================
@@ -1269,6 +1346,108 @@ describe('joinReducer — ChanServ ACCESS auto-prefix', () => {
1269
1346
  expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
1270
1347
  });
1271
1348
 
1349
+ it('does not auto-op the first joiner of an empty registered channel when off the access list', () => {
1350
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1351
+ services.registerNick('alice', 'pw', 'a@e');
1352
+ services.registerNick('bob', 'pw', 'b@e');
1353
+ services.registerChannel('#foo', 'alice');
1354
+ const chan = makeChan('#foo');
1355
+ expect(chan.members.size).toBe(0);
1356
+ const conn = makeConn('c1', 'bob');
1357
+ conn.account = 'bob';
1358
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1359
+
1360
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1361
+
1362
+ const entry = out.state.members.get('c1');
1363
+ expect(entry?.op).toBe(false);
1364
+ expect(entry?.voice).toBe(false);
1365
+ const broadcasts = out.effects.flatMap((e) =>
1366
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1367
+ );
1368
+ expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
1369
+ expect(broadcasts.some((t) => t.includes('+o bob'))).toBe(false);
1370
+ });
1371
+
1372
+ it('auto-ops the first joiner of an empty registered channel when they hold AUTOOP-level access', () => {
1373
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1374
+ services.registerNick('alice', 'pw', 'a@e');
1375
+ services.registerNick('bob', 'pw', 'b@e');
1376
+ services.registerChannel('#foo', 'alice');
1377
+ services.setChannelAccess('#foo', 'bob', 5);
1378
+ const chan = makeChan('#foo');
1379
+ expect(chan.members.size).toBe(0);
1380
+ const conn = makeConn('c1', 'bob');
1381
+ conn.account = 'bob';
1382
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1383
+
1384
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1385
+
1386
+ const entry = out.state.members.get('c1');
1387
+ expect(entry?.op).toBe(true);
1388
+ });
1389
+
1390
+ it('does not auto-op an unidentified first joiner of an empty registered channel', () => {
1391
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1392
+ services.registerNick('alice', 'pw', 'a@e');
1393
+ services.registerChannel('#foo', 'alice');
1394
+ const chan = makeChan('#foo');
1395
+ expect(chan.members.size).toBe(0);
1396
+ const conn = makeConn('c1', 'bob');
1397
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1398
+
1399
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1400
+
1401
+ const entry = out.state.members.get('c1');
1402
+ expect(entry?.op).toBe(false);
1403
+ });
1404
+
1405
+ it('still auto-ops the founder first-joining an empty registered channel', () => {
1406
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1407
+ services.registerNick('alice', 'pw', 'a@e');
1408
+ services.registerChannel('#foo', 'alice');
1409
+ const chan = makeChan('#foo');
1410
+ expect(chan.members.size).toBe(0);
1411
+ const conn = makeConn('c1', 'alice');
1412
+ conn.account = 'alice';
1413
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1414
+
1415
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1416
+
1417
+ const entry = out.state.members.get('c1');
1418
+ expect(entry?.op).toBe(true);
1419
+ });
1420
+
1421
+ it('auto-ops the first joiner of an empty channel when services are bound but the channel is unregistered', () => {
1422
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1423
+ services.registerNick('bob', 'pw', 'b@e');
1424
+ const chan = makeChan('#foo');
1425
+ expect(chan.members.size).toBe(0);
1426
+ const conn = makeConn('c1', 'bob');
1427
+ conn.account = 'bob';
1428
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1429
+
1430
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1431
+
1432
+ const entry = out.state.members.get('c1');
1433
+ expect(entry?.op).toBe(true);
1434
+ });
1435
+
1436
+ it('auto-ops the first joiner of an empty channel when no services store is bound', () => {
1437
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1438
+ services.registerNick('bob', 'pw', 'b@e');
1439
+ const chan = makeChan('#foo');
1440
+ expect(chan.members.size).toBe(0);
1441
+ const conn = makeConn('c1', 'bob');
1442
+ conn.account = 'bob';
1443
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, undefined);
1444
+
1445
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1446
+
1447
+ const entry = out.state.members.get('c1');
1448
+ expect(entry?.op).toBe(true);
1449
+ });
1450
+
1272
1451
  it('does nothing when no services store is bound', () => {
1273
1452
  const chan = makeChan('#foo');
1274
1453
  chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
@@ -283,6 +283,144 @@ describe('nickservReducer — IDENTIFY', () => {
283
283
  });
284
284
  });
285
285
 
286
+ describe('nickservReducer — ID (alias for IDENTIFY)', () => {
287
+ it('sets +r and account on correct password (ID <password>)', () => {
288
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
289
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
290
+ const conn = makeConn();
291
+ const ctx = makeCtx(conn, services);
292
+
293
+ const out = nickservReducer(conn, privmsg('ID hunter2'), ctx);
294
+
295
+ expect(conn.account).toBe('alice');
296
+ expect(conn.userModes.registered).toBe(true);
297
+ expect(sentTexts(out.effects)).toContain(
298
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
299
+ );
300
+ });
301
+
302
+ it('is case-insensitive on the subcommand (id)', () => {
303
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
304
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
305
+ const conn = makeConn();
306
+ const ctx = makeCtx(conn, services);
307
+
308
+ nickservReducer(conn, privmsg('id hunter2'), ctx);
309
+
310
+ expect(conn.account).toBe('alice');
311
+ expect(conn.userModes.registered).toBe(true);
312
+ });
313
+
314
+ it('rejects ID with wrong password (no +r, NOTICE only)', () => {
315
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
316
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
317
+ const conn = makeConn();
318
+ const ctx = makeCtx(conn, services);
319
+
320
+ const out = nickservReducer(conn, privmsg('ID wrongpw'), ctx);
321
+
322
+ expect(conn.account).toBeUndefined();
323
+ expect(conn.userModes.registered).toBe(false);
324
+ expect(sentTexts(out.effects)).toContain(
325
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
326
+ );
327
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(0);
328
+ });
329
+
330
+ it('sets +r and account for ID <nick> <password>', () => {
331
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
332
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
333
+ // Connection is using nick "bob" but identifies as "alice".
334
+ const conn = makeConn('c1', 'bob');
335
+ const ctx = makeCtx(conn, services);
336
+
337
+ const out = nickservReducer(conn, privmsg('ID alice hunter2'), ctx);
338
+
339
+ expect(conn.account).toBe('alice');
340
+ expect(conn.userModes.registered).toBe(true);
341
+ expect(sentTexts(out.effects)).toContain(
342
+ ':NickServ!NickServ@services NOTICE bob :You are now identified for nick alice.',
343
+ );
344
+ });
345
+ });
346
+
347
+ // ============================================================================
348
+ // IDENTIFY — HostServ vhost auto-apply on identify
349
+ // ============================================================================
350
+
351
+ describe('nickservReducer — IDENTIFY applies HostServ vhost', () => {
352
+ it('applies an assigned vhost on a successful IDENTIFY', () => {
353
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
354
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
355
+ services.setVhost('alice', 'cool.example.net');
356
+ const conn = makeConn();
357
+ conn.host = 'real.example.net';
358
+ const ctx = makeCtx(conn, services);
359
+
360
+ nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
361
+
362
+ expect(conn.account).toBe('alice');
363
+ expect(conn.vhostActive).toBe(true);
364
+ expect(conn.host).toBe('cool.example.net');
365
+ expect(conn.preVhostHost).toBe('real.example.net');
366
+ });
367
+
368
+ it('leaves the host unchanged on IDENTIFY when no vhost is assigned', () => {
369
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
370
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
371
+ const conn = makeConn();
372
+ conn.host = 'real.example.net';
373
+ const ctx = makeCtx(conn, services);
374
+
375
+ nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
376
+
377
+ expect(conn.vhostActive).toBeUndefined();
378
+ expect(conn.host).toBe('real.example.net');
379
+ });
380
+
381
+ it('emits a CHGHOST broadcast to joined peers on a post-JOIN IDENTIFY', () => {
382
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
383
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
384
+ services.setVhost('alice', 'cool.example.net');
385
+ const conn = makeConn();
386
+ conn.user = 'aliceuser';
387
+ conn.host = 'real.example.net';
388
+ conn.joinedChannels.add('#foo');
389
+ const ctx = makeCtx(conn, services);
390
+
391
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
392
+
393
+ const chghost = out.effects.filter((e) => e.tag === 'Broadcast' && e.cap === 'chghost');
394
+ expect(chghost).toHaveLength(1);
395
+ if (chghost[0]?.tag === 'Broadcast') {
396
+ expect(chghost[0].chan).toBe('#foo');
397
+ expect(chghost[0].capLines?.[0]?.text).toBe(
398
+ ':alice!aliceuser@real.example.net CHGHOST aliceuser :cool.example.net',
399
+ );
400
+ }
401
+ });
402
+
403
+ it('still emits the NickServ confirmation NOTICE and ACCOUNT broadcast alongside the vhost apply', () => {
404
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
405
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
406
+ services.setVhost('alice', 'cool.example.net');
407
+ const conn = makeConn();
408
+ conn.host = 'real.example.net';
409
+ conn.joinedChannels.add('#foo');
410
+ const ctx = makeCtx(conn, services);
411
+
412
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
413
+
414
+ expect(sentTexts(out.effects)).toContain(
415
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
416
+ );
417
+ const accountBcast = out.effects.find(
418
+ (e) => e.tag === 'Broadcast' && e.cap === 'account-notify',
419
+ );
420
+ expect(accountBcast).toBeDefined();
421
+ });
422
+ });
423
+
286
424
  describe('nickservReducer — DROP', () => {
287
425
  it('removes the registration when the connection is identified', () => {
288
426
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
@@ -364,10 +502,11 @@ describe('nickservReducer — DROP', () => {
364
502
  });
365
503
 
366
504
  describe('nickservReducer — INFO', () => {
367
- it('reports info for a registered nick', () => {
505
+ it('shows the full record (incl. Email) to the owner via INFO with no target', () => {
368
506
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
369
507
  services.registerNick('alice', 'hunter2', 'alice@example.com');
370
508
  const conn = makeConn();
509
+ conn.account = 'alice';
371
510
  const ctx = makeCtx(conn, services);
372
511
 
373
512
  const out = nickservReducer(conn, privmsg('INFO'), ctx);
@@ -378,10 +517,54 @@ describe('nickservReducer — INFO', () => {
378
517
  expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Email: alice@example.com');
379
518
  });
380
519
 
381
- it('reports info for an explicitly named registered nick', () => {
520
+ it('withholds Email from a non-owner querying someone else nick', () => {
521
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
522
+ services.registerNick('bob', 'pw', 'bob@example.com');
523
+ const conn = makeConn();
524
+ const ctx = makeCtx(conn, services);
525
+
526
+ const out = nickservReducer(conn, privmsg('INFO bob'), ctx);
527
+
528
+ const texts = sentTexts(out.effects);
529
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Nick: bob');
530
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Account: bob');
531
+ expect(texts).not.toContain(':NickServ!NickServ@services NOTICE alice :Email: bob@example.com');
532
+ });
533
+
534
+ it('withholds Email from a non-owner querying their own nick when unidentified', () => {
535
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
536
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
537
+ const conn = makeConn();
538
+ const ctx = makeCtx(conn, services);
539
+
540
+ const out = nickservReducer(conn, privmsg('INFO'), ctx);
541
+
542
+ const texts = sentTexts(out.effects);
543
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Nick: alice');
544
+ expect(texts).not.toContain(
545
+ ':NickServ!NickServ@services NOTICE alice :Email: alice@example.com',
546
+ );
547
+ });
548
+
549
+ it('shows Email to the owner querying their own nick by name', () => {
550
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
551
+ services.registerNick('bob', 'pw', 'bob@example.com');
552
+ const conn = makeConn('c1', 'bob');
553
+ conn.account = 'bob';
554
+ const ctx = makeCtx(conn, services);
555
+
556
+ const out = nickservReducer(conn, privmsg('INFO bob'), ctx);
557
+
558
+ const texts = sentTexts(out.effects);
559
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: bob');
560
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Email: bob@example.com');
561
+ });
562
+
563
+ it('shows Email to an oper even when not the owner', () => {
382
564
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
383
565
  services.registerNick('bob', 'pw', 'bob@example.com');
384
566
  const conn = makeConn();
567
+ conn.userModes.oper = true;
385
568
  const ctx = makeCtx(conn, services);
386
569
 
387
570
  const out = nickservReducer(conn, privmsg('INFO bob'), ctx);