serverless-ircd 0.9.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 (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. 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
 
@@ -615,8 +608,9 @@ describe('passReducer', () => {
615
608
  describe('passReducer — post-registration PASS <nick>:<password>', () => {
616
609
  it('logs in when PASS <nick>:<password> matches an account after registration', () => {
617
610
  const state = registeredState();
618
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
619
- const ctx = makeCtxWithAccounts(state, accounts);
611
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
612
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
613
+ const ctx = makeCtxWithServices(state, services);
620
614
  const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
621
615
 
622
616
  expect(out.state.account).toBe('alice');
@@ -632,8 +626,9 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
632
626
 
633
627
  it('does not log in or disconnect when the password is wrong', () => {
634
628
  const state = registeredState();
635
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
636
- const ctx = makeCtxWithAccounts(state, accounts);
629
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
630
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
631
+ const ctx = makeCtxWithServices(state, services);
637
632
  const out = passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
638
633
 
639
634
  expect(out.state.account).toBeUndefined();
@@ -645,8 +640,8 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
645
640
  it('is indistinguishable from an unknown nick (no auth numerics, no disconnect)', () => {
646
641
  const state = registeredState();
647
642
  state.nick = 'nobody';
648
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
649
- const ctx = makeCtxWithAccounts(state, accounts);
643
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
644
+ const ctx = makeCtxWithServices(state, services);
650
645
  const out = passReducer(state, { command: 'PASS', params: ['nobody:wrong'], tags: {} }, ctx);
651
646
 
652
647
  expect(out.state.account).toBeUndefined();
@@ -655,55 +650,63 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
655
650
 
656
651
  it('equalises timing with an extra dummy verify on failure', () => {
657
652
  const state = registeredState();
658
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
659
- const ctx = makeCtxWithAccounts(state, accounts);
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);
660
657
  passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
661
658
 
662
- expect(accounts.calls).toHaveLength(2);
663
- expect(accounts.calls[0]?.payload).toEqual({
664
- kind: 'PLAIN',
665
- username: 'alice',
666
- password: 'wrong',
667
- });
668
- expect(accounts.calls[1]?.payload.kind).toBe('PLAIN');
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');
669
666
  });
670
667
 
671
668
  it('does not log in when the payload nick does not match state.nick', () => {
672
669
  const state = registeredState();
673
- const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
674
- const ctx = makeCtxWithAccounts(state, accounts);
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);
675
674
  const out = passReducer(state, { command: 'PASS', params: ['bob:hunter2'], tags: {} }, ctx);
676
675
 
677
676
  expect(out.state.account).toBeUndefined();
678
- expect(accounts.calls).toHaveLength(0);
677
+ expect(verifyNick).not.toHaveBeenCalled();
679
678
  expect(out.effects).toEqual([]);
680
679
  });
681
680
 
682
681
  it('ignores a bare PASS value (no colon) after registration — no 462, no disconnect', () => {
683
682
  const state = registeredState();
684
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
685
- const ctx = makeCtxWithAccounts(state, accounts);
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);
686
687
  const out = passReducer(state, { command: 'PASS', params: ['serverpw'], tags: {} }, ctx);
687
688
 
688
689
  expect(out.state.account).toBeUndefined();
689
690
  expect(out.effects).toEqual([]);
690
- expect(accounts.calls).toHaveLength(0);
691
+ expect(verifyNick).not.toHaveBeenCalled();
691
692
  expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
692
693
  });
693
694
 
694
695
  it('ignores a late PASS when the connection is already identified', () => {
695
696
  const state = registeredState();
696
697
  state.account = 'alice';
697
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
698
- const ctx = makeCtxWithAccounts(state, accounts);
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);
699
702
  const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
700
703
 
701
- expect(accounts.calls).toHaveLength(0);
704
+ expect(verifyNick).not.toHaveBeenCalled();
702
705
  expect(out.effects).toEqual([]);
703
706
  expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
704
707
  });
705
708
 
706
- it('ignores a late PASS <nick>:<password> when no AccountStore is bound', () => {
709
+ it('ignores a late PASS <nick>:<password> when no ServicesStore is bound', () => {
707
710
  const state = registeredState();
708
711
  const ctx = makeCtx(state);
709
712
  const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
@@ -1630,32 +1633,6 @@ describe('emitWelcomeIfReady — OperServ AKILL admission', () => {
1630
1633
  // PASS-based account auth (NICK + PASS <nick>:<password> + USER → identified)
1631
1634
  // ============================================================================
1632
1635
 
1633
- /** A capture-and-respond fake AccountStore (mirrors the sasl.test.ts fake). */
1634
- class FakeAccountStore implements AccountStore {
1635
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
1636
- constructor(private readonly result: SaslResult) {}
1637
- verify(mech: string, payload: SaslPayload): SaslResult {
1638
- this.calls.push({ mech, payload });
1639
- return this.result;
1640
- }
1641
- }
1642
-
1643
- /** Like {@link makeCtx} but also threads a bound AccountStore. */
1644
- function makeCtxWithAccounts(
1645
- state: ConnectionState,
1646
- accounts: AccountStore,
1647
- clock = new FakeClock(1_000),
1648
- ): Ctx {
1649
- return buildCtx({
1650
- serverConfig,
1651
- clock,
1652
- ids: new SequentialIdFactory(),
1653
- motd: EmptyMotdProvider,
1654
- connection: state,
1655
- accounts,
1656
- });
1657
- }
1658
-
1659
1636
  const SV = 'irc.example.com';
1660
1637
  const L2 = (text: string): RawLine => ({ text });
1661
1638
 
@@ -1666,8 +1643,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
1666
1643
  state.user = 'alice';
1667
1644
  state.host = 'example.com';
1668
1645
  state.passAttempt = 'alice:hunter2';
1669
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1670
- 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);
1671
1649
 
1672
1650
  const effects = emitWelcomeIfReady(state, ctx);
1673
1651
 
@@ -1684,11 +1662,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
1684
1662
 
1685
1663
  // ---- helpers for the broader PASS-auth coverage ----
1686
1664
 
1687
- /** Flexible ctx that binds any combination of account/services/away stores. */
1665
+ /** Flexible ctx that binds any combination of services/away stores. */
1688
1666
  function makeCtxPassAuth(
1689
1667
  state: ConnectionState,
1690
1668
  opts: {
1691
- accounts?: AccountStore;
1692
1669
  services?: ServicesStore;
1693
1670
  away?: AwayStore;
1694
1671
  serverPassword?: string;
@@ -1703,7 +1680,6 @@ function makeCtxPassAuth(
1703
1680
  ids: new SequentialIdFactory(),
1704
1681
  motd: EmptyMotdProvider,
1705
1682
  connection: state,
1706
- ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
1707
1683
  ...(opts.services !== undefined ? { services: opts.services } : {}),
1708
1684
  ...(opts.away !== undefined ? { away: opts.away } : {}),
1709
1685
  });
@@ -1715,16 +1691,16 @@ function sentTexts(effects: EffectType[]): string[] {
1715
1691
  }
1716
1692
 
1717
1693
  describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity with SASL)', () => {
1718
- 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', () => {
1719
1695
  const state = makeState();
1720
1696
  state.nick = 'alice';
1721
1697
  state.user = 'alice';
1722
1698
  state.passAttempt = 'alice:hunter2';
1723
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1724
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1725
- expect(accounts.calls).toEqual([
1726
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'hunter2' } },
1727
- ]);
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');
1728
1704
  });
1729
1705
 
1730
1706
  it('stamps read-only user mode `r` on a successful PASS-auth', () => {
@@ -1732,8 +1708,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1732
1708
  state.nick = 'alice';
1733
1709
  state.user = 'alice';
1734
1710
  state.passAttempt = 'alice:hunter2';
1735
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1736
- 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 }));
1737
1714
  expect(state.userModes.registered).toBe(true);
1738
1715
  });
1739
1716
 
@@ -1742,10 +1719,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1742
1719
  state.nick = 'alice';
1743
1720
  state.user = 'alice';
1744
1721
  state.passAttempt = 'alice:hunter2';
1745
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1746
1722
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1723
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1747
1724
  services.setLastReadMarker('alice', '#foo', 'm1');
1748
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
1725
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1749
1726
  expect(state.lastReadMarkers?.get('#foo')).toBe('m1');
1750
1727
  });
1751
1728
 
@@ -1754,10 +1731,11 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1754
1731
  state.nick = 'alice';
1755
1732
  state.user = 'alice';
1756
1733
  state.passAttempt = 'alice:hunter2';
1757
- 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');
1758
1736
  const away = new InMemoryAwayStore();
1759
1737
  away.set('alice', 'brb');
1760
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, away }));
1738
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services, away }));
1761
1739
  expect(state.away).toBe('brb');
