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
@@ -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);
@@ -439,7 +622,7 @@ describe('nickservReducer — misc', () => {
439
622
  const out = nickservReducer(conn, privmsg('FROBNICATE foo bar'), ctx);
440
623
 
441
624
  expect(sentTexts(out.effects)).toContain(
442
- ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET',
625
+ ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
443
626
  );
444
627
  });
445
628
 
@@ -674,6 +857,242 @@ describe('nickservReducer — SET ENFORCE', () => {
674
857
  });
675
858
  });
676
859
 
860
+ // ============================================================================
861
+ // SET PASSWORD
862
+ // ============================================================================
863
+
864
+ describe('nickservReducer — SET PASSWORD', () => {
865
+ it('changes the password when the caller supplies the correct old one', () => {
866
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
867
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
868
+ const conn = makeConn();
869
+ conn.account = 'alice';
870
+ conn.userModes.registered = true;
871
+ const ctx = makeCtx(conn, services);
872
+
873
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
874
+
875
+ expect(sentTexts(out.effects)).toContain(
876
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
877
+ );
878
+ // The new password verifies, the old one no longer does.
879
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(true);
880
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(false);
881
+ });
882
+
883
+ it('rejects a wrong old password and leaves the credential unchanged', () => {
884
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
885
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
886
+ const conn = makeConn();
887
+ conn.account = 'alice';
888
+ conn.userModes.registered = true;
889
+ const ctx = makeCtx(conn, services);
890
+
891
+ const out = nickservReducer(conn, privmsg('SET PASSWORD wrongold newpassword'), ctx);
892
+
893
+ expect(sentTexts(out.effects)).toContain(
894
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
895
+ );
896
+ // The original password still verifies; the new one does not.
897
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
898
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
899
+ });
900
+
901
+ it('requires identification before SET PASSWORD (unidentified caller)', () => {
902
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
903
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
904
+ const conn = makeConn();
905
+ // No account / +r.
906
+ const ctx = makeCtx(conn, services);
907
+
908
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
909
+
910
+ expect(sentTexts(out.effects)).toContain(
911
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing your password.',
912
+ );
913
+ // Credential unchanged.
914
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
915
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
916
+ });
917
+
918
+ it('rejects SET PASSWORD when identified as a different account', () => {
919
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
920
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
921
+ // bob is identified, but is using alice's nick.
922
+ services.registerNick('bob', 'bobpw', 'bob@example.com');
923
+ const conn = makeConn('c1', 'alice');
924
+ conn.account = 'bob';
925
+ conn.userModes.registered = true;
926
+ const ctx = makeCtx(conn, services);
927
+
928
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
929
+
930
+ expect(sentTexts(out.effects)).toContain(
931
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing your password.',
932
+ );
933
+ // Credential unchanged.
934
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
935
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
936
+ });
937
+
938
+ it('emits the syntax notice when args are missing', () => {
939
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
940
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
941
+ const conn = makeConn();
942
+ conn.account = 'alice';
943
+ conn.userModes.registered = true;
944
+ const ctx = makeCtx(conn, services);
945
+
946
+ const noArgs = nickservReducer(conn, privmsg('SET PASSWORD'), ctx);
947
+ expect(sentTexts(noArgs.effects)).toContain(
948
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET PASSWORD <old-password> <new-password>',
949
+ );
950
+
951
+ const oneArg = nickservReducer(conn, privmsg('SET PASSWORD only-one'), ctx);
952
+ expect(sentTexts(oneArg.effects)).toContain(
953
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET PASSWORD <old-password> <new-password>',
954
+ );
955
+ // Credential unchanged.
956
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
957
+ });
958
+
959
+ it('rejects a new password shorter than the default minimum (8)', () => {
960
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
961
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
962
+ const conn = makeConn();
963
+ conn.account = 'alice';
964
+ conn.userModes.registered = true;
965
+ const ctx = makeCtx(conn, services);
966
+
967
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 short'), ctx);
968
+
969
+ expect(sentTexts(out.effects)).toContain(
970
+ ':NickServ!NickServ@services NOTICE alice :Password is too short (minimum 8 characters).',
971
+ );
972
+ // Credential unchanged.
973
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
974
+ expect(services.verifyNick('alice', 'short').ok).toBe(false);
975
+ });
976
+
977
+ it('honours a configured nickServ.minPasswordLength override', () => {
978
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
979
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
980
+ const conn = makeConn();
981
+ conn.account = 'alice';
982
+ conn.userModes.registered = true;
983
+ const ctx: Ctx = buildCtx({
984
+ serverConfig: { ...serverConfig, nickServ: { minPasswordLength: 12 } },
985
+ clock: new FakeClock(NOW),
986
+ ids: new SequentialIdFactory(),
987
+ motd: EmptyMotdProvider,
988
+ connection: conn,
989
+ services,
990
+ });
991
+
992
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 elevenchars'), ctx);
993
+
994
+ expect(sentTexts(out.effects)).toContain(
995
+ ':NickServ!NickServ@services NOTICE alice :Password is too short (minimum 12 characters).',
996
+ );
997
+ // Credential unchanged.
998
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
999
+ });
1000
+
1001
+ it('rejects a new password longer than the maximum (256)', () => {
1002
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1003
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1004
+ const conn = makeConn();
1005
+ conn.account = 'alice';
1006
+ conn.userModes.registered = true;
1007
+ const ctx = makeCtx(conn, services);
1008
+
1009
+ const tooLong = 'x'.repeat(257);
1010
+ const out = nickservReducer(conn, privmsg(`SET PASSWORD hunter2 ${tooLong}`), ctx);
1011
+
1012
+ expect(sentTexts(out.effects)).toContain(
1013
+ ':NickServ!NickServ@services NOTICE alice :Password is too long (maximum 256 characters).',
1014
+ );
1015
+ // Credential unchanged.
1016
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1017
+ });
1018
+
1019
+ it('keeps the session +r after a successful change (no re-identify required)', () => {
1020
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1021
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1022
+ const conn = makeConn();
1023
+ conn.account = 'alice';
1024
+ conn.userModes.registered = true;
1025
+ const ctx = makeCtx(conn, services);
1026
+
1027
+ nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1028
+
1029
+ expect(conn.userModes.registered).toBe(true);
1030
+ expect(conn.account).toBe('alice');
1031
+ });
1032
+
1033
+ it('accepts a new password exactly at the minimum length boundary (8)', () => {
1034
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1035
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1036
+ const conn = makeConn();
1037
+ conn.account = 'alice';
1038
+ conn.userModes.registered = true;
1039
+ const ctx = makeCtx(conn, services);
1040
+
1041
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 exactly8'), ctx);
1042
+
1043
+ expect(sentTexts(out.effects)).toContain(
1044
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1045
+ );
1046
+ expect(services.verifyNick('alice', 'exactly8').ok).toBe(true);
1047
+ });
1048
+
1049
+ it('accepts a new password exactly at the maximum length boundary (256)', () => {
1050
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1051
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1052
+ const conn = makeConn();
1053
+ conn.account = 'alice';
1054
+ conn.userModes.registered = true;
1055
+ const ctx = makeCtx(conn, services);
1056
+
1057
+ const boundary = 'x'.repeat(256);
1058
+ const out = nickservReducer(conn, privmsg(`SET PASSWORD hunter2 ${boundary}`), ctx);
1059
+
1060
+ expect(sentTexts(out.effects)).toContain(
1061
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1062
+ );
1063
+ expect(services.verifyNick('alice', boundary).ok).toBe(true);
1064
+ });
1065
+
1066
+ it('is case-insensitive on the PASSWORD subkey', () => {
1067
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1068
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1069
+ const conn = makeConn();
1070
+ conn.account = 'alice';
1071
+ conn.userModes.registered = true;
1072
+ const ctx = makeCtx(conn, services);
1073
+
1074
+ const out = nickservReducer(conn, privmsg('set password hunter2 newpassword'), ctx);
1075
+
1076
+ expect(sentTexts(out.effects)).toContain(
1077
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1078
+ );
1079
+ });
1080
+
1081
+ it('rejects SET PASSWORD on an unregistered nick', () => {
1082
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1083
+ const conn = makeConn();
1084
+ conn.account = 'alice';
1085
+ conn.userModes.registered = true;
1086
+ const ctx = makeCtx(conn, services);
1087
+
1088
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1089
+
1090
+ expect(sentTexts(out.effects)).toContain(
1091
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is not registered.',
1092
+ );
1093
+ });
1094
+ });
1095
+
677
1096
  // ============================================================================
678
1097
  // enforceRegisteredNick — the enforcement hook fired by the NICK path
679
1098
  // ============================================================================
@@ -81,6 +81,21 @@ describe('operReducer — successful authentication', () => {
81
81
  expect(modeLines).toContain(':alice!alice@example.com MODE alice +o');
82
82
  });
83
83
 
84
+ it('falls back to ? / * in the MODE echo when the connection has no nick or hostmask', () => {
85
+ // A bare connection with no nick/user/host: hostmaskOf returns undefined
86
+ // and state.nick is undefined, so modeEchoLine falls back to '?' / '*'.
87
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
88
+ const ctx = makeCtx(state);
89
+
90
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
91
+
92
+ expect(out.state.userModes.oper).toBe(true);
93
+ const modeLines = out.effects
94
+ .filter((e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send')
95
+ .flatMap((e) => e.lines.map((l) => l.text));
96
+ expect(modeLines).toContain(':? MODE * +o');
97
+ });
98
+
84
99
  it('matches the second configured credential pair', () => {
85
100
  const state = makeState();
86
101
  const ctx = makeCtx(state);