serverless-ircd 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import pkg from '../../package.json' with { type: 'json' };
3
3
  import { cloakHost } from '../../src/cloak';
4
4
  import {
@@ -27,14 +27,7 @@ import {
27
27
  SequentialIdFactory,
28
28
  StaticMotdProvider,
29
29
  } from '../../src/ports';
30
- import type {
31
- AccountStore,
32
- AwayStore,
33
- MotdProvider,
34
- SaslPayload,
35
- SaslResult,
36
- ServicesStore,
37
- } from '../../src/ports';
30
+ import type { AwayStore, MotdProvider, ServicesStore } from '../../src/ports';
38
31
  import { type ConnectionState, createConnection } from '../../src/state/connection';
39
32
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
40
33
 
@@ -577,12 +570,15 @@ describe('passReducer', () => {
577
570
  expect(out.effects).toEqual([]);
578
571
  });
579
572
 
580
- it('overwrites a previous passAttempt on repeated PASS', () => {
573
+ it('emits 462 when PASS is sent twice before registration completes', () => {
581
574
  const state = makeState();
582
575
  state.passAttempt = 'first';
583
576
  const ctx = makeCtx(state);
584
577
  const out = passReducer(state, { command: 'PASS', params: ['second'], tags: {} }, ctx);
585
- expect(out.state.passAttempt).toBe('second');
578
+ expect(out.effects).toEqual<EffectType[]>([
579
+ Effect.send('c1', [L(':irc.example.com 462 * :You may not reregister')]),
580
+ ]);
581
+ expect(out.state.passAttempt).toBe('first');
586
582
  });
587
583
 
588
584
  it('emits 461 when no password is supplied', () => {
@@ -595,14 +591,129 @@ describe('passReducer', () => {
595
591
  expect(out.state.passAttempt).toBeUndefined();
596
592
  });
597
593
 
598
- it('emits 462 when already registered', () => {
594
+ it('emits 461 when no password is supplied even after registration', () => {
599
595
  const state = registeredState();
600
596
  const ctx = makeCtx(state);
601
- const out = passReducer(state, { command: 'PASS', params: ['secret'], tags: {} }, ctx);
597
+ const out = passReducer(state, { command: 'PASS', params: [], tags: {} }, ctx);
602
598
  expect(out.effects).toEqual<EffectType[]>([
603
- Effect.send('c1', [L(':irc.example.com 462 alice :You may not reregister')]),
599
+ Effect.send('c1', [L(':irc.example.com 461 alice PASS :Not enough parameters')]),
604
600
  ]);
605
- expect(out.state.passAttempt).toBeUndefined();
601
+ });
602
+ });
603
+
604
+ // ============================================================================
605
+ // passReducer — post-registration PASS (late account login)
606
+ // ============================================================================
607
+
608
+ describe('passReducer — post-registration PASS <nick>:<password>', () => {
609
+ it('logs in when PASS <nick>:<password> matches an account after registration', () => {
610
+ const state = registeredState();
611
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
612
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
613
+ const ctx = makeCtxWithServices(state, services);
614
+ const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
615
+
616
+ expect(out.state.account).toBe('alice');
617
+ expect(out.state.userModes.registered).toBe(true);
618
+ const texts = sentTexts(out.effects);
619
+ expect(texts).toContain(
620
+ `:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
621
+ );
622
+ expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
623
+ expect(texts.some((t) => t.includes(' 462 '))).toBe(false);
624
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
625
+ });
626
+
627
+ it('does not log in or disconnect when the password is wrong', () => {
628
+ const state = registeredState();
629
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
630
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
631
+ const ctx = makeCtxWithServices(state, services);
632
+ const out = passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
633
+
634
+ expect(out.state.account).toBeUndefined();
635
+ expect(out.state.userModes.registered).toBe(false);
636
+ expect(out.effects).toEqual([]);
637
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
638
+ });
639
+
640
+ it('is indistinguishable from an unknown nick (no auth numerics, no disconnect)', () => {
641
+ const state = registeredState();
642
+ state.nick = 'nobody';
643
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
644
+ const ctx = makeCtxWithServices(state, services);
645
+ const out = passReducer(state, { command: 'PASS', params: ['nobody:wrong'], tags: {} }, ctx);
646
+
647
+ expect(out.state.account).toBeUndefined();
648
+ expect(out.effects).toEqual([]);
649
+ });
650
+
651
+ it('equalises timing with an extra dummy verify on failure', () => {
652
+ const state = registeredState();
653
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
654
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
655
+ const verifyNick = vi.spyOn(services, 'verifyNick');
656
+ const ctx = makeCtxWithServices(state, services);
657
+ passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
658
+
659
+ // Two calls: the real (failed) verify against `alice`, then the
660
+ // timing-equalising dummy verify.
661
+ expect(verifyNick).toHaveBeenCalledTimes(2);
662
+ expect(verifyNick.mock.calls[0]?.[0]).toBe('alice');
663
+ expect(verifyNick.mock.calls[0]?.[1]).toBe('wrong');
664
+ // The second call is the dummy; its nick is the sentinel, not 'alice'.
665
+ expect(verifyNick.mock.calls[1]?.[0]).not.toBe('alice');
666
+ });
667
+
668
+ it('does not log in when the payload nick does not match state.nick', () => {
669
+ const state = registeredState();
670
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
671
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
672
+ const verifyNick = vi.spyOn(services, 'verifyNick');
673
+ const ctx = makeCtxWithServices(state, services);
674
+ const out = passReducer(state, { command: 'PASS', params: ['bob:hunter2'], tags: {} }, ctx);
675
+
676
+ expect(out.state.account).toBeUndefined();
677
+ expect(verifyNick).not.toHaveBeenCalled();
678
+ expect(out.effects).toEqual([]);
679
+ });
680
+
681
+ it('ignores a bare PASS value (no colon) after registration — no 462, no disconnect', () => {
682
+ const state = registeredState();
683
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
684
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
685
+ const verifyNick = vi.spyOn(services, 'verifyNick');
686
+ const ctx = makeCtxWithServices(state, services);
687
+ const out = passReducer(state, { command: 'PASS', params: ['serverpw'], tags: {} }, ctx);
688
+
689
+ expect(out.state.account).toBeUndefined();
690
+ expect(out.effects).toEqual([]);
691
+ expect(verifyNick).not.toHaveBeenCalled();
692
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
693
+ });
694
+
695
+ it('ignores a late PASS when the connection is already identified', () => {
696
+ const state = registeredState();
697
+ state.account = 'alice';
698
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
699
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
700
+ const verifyNick = vi.spyOn(services, 'verifyNick');
701
+ const ctx = makeCtxWithServices(state, services);
702
+ const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
703
+
704
+ expect(verifyNick).not.toHaveBeenCalled();
705
+ expect(out.effects).toEqual([]);
706
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
707
+ });
708
+
709
+ it('ignores a late PASS <nick>:<password> when no ServicesStore is bound', () => {
710
+ const state = registeredState();
711
+ const ctx = makeCtx(state);
712
+ const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
713
+
714
+ expect(out.state.account).toBeUndefined();
715
+ expect(out.effects).toEqual([]);
716
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
606
717
  });
607
718
  });
608
719
 
@@ -1522,32 +1633,6 @@ describe('emitWelcomeIfReady — OperServ AKILL admission', () => {
1522
1633
  // PASS-based account auth (NICK + PASS <nick>:<password> + USER → identified)
1523
1634
  // ============================================================================
1524
1635
 
1525
- /** A capture-and-respond fake AccountStore (mirrors the sasl.test.ts fake). */
1526
- class FakeAccountStore implements AccountStore {
1527
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
1528
- constructor(private readonly result: SaslResult) {}
1529
- verify(mech: string, payload: SaslPayload): SaslResult {
1530
- this.calls.push({ mech, payload });
1531
- return this.result;
1532
- }
1533
- }
1534
-
1535
- /** Like {@link makeCtx} but also threads a bound AccountStore. */
1536
- function makeCtxWithAccounts(
1537
- state: ConnectionState,
1538
- accounts: AccountStore,
1539
- clock = new FakeClock(1_000),
1540
- ): Ctx {
1541
- return buildCtx({
1542
- serverConfig,
1543
- clock,
1544
- ids: new SequentialIdFactory(),
1545
- motd: EmptyMotdProvider,
1546
- connection: state,
1547
- accounts,
1548
- });
1549
- }
1550
-
1551
1636
  const SV = 'irc.example.com';
1552
1637
  const L2 = (text: string): RawLine => ({ text });
1553
1638
 
@@ -1558,8 +1643,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
1558
1643
  state.user = 'alice';
1559
1644
  state.host = 'example.com';
1560
1645
  state.passAttempt = 'alice:hunter2';
1561
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1562
- const ctx = makeCtxWithAccounts(state, accounts);
1646
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1647
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1648
+ const ctx = makeCtxWithServices(state, services);
1563
1649
 
1564
1650
  const effects = emitWelcomeIfReady(state, ctx);
1565
1651
 
@@ -1576,11 +1662,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
1576
1662
 
1577
1663
  // ---- helpers for the broader PASS-auth coverage ----
1578
1664
 
1579
- /** Flexible ctx that binds any combination of account/services/away stores. */
1665
+ /** Flexible ctx that binds any combination of services/away stores. */
1580
1666
  function makeCtxPassAuth(
1581
1667
  state: ConnectionState,
1582
1668
  opts: {
1583
- accounts?: AccountStore;
1584
1669
  services?: ServicesStore;
1585
1670
  away?: AwayStore;
1586
1671
  serverPassword?: string;
@@ -1595,7 +1680,6 @@ function makeCtxPassAuth(
1595
1680
  ids: new SequentialIdFactory(),
1596
1681
  motd: EmptyMotdProvider,
1597
1682
  connection: state,
1598
- ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
1599
1683
  ...(opts.services !== undefined ? { services: opts.services } : {}),
1600
1684
  ...(opts.away !== undefined ? { away: opts.away } : {}),
1601
1685
  });
@@ -1607,16 +1691,16 @@ function sentTexts(effects: EffectType[]): string[] {
1607
1691
  }
1608
1692
 
1609
1693
  describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity with SASL)', () => {
1610
- it('forwards the parsed credentials to the AccountStore as a PLAIN verify', () => {
1694
+ it('forwards the parsed credentials to services.verifyNick as a PLAIN verify', () => {
1611
1695
  const state = makeState();
1612
1696
  state.nick = 'alice';
1613
1697
  state.user = 'alice';
1614
1698
  state.passAttempt = 'alice:hunter2';
1615
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1616
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1617
- expect(accounts.calls).toEqual([
1618
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'hunter2' } },
1619
- ]);
1699
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1700
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1701
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1702
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1703
+ expect(verifyNick).toHaveBeenCalledWith('alice', 'hunter2');
1620
1704
  });
1621
1705
 
1622
1706
  it('stamps read-only user mode `r` on a successful PASS-auth', () => {
@@ -1624,8 +1708,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1624
1708
  state.nick = 'alice';
1625
1709
  state.user = 'alice';
1626
1710
  state.passAttempt = 'alice:hunter2';
1627
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1628
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1711
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1712
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1713
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1629
1714
  expect(state.userModes.registered).toBe(true);
1630
1715
  });
1631
1716
 
@@ -1634,10 +1719,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1634
1719
  state.nick = 'alice';
1635
1720
  state.user = 'alice';
1636
1721
  state.passAttempt = 'alice:hunter2';
1637
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1638
1722
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1723
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1639
1724
  services.setLastReadMarker('alice', '#foo', 'm1');
1640
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
1725
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1641
1726
  expect(state.lastReadMarkers?.get('#foo')).toBe('m1');
1642
1727
  });
1643
1728
 
@@ -1646,10 +1731,11 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1646
1731
  state.nick = 'alice';
1647
1732
  state.user = 'alice';
1648
1733
  state.passAttempt = 'alice:hunter2';
1649
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1734
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1735
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1650
1736
  const away = new InMemoryAwayStore();
1651
1737
  away.set('alice', 'brb');
1652
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, away }));
1738
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services, away }));
1653
1739
  expect(state.away).toBe('brb');
1654
1740
  expect(sentTexts(effects)).toContain(`:${SV} 306 alice :You have been marked as being away`);
1655
1741
  });
@@ -1663,18 +1749,87 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1663
1749
  services.registerNick('alice', 'hunter2', 'alice@example.com');
1664
1750
  services.registerNick('bob', 'pw', 'bob@example.com');
1665
1751
  services.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: 0 });
1666
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1667
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
1752
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1668
1753
  expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
1669
1754
  });
1670
1755
 
1756
+ it('applies the assigned HostServ vhost on PASS-auth success so 001 carries the vhost', () => {
1757
+ const state = makeState();
1758
+ state.nick = 'alice';
1759
+ state.user = 'alice';
1760
+ state.host = 'real.example.net';
1761
+ state.passAttempt = 'alice:hunter2';
1762
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1763
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1764
+ services.setVhost('alice', 'cool.example.net');
1765
+ const ctx = makeCtxPassAuth(state, { services });
1766
+
1767
+ const effects = emitWelcomeIfReady(state, ctx);
1768
+
1769
+ expect(state.account).toBe('alice');
1770
+ expect(state.vhostActive).toBe(true);
1771
+ expect(state.host).toBe('cool.example.net');
1772
+ expect(state.preVhostHost).toBe('real.example.net');
1773
+ const welcome = sentTexts(effects).find((t) => t.startsWith(`:${SV} 001 `));
1774
+ expect(welcome).toBeDefined();
1775
+ expect(welcome).toContain('cool.example.net');
1776
+ });
1777
+
1778
+ it('does not apply a vhost on PASS-auth success when none is assigned', () => {
1779
+ const state = makeState();
1780
+ state.nick = 'alice';
1781
+ state.user = 'alice';
1782
+ state.host = 'real.example.net';
1783
+ state.passAttempt = 'alice:hunter2';
1784
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1785
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1786
+ const ctx = makeCtxPassAuth(state, { services });
1787
+
1788
+ emitWelcomeIfReady(state, ctx);
1789
+
1790
+ expect(state.vhostActive).toBeUndefined();
1791
+ expect(state.host).toBe('real.example.net');
1792
+ });
1793
+
1794
+ it('does not cloak over an active vhost applied during PASS-auth (vhost wins)', () => {
1795
+ const state = makeState();
1796
+ state.nick = 'alice';
1797
+ state.user = 'alice';
1798
+ state.host = '203.0.113.5';
1799
+ state.passAttempt = 'alice:hunter2';
1800
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1801
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1802
+ services.setVhost('alice', 'cool.example.net');
1803
+ const ctx: Ctx = buildCtx({
1804
+ serverConfig: { ...serverConfig, cloaking: { enabled: true, secret: 's3cret' } },
1805
+ clock: new FakeClock(1_000),
1806
+ ids: new SequentialIdFactory(),
1807
+ motd: EmptyMotdProvider,
1808
+ connection: state,
1809
+ services,
1810
+ });
1811
+
1812
+ const effects = emitWelcomeIfReady(state, ctx);
1813
+
1814
+ // The vhost was applied by the PASS-auth success chain; cloaking must not
1815
+ // clobber it, and the 001 welcome must carry the vhost, not the cloak.
1816
+ expect(state.vhostActive).toBe(true);
1817
+ expect(state.host).toBe('cool.example.net');
1818
+ const welcome = sentTexts(effects).find((t) => t.startsWith(`:${SV} 001 `));
1819
+ expect(welcome).toContain('cool.example.net');
1820
+ expect(welcome).not.toContain(
1821
+ cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'ExampleNet'),
1822
+ );
1823
+ });
1824
+
1671
1825
  it('records the payload nick as the canonical account name', () => {
1672
1826
  const state = makeState();
1673
1827
  state.nick = 'alice';
1674
1828
  state.user = 'alice';
1675
1829
  state.passAttempt = 'alice:hunter2';
1676
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1677
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1830
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1831
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1832
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1678
1833
  expect(state.account).toBe('alice');
1679
1834
  });
1680
1835
  });
@@ -1685,8 +1840,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1685
1840
  state.nick = 'alice';
1686
1841
  state.user = 'alice';
1687
1842
  state.passAttempt = 'alice:wrong';
1688
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1689
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1843
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1844
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1845
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1690
1846
 
1691
1847
  expect(state.account).toBeUndefined();
1692
1848
  expect(state.userModes.registered).toBe(false);
@@ -1702,8 +1858,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1702
1858
  state.nick = 'nobody';
1703
1859
  state.user = 'nobody';
1704
1860
  state.passAttempt = 'nobody:wrong';
1705
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1706
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1861
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1862
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1707
1863
 
1708
1864
  expect(state.account).toBeUndefined();
1709
1865
  expect(state.registration).toBe('registered');
@@ -1719,17 +1875,15 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1719
1875
  state.nick = 'alice';
1720
1876
  state.user = 'alice';
1721
1877
  state.passAttempt = 'alice:wrong';
1722
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1723
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1724
-
1725
- expect(accounts.calls).toHaveLength(2);
1726
- expect(accounts.calls[0]?.payload).toEqual({
1727
- kind: 'PLAIN',
1728
- username: 'alice',
1729
- password: 'wrong',
1730
- });
1731
- expect(accounts.calls[1]?.mech).toBe('PLAIN');
1732
- expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
1878
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1879
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1880
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1881
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1882
+
1883
+ expect(verifyNick).toHaveBeenCalledTimes(2);
1884
+ expect(verifyNick.mock.calls[0]?.[0]).toBe('alice');
1885
+ expect(verifyNick.mock.calls[0]?.[1]).toBe('wrong');
1886
+ expect(verifyNick.mock.calls[1]?.[0]).not.toBe('alice');
1733
1887
  });
1734
1888
 
1735
1889
  it('treats a payload nick that does not match state.nick as a failed auth (no silent login)', () => {
@@ -1737,19 +1891,21 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1737
1891
  state.nick = 'alice';
1738
1892
  state.user = 'alice';
1739
1893
  state.passAttempt = 'bob:hunter2';
1740
- const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
1741
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1894
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1895
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
1896
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1897
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1742
1898
 
1743
1899
  expect(state.account).toBeUndefined();
1744
1900
  expect(state.registration).toBe('registered');
1745
1901
  // The store is never consulted on a nick mismatch.
1746
- expect(accounts.calls).toHaveLength(0);
1902
+ expect(verifyNick).not.toHaveBeenCalled();
1747
1903
  expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
1748
1904
  });
1749
1905
  });
1750
1906
 
1751
1907
  describe('emitWelcomeIfReady — PASS-auth interaction with the server-password gate', () => {
1752
- it('ignores the <nick>:<password> form when no AccountStore is bound (open gate)', () => {
1908
+ it('ignores the <nick>:<password> form when no ServicesStore is bound (open gate)', () => {
1753
1909
  const state = makeState();
1754
1910
  state.nick = 'alice';
1755
1911
  state.user = 'alice';
@@ -1761,7 +1917,28 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1761
1917
  expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
1762
1918
  });
1763
1919
 
1764
- it('collapses to the server-password gate (464) when no AccountStore is bound and the colon form does not match', () => {
1920
+ it('a SASL-identified connection (state.account set) satisfies the gate with no credential-store read', () => {
1921
+ const state = makeState();
1922
+ state.nick = 'alice';
1923
+ state.user = 'alice';
1924
+ state.account = 'alice'; // SASL-identified before registration completion.
1925
+ state.passAttempt = 'alice:hunter2';
1926
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1927
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1928
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1929
+ const effects = emitWelcomeIfReady(
1930
+ state,
1931
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1932
+ );
1933
+
1934
+ // The gate is satisfied by state.account; no PASS-auth verify fires.
1935
+ expect(verifyNick).not.toHaveBeenCalled();
1936
+ expect(state.registration).toBe('registered');
1937
+ expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
1938
+ expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
1939
+ });
1940
+
1941
+ it('collapses to the server-password gate (464) when no ServicesStore is bound and the colon form does not match', () => {
1765
1942
  const state = makeState();
1766
1943
  state.nick = 'alice';
1767
1944
  state.user = 'alice';
@@ -1780,10 +1957,11 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1780
1957
  state.nick = 'alice';
1781
1958
  state.user = 'alice';
1782
1959
  state.passAttempt = 'alice:wrong';
1783
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1960
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1961
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1784
1962
  const effects = emitWelcomeIfReady(
1785
1963
  state,
1786
- makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
1964
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1787
1965
  );
1788
1966
 
1789
1967
  expect(state.registration).not.toBe('registered');
@@ -1800,10 +1978,12 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1800
1978
  state.user = 'alice';
1801
1979
  state.account = 'alice'; // SASL-identified before registration completion
1802
1980
  state.passAttempt = 'alice:hunter2';
1803
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1804
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1981
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1982
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1983
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1984
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1805
1985
 
1806
- expect(accounts.calls).toHaveLength(0);
1986
+ expect(verifyNick).not.toHaveBeenCalled();
1807
1987
  expect(state.registration).toBe('registered');
1808
1988
  // No 900/903 are re-emitted by PASS-auth; SASL already emitted them.
1809
1989
  expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
@@ -1814,13 +1994,15 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1814
1994
  state.nick = 'alice';
1815
1995
  state.user = 'alice';
1816
1996
  state.passAttempt = 's3cret';
1817
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1997
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1998
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1999
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1818
2000
  const effects = emitWelcomeIfReady(
1819
2001
  state,
1820
- makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
2002
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1821
2003
  );
1822
2004
 
1823
- expect(accounts.calls).toHaveLength(0);
2005
+ expect(verifyNick).not.toHaveBeenCalled();
1824
2006
  expect(state.account).toBeUndefined();
1825
2007
  expect(state.registration).toBe('registered');
1826
2008
  expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
@@ -1833,8 +2015,9 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
1833
2015
  state.user = 'alice';
1834
2016
  // state.nick intentionally undefined — PASS arrived before NICK.
1835
2017
  state.passAttempt = 'alice:hunter2';
1836
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1837
- const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
2018
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2019
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2020
+ const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { services }));
1838
2021
 
1839
2022
  expect(state.account).toBe('alice');
1840
2023
  expect(effects.some((e) => e.tag === 'Send')).toBe(true);
@@ -1843,26 +2026,31 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
1843
2026
  it('returns no effects when no PASS was sent', () => {
1844
2027
  const state = makeState();
1845
2028
  state.nick = 'alice';
1846
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1847
- expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
2029
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2030
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2031
+ expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
1848
2032
  });
1849
2033
 
1850
2034
  it('returns no effects for a bare PASS value (no colon)', () => {
1851
2035
  const state = makeState();
1852
2036
  state.nick = 'alice';
1853
2037
  state.passAttempt = 's3cret';
1854
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1855
- expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
1856
- expect(accounts.calls).toHaveLength(0);
2038
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2039
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2040
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2041
+ expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
2042
+ expect(verifyNick).not.toHaveBeenCalled();
1857
2043
  });
1858
2044
 
1859
2045
  it('returns no effects when the nick portion is empty (`:password`)', () => {
1860
2046
  const state = makeState();
1861
2047
  state.nick = 'alice';
1862
2048
  state.passAttempt = ':hunter2';
1863
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1864
- expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
1865
- expect(accounts.calls).toHaveLength(0);
2049
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2050
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2051
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2052
+ expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
2053
+ expect(verifyNick).not.toHaveBeenCalled();
1866
2054
  });
1867
2055
 
1868
2056
  it('splits only on the first colon so a password may contain colons', () => {
@@ -1870,16 +2058,13 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
1870
2058
  state.nick = 'alice';
1871
2059
  state.user = 'alice';
1872
2060
  state.passAttempt = 'alice:p:ass:word';
1873
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1874
- passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
1875
- expect(accounts.calls[0]?.payload).toEqual({
1876
- kind: 'PLAIN',
1877
- username: 'alice',
1878
- password: 'p:ass:word',
1879
- });
2061
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2062
+ services.registerNick('alice', 'p:ass:word', 'alice@example.com');
2063
+ passBasedAccountAuth(state, makeCtxPassAuth(state, { services }));
2064
+ expect(state.account).toBe('alice');
1880
2065
  });
1881
2066
 
1882
- it('returns no effects when no AccountStore is bound', () => {
2067
+ it('returns no effects when no ServicesStore is bound', () => {
1883
2068
  const state = makeState();
1884
2069
  state.nick = 'alice';
1885
2070
  state.passAttempt = 'alice:hunter2';
@@ -1940,3 +2125,46 @@ describe('applyAccountSuccess — shared account-login helper', () => {
1940
2125
  expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
1941
2126
  });
1942
2127
  });
2128
+
2129
+ // ============================================================================
2130
+ // applyAccountSuccess — HostServ vhost auto-apply (SASL + PASS-auth share it)
2131
+ // ============================================================================
2132
+
2133
+ describe('applyAccountSuccess — HostServ vhost auto-apply', () => {
2134
+ it('applies an assigned vhost to state.host and sets vhostActive when services hold a vhost for the account', () => {
2135
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2136
+ services.setVhost('alice', 'cool.example.net');
2137
+ const state = registeredState();
2138
+ state.host = 'real.example.net';
2139
+ const ctx = makeCtxWithServices(state, services);
2140
+
2141
+ applyAccountSuccess(state, ctx, 'alice');
2142
+
2143
+ expect(state.vhostActive).toBe(true);
2144
+ expect(state.host).toBe('cool.example.net');
2145
+ expect(state.preVhostHost).toBe('real.example.net');
2146
+ });
2147
+
2148
+ it('leaves state.host unchanged when no vhost is assigned to the account', () => {
2149
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2150
+ const state = registeredState();
2151
+ state.host = 'real.example.net';
2152
+ const ctx = makeCtxWithServices(state, services);
2153
+
2154
+ applyAccountSuccess(state, ctx, 'alice');
2155
+
2156
+ expect(state.vhostActive).toBeUndefined();
2157
+ expect(state.host).toBe('real.example.net');
2158
+ });
2159
+
2160
+ it('does not emit a CHGHOST broadcast when no channels are joined (pre-JOIN identify)', () => {
2161
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2162
+ services.setVhost('alice', 'cool.example.net');
2163
+ const state = registeredState();
2164
+ const ctx = makeCtxWithServices(state, services);
2165
+
2166
+ const effects = applyAccountSuccess(state, ctx, 'alice');
2167
+
2168
+ expect(effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
2169
+ });
2170
+ });