1762
1740
  expect(sentTexts(effects)).toContain(`:${SV} 306 alice :You have been marked as being away`);
1763
1741
  });
@@ -1771,8 +1749,7 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1771
1749
  services.registerNick('alice', 'hunter2', 'alice@example.com');
1772
1750
  services.registerNick('bob', 'pw', 'bob@example.com');
1773
1751
  services.recordMemo({ from: 'bob', to: 'alice', body: 'hi', sentAt: 0 });
1774
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1775
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
1752
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1776
1753
  expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
1777
1754
  });
1778
1755
 
@@ -1782,10 +1759,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1782
1759
  state.user = 'alice';
1783
1760
  state.host = 'real.example.net';
1784
1761
  state.passAttempt = 'alice:hunter2';
1785
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1786
1762
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1763
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1787
1764
  services.setVhost('alice', 'cool.example.net');
1788
- const ctx = makeCtxPassAuth(state, { accounts, services });
1765
+ const ctx = makeCtxPassAuth(state, { services });
1789
1766
 
1790
1767
  const effects = emitWelcomeIfReady(state, ctx);
1791
1768
 
@@ -1804,9 +1781,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1804
1781
  state.user = 'alice';
1805
1782
  state.host = 'real.example.net';
1806
1783
  state.passAttempt = 'alice:hunter2';
