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,17 +1,10 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getLsString } from '../../src/caps/capabilities';
3
3
  import { capReducer } from '../../src/commands/cap';
4
4
  import { authenticateReducer } from '../../src/commands/sasl';
5
5
  import { Effect } from '../../src/effects';
6
6
  import type { Effect as EffectType, RawLine } from '../../src/effects';
7
- import type {
8
- AccountStore,
9
- AwayStore,
10
- MtlsIdentityProvider,
11
- SaslPayload,
12
- SaslResult,
13
- ServicesStore,
14
- } from '../../src/ports';
7
+ import type { AwayStore, MtlsIdentityProvider, ServicesStore } from '../../src/ports';
15
8
  import {
16
9
  EmptyMotdProvider,
17
10
  FakeClock,
@@ -53,11 +46,24 @@ function saslReadyState(): ConnectionState {
53
46
  return s;
54
47
  }
55
48
 
49
+ /**
50
+ * Builds a {@link InMemoryServicesStore} with `nick` registered under
51
+ * `password`. The single credential home for SASL PLAIN / EXTERNAL / PASS.
52
+ */
53
+ function registeredServices(
54
+ nick = 'alice',
55
+ password = 'secret',
56
+ email = 'alice@example.com',
57
+ ): InMemoryServicesStore {
58
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
59
+ services.registerNick(nick, password, email);
60
+ return services;
61
+ }
62
+
56
63
  function makeCtx(
57
64
  state: ConnectionState,
58
- accounts?: AccountStore,
59
- mtlsIdentity?: MtlsIdentityProvider,
60
65
  services?: ServicesStore,
66
+ mtlsIdentity?: MtlsIdentityProvider,
61
67
  away?: AwayStore,
62
68
  ): Ctx {
63
69
  return buildCtx({
@@ -66,23 +72,12 @@ function makeCtx(
66
72
  ids: new SequentialIdFactory(),
67
73
  motd: EmptyMotdProvider,
68
74
  connection: state,
69
- ...(accounts !== undefined ? { accounts } : {}),
70
- ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
71
75
  ...(services !== undefined ? { services } : {}),
76
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
72
77
  ...(away !== undefined ? { away } : {}),
73
78
  });
74
79
  }
75
80
 
76
- /** A capture-and-respond fake AccountStore. */
77
- class FakeAccountStore implements AccountStore {
78
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
79
- constructor(private readonly result: SaslResult) {}
80
- verify(mech: string, payload: SaslPayload): SaslResult {
81
- this.calls.push({ mech, payload });
82
- return this.result;
83
- }
84
- }
85
-
86
81
  /** A fake MtlsIdentityProvider that returns a canned identity for one connId. */
87
82
  class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
88
83
  readonly calls: string[] = [];
@@ -135,10 +130,10 @@ describe('authenticateReducer — PLAIN success', () => {
135
130
  expect(state.saslMech).toBe('PLAIN');
136
131
  });
137
132
 
138
- it('completes with 900 and 903 when the AccountStore accepts the credentials', () => {
133
+ it('completes with 900 and 903 when the ServicesStore accepts the credentials', () => {
139
134
  const state = saslReadyState();
140
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
141
- const ctx = makeCtx(state, accounts);
135
+ const services = registeredServices('alice', 'secret');
136
+ const ctx = makeCtx(state, services);
142
137
 
143
138
  authenticateReducer(state, authenticate('PLAIN'), ctx);
144
139
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -152,8 +147,8 @@ describe('authenticateReducer — PLAIN success', () => {
152
147
 
153
148
  it('records the authenticated account name on the connection', () => {
154
149
  const state = saslReadyState();
155
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
156
- const ctx = makeCtx(state, accounts);
150
+ const services = registeredServices('alice', 'secret');
151
+ const ctx = makeCtx(state, services);
157
152
 
158
153
  authenticateReducer(state, authenticate('PLAIN'), ctx);
159
154
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -163,11 +158,10 @@ describe('authenticateReducer — PLAIN success', () => {
163
158
 
164
159
  it('seeds lastReadMarkers from the ServicesStore at identify', () => {
165
160
  const state = saslReadyState();
166
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
167
- const services = new InMemoryServicesStore();
161
+ const services = registeredServices('alice', 'secret');
168
162
  services.setLastReadMarker('alice', '#foo', 'm1');
169
163
  services.setLastReadMarker('alice', '#bar', 'm2');
170
- const ctx = makeCtx(state, accounts, undefined, services);
164
+ const ctx = makeCtx(state, services);
171
165
 
172
166
  authenticateReducer(state, authenticate('PLAIN'), ctx);
173
167
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -178,10 +172,9 @@ describe('authenticateReducer — PLAIN success', () => {
178
172
 
179
173
  it('does not seed markers for a different account', () => {
180
174
  const state = saslReadyState();
181
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
182
- const services = new InMemoryServicesStore();
175
+ const services = registeredServices('alice', 'secret');
183
176
  services.setLastReadMarker('bob', '#foo', 'm9');
184
- const ctx = makeCtx(state, accounts, undefined, services);
177
+ const ctx = makeCtx(state, services);
185
178
 
186
179
  authenticateReducer(state, authenticate('PLAIN'), ctx);
187
180
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -191,10 +184,10 @@ describe('authenticateReducer — PLAIN success', () => {
191
184
 
192
185
  it('replays the persisted away reason on identify (draft/pre-away)', () => {
193
186
  const state = saslReadyState();
194
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
187
+ const services = registeredServices('alice', 'secret');
195
188
  const away = new InMemoryAwayStore();
196
189
  away.set('alice', 'brb');
197
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
190
+ const ctx = makeCtx(state, services, undefined, away);
198
191
 
199
192
  authenticateReducer(state, authenticate('PLAIN'), ctx);
200
193
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -204,10 +197,10 @@ describe('authenticateReducer — PLAIN success', () => {
204
197
 
205
198
  it('emits 306 RPL_NOWAWAY after identifying when an away reason is replayed', () => {
206
199
  const state = saslReadyState();
207
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
200
+ const services = registeredServices('alice', 'secret');
208
201
  const away = new InMemoryAwayStore();
209
202
  away.set('alice', 'brb');
210
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
203
+ const ctx = makeCtx(state, services, undefined, away);
211
204
 
212
205
  authenticateReducer(state, authenticate('PLAIN'), ctx);
213
206
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -220,9 +213,9 @@ describe('authenticateReducer — PLAIN success', () => {
220
213
 
221
214
  it('does not replay an away reason when none is persisted', () => {
222
215
  const state = saslReadyState();
223
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
216
+ const services = registeredServices('alice', 'secret');
224
217
  const away = new InMemoryAwayStore();
225
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
218
+ const ctx = makeCtx(state, services, undefined, away);
226
219
 
227
220
  authenticateReducer(state, authenticate('PLAIN'), ctx);
228
221
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -234,8 +227,8 @@ describe('authenticateReducer — PLAIN success', () => {
234
227
 
235
228
  it('does not replay an away reason when no AwayStore is bound', () => {
236
229
  const state = saslReadyState();
237
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
238
- const ctx = makeCtx(state, accounts); // no away store
230
+ const services = registeredServices('alice', 'secret');
231
+ const ctx = makeCtx(state, services); // no away store
239
232
 
240
233
  authenticateReducer(state, authenticate('PLAIN'), ctx);
241
234
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -247,10 +240,10 @@ describe('authenticateReducer — PLAIN success', () => {
247
240
 
248
241
  it('looks up the persisted away reason case-insensitively', () => {
249
242
  const state = saslReadyState();
250
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
243
+ const services = registeredServices('alice', 'secret');
251
244
  const away = new InMemoryAwayStore();
252
245
  away.set('ALICE', 'brb');
253
- const ctx = makeCtx(state, accounts, undefined, undefined, away);
246
+ const ctx = makeCtx(state, services, undefined, away);
254
247
 
255
248
  authenticateReducer(state, authenticate('PLAIN'), ctx);
256
249
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -260,10 +253,9 @@ describe('authenticateReducer — PLAIN success', () => {
260
253
 
261
254
  it('delivers queued unread memos on a successful SASL login', () => {
262
255
  const state = saslReadyState();
263
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
264
- const services = new InMemoryServicesStore();
256
+ const services = registeredServices('alice', 'secret');
265
257
  services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
266
- const ctx = makeCtx(state, accounts, undefined, services);
258
+ const ctx = makeCtx(state, services);
267
259
 
268
260
  authenticateReducer(state, authenticate('PLAIN'), ctx);
269
261
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -276,10 +268,9 @@ describe('authenticateReducer — PLAIN success', () => {
276
268
 
277
269
  it('marks delivered memos as read after a successful SASL login', () => {
278
270
  const state = saslReadyState();
279
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
280
- const services = new InMemoryServicesStore();
271
+ const services = registeredServices('alice', 'secret');
281
272
  services.recordMemo({ from: 'bob', to: 'alice', body: 'Hi alice!', sentAt: 1_000 });
282
- const ctx = makeCtx(state, accounts, undefined, services);
273
+ const ctx = makeCtx(state, services);
283
274
 
284
275
  authenticateReducer(state, authenticate('PLAIN'), ctx);
285
276
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -289,11 +280,10 @@ describe('authenticateReducer — PLAIN success', () => {
289
280
 
290
281
  it('does not deliver already-read memos on a SASL login', () => {
291
282
  const state = saslReadyState();
292
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
293
- const services = new InMemoryServicesStore();
283
+ const services = registeredServices('alice', 'secret');
294
284
  services.recordMemo({ from: 'bob', to: 'alice', body: 'old', sentAt: 1_000 });
295
285
  services.markMemoRead('alice', 1);
296
- const ctx = makeCtx(state, accounts, undefined, services);
286
+ const ctx = makeCtx(state, services);
297
287
 
298
288
  authenticateReducer(state, authenticate('PLAIN'), ctx);
299
289
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -302,11 +292,51 @@ describe('authenticateReducer — PLAIN success', () => {
302
292
  expect(texts.some((l) => l.text.includes('[Memo #1'))).toBe(false);
303
293
  });
304
294
 
295
+ it('applies the assigned HostServ vhost on SASL PLAIN success', () => {
296
+ const state = saslReadyState();
297
+ state.host = 'real.example.net';
298
+ const services = registeredServices('alice', 'secret');
299
+ services.setVhost('alice', 'cool.example.net');
300
+ const ctx = makeCtx(state, services);
301
+
302
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
303
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
304
+
305
+ expect(state.vhostActive).toBe(true);
306
+ expect(state.host).toBe('cool.example.net');
307
+ expect(state.preVhostHost).toBe('real.example.net');
308
+ });
309
+
310
+ it('does not emit a CHGHOST broadcast on SASL success (pre-registration, no peers)', () => {
311
+ const state = saslReadyState();
312
+ state.host = 'real.example.net';
313
+ const services = registeredServices('alice', 'secret');
314
+ services.setVhost('alice', 'cool.example.net');
315
+ const ctx = makeCtx(state, services);
316
+
317
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
318
+ const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
319
+
320
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toEqual<EffectType[]>([]);
321
+ });
322
+
323
+ it('leaves host unchanged on SASL success when no vhost is assigned', () => {
324
+ const state = saslReadyState();
325
+ state.host = 'real.example.net';
326
+ const services = registeredServices('alice', 'secret');
327
+ const ctx = makeCtx(state, services);
328
+
329
+ authenticateReducer(state, authenticate('PLAIN'), ctx);
330
+ authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
331
+
332
+ expect(state.vhostActive).toBeUndefined();
333
+ expect(state.host).toBe('real.example.net');
334
+ });
335
+
305
336
  it('delivers nothing on a SASL login when the account has no memos', () => {
306
337
  const state = saslReadyState();
307
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
308
- const services = new InMemoryServicesStore();
309
- const ctx = makeCtx(state, accounts, undefined, services);
338
+ const services = registeredServices('alice', 'secret');
339
+ const ctx = makeCtx(state, services);
310
340
 
311
341
  authenticateReducer(state, authenticate('PLAIN'), ctx);
312
342
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -317,10 +347,9 @@ describe('authenticateReducer — PLAIN success', () => {
317
347
 
318
348
  it('looks up queued memos case-insensitively on a SASL login', () => {
319
349
  const state = saslReadyState();
320
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
321
- const services = new InMemoryServicesStore();
350
+ const services = registeredServices('alice', 'secret');
322
351
  services.recordMemo({ from: 'bob', to: 'ALICE', body: 'folded', sentAt: 1_000 });
323
- const ctx = makeCtx(state, accounts, undefined, services);
352
+ const ctx = makeCtx(state, services);
324
353
 
325
354
  authenticateReducer(state, authenticate('PLAIN'), ctx);
326
355
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -333,8 +362,7 @@ describe('authenticateReducer — PLAIN success', () => {
333
362
 
334
363
  it('does not deliver memos when no ServicesStore is bound', () => {
335
364
  const state = saslReadyState();
336
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
337
- const ctx = makeCtx(state, accounts);
365
+ const ctx = makeCtx(state);
338
366
 
339
367
  authenticateReducer(state, authenticate('PLAIN'), ctx);
340
368
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -345,8 +373,8 @@ describe('authenticateReducer — PLAIN success', () => {
345
373
 
346
374
  it('clears the in-progress mechanism after a successful authentication', () => {
347
375
  const state = saslReadyState();
348
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
349
- const ctx = makeCtx(state, accounts);
376
+ const services = registeredServices('alice', 'secret');
377
+ const ctx = makeCtx(state, services);
350
378
 
351
379
  authenticateReducer(state, authenticate('PLAIN'), ctx);
352
380
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -354,40 +382,36 @@ describe('authenticateReducer — PLAIN success', () => {
354
382
  expect(state.saslMech).toBeUndefined();
355
383
  });
356
384
 
357
- it('forwards parsed PLAIN credentials to the AccountStore', () => {
385
+ it('forwards parsed PLAIN credentials to services.verifyNick', () => {
358
386
  const state = saslReadyState();
359
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
360
- const ctx = makeCtx(state, accounts);
387
+ const services = registeredServices('alice', 'secret');
388
+ const verifyNick = vi.spyOn(services, 'verifyNick');
389
+ const ctx = makeCtx(state, services);
361
390
 
362
391
  authenticateReducer(state, authenticate('PLAIN'), ctx);
363
392
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
364
393
 
365
- expect(accounts.calls).toEqual([
366
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
367
- ]);
394
+ expect(verifyNick).toHaveBeenCalledWith('alice', 'secret');
368
395
  });
369
396
 
370
397
  it('ignores the authorization identity field in the PLAIN payload', () => {
371
398
  const state = saslReadyState();
372
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
373
- const ctx = makeCtx(state, accounts);
399
+ const services = registeredServices('alice', 'secret');
400
+ const verifyNick = vi.spyOn(services, 'verifyNick');
401
+ const ctx = makeCtx(state, services);
374
402
 
375
403
  authenticateReducer(state, authenticate('PLAIN'), ctx);
376
404
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret', 'bob')), ctx);
377
405
 
378
- expect(accounts.calls[0]?.payload).toEqual({
379
- kind: 'PLAIN',
380
- username: 'alice',
381
- password: 'secret',
382
- });
406
+ expect(verifyNick).toHaveBeenCalledWith('alice', 'secret');
383
407
  });
384
408
 
385
409
  it('targets `*` with an empty hostmask when the client has no nick yet', () => {
386
410
  const state = createConnection({ id: 'c1', connectedSince: 0 });
387
411
  state.capNegotiating = true;
388
412
  state.caps.add('sasl');
389
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
390
- const ctx = makeCtx(state, accounts);
413
+ const services = registeredServices('alice', 'secret');
414
+ const ctx = makeCtx(state, services);
391
415
 
392
416
  authenticateReducer(state, authenticate('PLAIN'), ctx);
393
417
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -405,10 +429,10 @@ describe('authenticateReducer — PLAIN success', () => {
405
429
  // ============================================================================
406
430
 
407
431
  describe('authenticateReducer — PLAIN failure', () => {
408
- it('emits 904 ERR_SASLFAIL when the AccountStore rejects the credentials', () => {
432
+ it('emits 904 ERR_SASLFAIL when the ServicesStore rejects the credentials', () => {
409
433
  const state = saslReadyState();
410
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
411
- const ctx = makeCtx(state, accounts);
434
+ const services = registeredServices('alice', 'secret');
435
+ const ctx = makeCtx(state, services);
412
436
 
413
437
  authenticateReducer(state, authenticate('PLAIN'), ctx);
414
438
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
@@ -420,8 +444,8 @@ describe('authenticateReducer — PLAIN failure', () => {
420
444
 
421
445
  it('does not record an account on failure', () => {
422
446
  const state = saslReadyState();
423
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
424
- const ctx = makeCtx(state, accounts);
447
+ const services = registeredServices('alice', 'secret');
448
+ const ctx = makeCtx(state, services);
425
449
 
426
450
  authenticateReducer(state, authenticate('PLAIN'), ctx);
427
451
  authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
@@ -431,8 +455,8 @@ describe('authenticateReducer — PLAIN failure', () => {
431
455
 
432
456
  it('clears the in-progress mechanism after a failure', () => {
433
457
  const state = saslReadyState();
434
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
435
- const ctx = makeCtx(state, accounts);
458
+ const services = registeredServices('alice', 'secret');
459
+ const ctx = makeCtx(state, services);
436
460
 
437
461
  authenticateReducer(state, authenticate('PLAIN'), ctx);
438
462
  authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
@@ -442,11 +466,11 @@ describe('authenticateReducer — PLAIN failure', () => {
442
466
  });
443
467
 
444
468
  // ============================================================================
445
- // AUTHENTICATE PLAIN — no AccountStore wired
469
+ // AUTHENTICATE PLAIN — no ServicesStore wired
446
470
  // ============================================================================
447
471
 
448
- describe('authenticateReducer — no AccountStore configured', () => {
449
- it('emits 904 when the PLAIN payload arrives but no AccountStore is wired', () => {
472
+ describe('authenticateReducer — no ServicesStore configured', () => {
473
+ it('emits 904 when the PLAIN payload arrives but no ServicesStore is wired', () => {
450
474
  const state = saslReadyState();
451
475
  const ctx = makeCtx(state);
452
476
 
@@ -487,28 +511,41 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
487
511
 
488
512
  it('succeeds (900/903) when a valid cert identity is presented', () => {
489
513
  const state = saslReadyState();
490
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
514
+ const services = registeredServices('alice', 'secret');
515
+ services.addCertFP('alice', 'CN=alice');
491
516
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
492
- const ctx = makeCtx(state, accounts, provider);
517
+ const ctx = makeCtx(state, services, provider);
493
518
 
494
519
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
495
520
  const out = authenticateReducer(state, authenticate('+'), ctx);
496
521
 
497
522
  expect(state.account).toBe('alice');
498
- expect(accounts.calls).toEqual([
499
- { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
500
- ]);
501
523
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
502
524
  L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
503
525
  L(`:${SV} 903 alice :SASL authentication successful`),
504
526
  ]);
505
527
  });
506
528
 
529
+ it('forwards the resolved mTLS subject to services.verifyCertFP', () => {
530
+ const state = saslReadyState();
531
+ const services = registeredServices('alice', 'secret');
532
+ services.addCertFP('alice', 'CN=alice');
533
+ const verifyCertFP = vi.spyOn(services, 'verifyCertFP');
534
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
535
+ const ctx = makeCtx(state, services, provider);
536
+
537
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
538
+ authenticateReducer(state, authenticate('+'), ctx);
539
+
540
+ expect(verifyCertFP).toHaveBeenCalledWith('CN=alice');
541
+ });
542
+
507
543
  it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
508
544
  const state = saslReadyState();
509
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
545
+ const services = registeredServices('alice', 'secret');
546
+ services.addCertFP('alice', 'CN=alice');
510
547
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
511
- const ctx = makeCtx(state, accounts, provider);
548
+ const ctx = makeCtx(state, services, provider);
512
549
 
513
550
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
514
551
  authenticateReducer(state, authenticate('+'), ctx);
@@ -519,11 +556,11 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
519
556
 
520
557
  it('delivers queued memos on a successful EXTERNAL login', () => {
521
558
  const state = saslReadyState();
522
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
523
- const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
524
- const services = new InMemoryServicesStore();
559
+ const services = registeredServices('alice', 'secret');
560
+ services.addCertFP('alice', 'CN=alice');
525
561
  services.recordMemo({ from: 'bob', to: 'alice', body: 'via mTLS', sentAt: 1_000 });
526
- const ctx = makeCtx(state, accounts, provider, services);
562
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
563
+ const ctx = makeCtx(state, services, provider);
527
564
 
528
565
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
529
566
  const out = authenticateReducer(state, authenticate('+'), ctx);
@@ -537,9 +574,10 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
537
574
 
538
575
  it('fails with 904 when the provider has no identity for the connection', () => {
539
576
  const state = saslReadyState();
540
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
577
+ const services = registeredServices('alice', 'secret');
578
+ services.addCertFP('alice', 'CN=alice');
541
579
  const provider = new FakeMtlsIdentityProvider('c1', undefined);
542
- const ctx = makeCtx(state, accounts, provider);
580
+ const ctx = makeCtx(state, services, provider);
543
581
 
544
582
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
545
583
  const out = authenticateReducer(state, authenticate('+'), ctx);
@@ -550,11 +588,12 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
550
588
  expect(state.account).toBeUndefined();
551
589
  });
552
590
 
553
- it('fails with 904 when the account store rejects the identity', () => {
591
+ it('fails with 904 when the subject is not attached to any account', () => {
554
592
  const state = saslReadyState();
555
- const accounts = new FakeAccountStore({ ok: false, reason: 'untrusted certificate' });
593
+ const services = registeredServices('alice', 'secret');
594
+ // No cert subject attached to alice; CN=evil is unknown to the store.
556
595
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
557
- const ctx = makeCtx(state, accounts, provider);
596
+ const ctx = makeCtx(state, services, provider);
558
597
 
559
598
  authenticateReducer(state, authenticate('EXTERNAL'), ctx);
560
599
  const out = authenticateReducer(state, authenticate('+'), ctx);
@@ -562,9 +601,27 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
562
601
  expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
563
602
  L(`:${SV} 904 alice :SASL authentication failed`),
564
603
  ]);
604
+ expect(state.account).toBeUndefined();
605
+ });
606
+
607
+ it('does not leak account existence on an unknown subject (no state change)', () => {
608
+ const state = saslReadyState();
609
+ const services = registeredServices('alice', 'secret');
610
+ services.addCertFP('alice', 'CN=alice');
611
+ const provider = new FakeMtlsIdentityProvider('c1', 'CN=evil');
612
+ const ctx = makeCtx(state, services, provider);
613
+
614
+ authenticateReducer(state, authenticate('EXTERNAL'), ctx);
615
+ const out = authenticateReducer(state, authenticate('+'), ctx);
616
+
617
+ expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
618
+ L(`:${SV} 904 alice :SASL authentication failed`),
619
+ ]);
620
+ expect(state.account).toBeUndefined();
621
+ expect(state.saslMech).toBeUndefined();
565
622
  });
566
623
 
567
- it('fails with 904 when no account store is configured', () => {
624
+ it('fails with 904 when no ServicesStore is configured', () => {
568
625
  const state = saslReadyState();
569
626
  const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
570
627
  const ctx = makeCtx(state, undefined, provider);
@@ -630,8 +687,8 @@ describe('authenticateReducer — abort / already-authenticated', () => {
630
687
 
631
688
  it('emits 907 when sent after a successful SASL authentication', () => {
632
689
  const state = saslReadyState();
633
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
634
- const ctx = makeCtx(state, accounts);
690
+ const services = registeredServices('alice', 'secret');
691
+ const ctx = makeCtx(state, services);
635
692
 
636
693
  authenticateReducer(state, authenticate('PLAIN'), ctx);
637
694
  authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
@@ -692,13 +749,14 @@ describe('authenticateReducer — client abort', () => {
692
749
  describe('authenticateReducer — empty payload (`+`)', () => {
693
750
  it('treats `AUTHENTICATE +` as the final (empty) chunk and rejects malformed PLAIN', () => {
694
751
  const state = saslReadyState();
695
- const accounts = new FakeAccountStore({ ok: false, reason: 'empty' });
696
- const ctx = makeCtx(state, accounts);
752
+ const services = registeredServices('alice', 'secret');
753
+ const verifyNick = vi.spyOn(services, 'verifyNick');
754
+ const ctx = makeCtx(state, services);
697
755
 
698
756
  authenticateReducer(state, authenticate('PLAIN'), ctx);
699
757
  const out = authenticateReducer(state, authenticate('+'), ctx);
700
758
 
701
- expect(accounts.calls).toEqual([]);
759
+ expect(verifyNick).not.toHaveBeenCalled();
702
760
  expect(firstLineText(out.effects[0])).toContain(' 904 ');
703
761
  expect(state.saslMech).toBeUndefined();
704
762
  });
@@ -711,8 +769,9 @@ describe('authenticateReducer — empty payload (`+`)', () => {
711
769
  describe('authenticateReducer — chunked payload', () => {
712
770
  it('buffers chunks of exactly 400 bytes and verifies on the short final chunk', () => {
713
771
  const state = saslReadyState();
714
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
715
- const ctx = makeCtx(state, accounts);
772
+ const services = registeredServices('alice', 'p'.repeat(320));
773
+ const verifyNick = vi.spyOn(services, 'verifyNick');
774
+ const ctx = makeCtx(state, services);
716
775
 
717
776
  const longPassword = 'p'.repeat(320);
718
777
  const payload = plainPayload('alice', longPassword);
@@ -727,19 +786,17 @@ describe('authenticateReducer — chunked payload', () => {
727
786
  const buffered = authenticateReducer(state, authenticate(first), ctx);
728
787
  expect(buffered.effects).toEqual([]);
729
788
  expect(state.saslMech).toBe('PLAIN');
730
- expect(accounts.calls).toEqual([]);
789
+ expect(verifyNick).not.toHaveBeenCalled();
731
790
 
732
791
  authenticateReducer(state, authenticate(rest), ctx);
733
- expect(accounts.calls).toEqual([
734
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: longPassword } },
735
- ]);
792
+ expect(verifyNick).toHaveBeenCalledWith('alice', longPassword);
736
793
  expect(state.account).toBe('alice');
737
794
  });
738
795
 
739
796
  it('rejects an oversized payload with 905 ERR_SASLTOOLONG', () => {
740
797
  const state = saslReadyState();
741
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
742
- const ctx = makeCtx(state, accounts);
798
+ const services = registeredServices('alice', 'secret');
799
+ const ctx = makeCtx(state, services);
743
800
 
744
801
  authenticateReducer(state, authenticate('PLAIN'), ctx);
745
802
  const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
@@ -758,37 +815,40 @@ describe('authenticateReducer — chunked payload', () => {
758
815
  describe('authenticateReducer — malformed PLAIN', () => {
759
816
  it('emits 904 when the base64 payload does not decode to three NUL-separated fields', () => {
760
817
  const state = saslReadyState();
761
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
762
- const ctx = makeCtx(state, accounts);
818
+ const services = registeredServices('alice', 'secret');
819
+ const verifyNick = vi.spyOn(services, 'verifyNick');
820
+ const ctx = makeCtx(state, services);
763
821
 
764
822
  authenticateReducer(state, authenticate('PLAIN'), ctx);
765
823
  const out = authenticateReducer(state, authenticate(encodeBase64('onlyonefield')), ctx);
766
824
 
767
825
  expect(firstLineText(out.effects[0])).toContain(' 904 ');
768
- expect(accounts.calls).toEqual([]);
826
+ expect(verifyNick).not.toHaveBeenCalled();
769
827
  });
770
828
 
771
829
  it('emits 904 when the payload is not valid base64', () => {
772
830
  const state = saslReadyState();
773
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
774
- const ctx = makeCtx(state, accounts);
831
+ const services = registeredServices('alice', 'secret');
832
+ const verifyNick = vi.spyOn(services, 'verifyNick');
833
+ const ctx = makeCtx(state, services);
775
834
 
776
835
  authenticateReducer(state, authenticate('PLAIN'), ctx);
777
836
  const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
778
837
 
779
838
  expect(firstLineText(out.effects[0])).toContain(' 904 ');
780
- expect(accounts.calls).toEqual([]);
839
+ expect(verifyNick).not.toHaveBeenCalled();
781
840
  });
782
841
 
783
842
  it('emits 908 when the first AUTHENTICATE frame is not a recognized mechanism', () => {
784
843
  const state = saslReadyState();
785
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
786
- const ctx = makeCtx(state, accounts);
844
+ const services = registeredServices('alice', 'secret');
845
+ const verifyNick = vi.spyOn(services, 'verifyNick');
846
+ const ctx = makeCtx(state, services);
787
847
 
788
848
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
789
849
 
790
850
  expect(firstLineText(out.effects[0])).toContain(' 908 ');
791
- expect(accounts.calls).toEqual([]);
851
+ expect(verifyNick).not.toHaveBeenCalled();
792
852
  });
793
853
  });
794
854
 
@@ -835,8 +895,8 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
835
895
  state.user = 'alice';
836
896
  state.host = 'example.com';
837
897
  state.registration = 'registering';
838
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
839
- const ctx = makeCtx(state, accounts);
898
+ const services = registeredServices('alice', 'secret');
899
+ const ctx = makeCtx(state, services);
840
900
 
841
901
  capReducer(state, { command: 'CAP', params: ['REQ', 'sasl'], tags: {} }, ctx);
842
902
  expect(state.caps.has('sasl')).toBe(true);
@@ -861,15 +921,15 @@ describe('authenticateReducer — CAP REQ sasl integration', () => {
861
921
  });
862
922
 
863
923
  // ============================================================================
864
- // SASL PLAIN — NickServ-registered accounts (ServicesStore fallback)
924
+ // SASL PLAIN — NickServ-registered accounts (single credential home)
865
925
  // ============================================================================
866
926
 
867
927
  describe('authenticateReducer — NickServ-registered nick via ServicesStore', () => {
868
- it('succeeds with credentials registered via NickServ when no AccountStore is bound', () => {
928
+ it('succeeds with credentials registered via NickServ (no second registration)', () => {
869
929
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
870
930
  services.registerNick('alice', 'hunter2', 'alice@example.com');
871
931
  const state = saslReadyState();
872
- const ctx = makeCtx(state, undefined, undefined, services);
932
+ const ctx = makeCtx(state, services);
873
933
 
874
934
  authenticateReducer(state, authenticate('PLAIN'), ctx);
875
935
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'hunter2')), ctx);
@@ -881,46 +941,11 @@ describe('authenticateReducer — NickServ-registered nick via ServicesStore', (
881
941
  );
882
942
  });
883
943
 
884
- it('succeeds with NickServ credentials even when a static AccountStore is also bound', () => {
885
- const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
886
- services.registerNick('bob', 'bobpass', 'bob@example.com');
887
- // Static AccountStore does not know about bob; the services store does.
888
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
889
- const state = saslReadyState();
890
- state.nick = 'bob';
891
- state.user = 'bob';
892
- state.host = 'example.com';
893
- const ctx = makeCtx(state, accounts, undefined, services);
894
-
895
- authenticateReducer(state, authenticate('PLAIN'), ctx);
896
- const out = authenticateReducer(state, authenticate(plainPayload('bob', 'bobpass')), ctx);
897
-
898
- expect(state.account).toBe('bob');
899
- expect(sendLinesOf(out.effects[0]).map((l) => l.text)).toContain(
900
- `:${SV} 903 bob :SASL authentication successful`,
901
- );
902
- });
903
-
904
- it('prefers the static AccountStore over the services store on a match', () => {
905
- const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
906
- services.registerNick('alice', 'servicespw', 'alice@example.com');
907
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
908
- const state = saslReadyState();
909
- const ctx = makeCtx(state, accounts, undefined, services);
910
-
911
- authenticateReducer(state, authenticate('PLAIN'), ctx);
912
- authenticateReducer(state, authenticate(plainPayload('alice', 'staticpw')), ctx);
913
-
914
- // The static store vouched for the account; it is used.
915
- expect(accounts.calls).toHaveLength(1);
916
- expect(state.account).toBe('alice');
917
- });
918
-
919
- it('fails when neither the AccountStore nor the ServicesStore verifies the credential', () => {
944
+ it('fails when neither the nick nor a matching credential verifies', () => {
920
945
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
921
946
  services.registerNick('alice', 'hunter2', 'alice@example.com');
922
947
  const state = saslReadyState();
923
- const ctx = makeCtx(state, undefined, undefined, services);
948
+ const ctx = makeCtx(state, services);
924
949
 
925
950
  authenticateReducer(state, authenticate('PLAIN'), ctx);
926
951
  const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);