1807
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1808
1784
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1809
- const ctx = makeCtxPassAuth(state, { accounts, services });
1785
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1786
+ const ctx = makeCtxPassAuth(state, { services });
1810
1787
 
1811
1788
  emitWelcomeIfReady(state, ctx);
1812
1789
 
@@ -1820,8 +1797,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1820
1797
  state.user = 'alice';
1821
1798
  state.host = '203.0.113.5';
1822
1799
  state.passAttempt = 'alice:hunter2';
1823
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1824
1800
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1801
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1825
1802
  services.setVhost('alice', 'cool.example.net');
1826
1803
  const ctx: Ctx = buildCtx({
1827
1804
  serverConfig: { ...serverConfig, cloaking: { enabled: true, secret: 's3cret' } },
@@ -1829,7 +1806,6 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1829
1806
  ids: new SequentialIdFactory(),
1830
1807
  motd: EmptyMotdProvider,
1831
1808
  connection: state,
1832
- accounts,
1833
1809
  services,
1834
1810
  });
1835
1811
 
@@ -1851,8 +1827,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1851
1827
  state.nick = 'alice';
1852
1828
  state.user = 'alice';
1853
1829
  state.passAttempt = 'alice:hunter2';
1854
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1855
- 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 }));
1856
1833
  expect(state.account).toBe('alice');
1857
1834
  });
1858
1835
  });
@@ -1863,8 +1840,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1863
1840
  state.nick = 'alice';
1864
1841
  state.user = 'alice';
1865
1842
  state.passAttempt = 'alice:wrong';
1866
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1867
- 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 }));
1868
1846
 
1869
1847
  expect(state.account).toBeUndefined();
1870
1848
  expect(state.userModes.registered).toBe(false);
@@ -1880,8 +1858,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1880
1858
  state.nick = 'nobody';
1881
1859
  state.user = 'nobody';
1882
1860
  state.passAttempt = 'nobody:wrong';
1883
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1884
- 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 }));
1885
1863
 
1886
1864
  expect(state.account).toBeUndefined();
1887
1865
  expect(state.registration).toBe('registered');
@@ -1897,17 +1875,15 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1897
1875
  state.nick = 'alice';
1898
1876
  state.user = 'alice';
1899
1877
  state.passAttempt = 'alice:wrong';
1900
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1901
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
1902
-
1903
- expect(accounts.calls).toHaveLength(2);
1904
- expect(accounts.calls[0]?.payload).toEqual({
1905
- kind: 'PLAIN',
1906
- username: 'alice',
1907
- password: 'wrong',
1908
- });
1909
- expect(accounts.calls[1]?.mech).toBe('PLAIN');
1910
- 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');
1911
1887
  });
1912
1888
 
1913
1889
  it('treats a payload nick that does not match state.nick as a failed auth (no silent login)', () => {
@@ -1915,19 +1891,21 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1915
1891
  state.nick = 'alice';
1916
1892
  state.user = 'alice';
1917
1893
  state.passAttempt = 'bob:hunter2';
1918
- const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
1919
- 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 }));
1920
1898
 
1921
1899
  expect(state.account).toBeUndefined();
1922
1900
  expect(state.registration).toBe('registered');
1923
1901
  // The store is never consulted on a nick mismatch.
1924
- expect(accounts.calls).toHaveLength(0);
1902
+ expect(verifyNick).not.toHaveBeenCalled();
1925
1903
  expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
1926
1904
  });
1927
1905
  });
1928
1906
 
1929
1907
  describe('emitWelcomeIfReady — PASS-auth interaction with the server-password gate', () => {
1930
- 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)', () => {
1931
1909
  const state = makeState();
1932
1910
  state.nick = 'alice';
1933
1911
  state.user = 'alice';
@@ -1939,7 +1917,28 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1939
1917
  expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
1940
1918
  });
1941
1919
 
1942
- 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', () => {
1943
1942
  const state = makeState();
1944
1943
  state.nick = 'alice';
1945
1944
  state.user = 'alice';
@@ -1958,10 +1957,11 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1958
1957
  state.nick = 'alice';
1959
1958
  state.user = 'alice';
1960
1959
  state.passAttempt = 'alice:wrong';
1961
- 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');
1962
1962
  const effects = emitWelcomeIfReady(
1963
1963
  state,
1964
- makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
1964
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1965
1965
  );
1966
1966
 
1967
1967
  expect(state.registration).not.toBe('registered');
@@ -1978,10 +1978,12 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1978
1978
  state.user = 'alice';
1979
1979
  state.account = 'alice'; // SASL-identified before registration completion
1980
1980
  state.passAttempt = 'alice:hunter2';
1981
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1982
- 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 }));
1983
1985
 
1984
- expect(accounts.calls).toHaveLength(0);
1986
+ expect(verifyNick).not.toHaveBeenCalled();
1985
1987
  expect(state.registration).toBe('registered');
1986
1988
  // No 900/903 are re-emitted by PASS-auth; SASL already emitted them.
1987
1989
  expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
@@ -1992,13 +1994,15 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1992
1994
  state.nick = 'alice';
1993
1995
  state.user = 'alice';
1994
1996
  state.passAttempt = 's3cret';
1995
- 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');
1996
2000
  const effects = emitWelcomeIfReady(
1997
2001
  state,
1998
- makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
2002
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1999
2003
  );
2000
2004
 
2001
- expect(accounts.calls).toHaveLength(0);
2005
+ expect(verifyNick).not.toHaveBeenCalled();
2002
2006
  expect(state.account).toBeUndefined();
2003
2007
  expect(state.registration).toBe('registered');
2004
2008
  expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
@@ -2011,8 +2015,9 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
2011
2015
  state.user = 'alice';
2012
2016
  // state.nick intentionally undefined — PASS arrived before NICK.
2013
2017
  state.passAttempt = 'alice:hunter2';
2014
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2015
- 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 }));
2016
2021
 
2017
2022
  expect(state.account).toBe('alice');
2018
2023
  expect(effects.some((e) => e.tag === 'Send')).toBe(true);
@@ -2021,26 +2026,31 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
2021
2026
  it('returns no effects when no PASS was sent', () => {
2022
2027
  const state = makeState();
2023
2028
  state.nick = 'alice';
2024
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2025
- 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([]);
2026
2032
  });
2027
2033
 
2028
2034
  it('returns no effects for a bare PASS value (no colon)', () => {
2029
2035
  const state = makeState();
2030
2036
  state.nick = 'alice';
2031
2037
  state.passAttempt = 's3cret';
2032
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2033
- expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
2034
- 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();
2035
2043
  });
2036
2044
 
2037
2045
  it('returns no effects when the nick portion is empty (`:password`)', () => {
2038
2046
  const state = makeState();
2039
2047
  state.nick = 'alice';
2040
2048
  state.passAttempt = ':hunter2';
2041
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2042
- expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
2043
- 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();
2044
2054
  });
2045
2055
 
2046
2056
  it('splits only on the first colon so a password may contain colons', () => {
@@ -2048,16 +2058,13 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
2048
2058
  state.nick = 'alice';
2049
2059
  state.user = 'alice';
2050
2060
  state.passAttempt = 'alice:p:ass:word';
2051
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2052
- passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
2053
- expect(accounts.calls[0]?.payload).toEqual({
2054
- kind: 'PLAIN',
2055
- username: 'alice',
2056
- password: 'p:ass:word',
2057
- });
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');
2058
2065
  });
2059
2066
 
2060
- it('returns no effects when no AccountStore is bound', () => {
2067
+ it('returns no effects when no ServicesStore is bound', () => {
2061
2068
  const state = makeState();
2062
2069
  state.nick = 'alice';
2063
2070
  state.passAttempt = 'alice:hunter2';