serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -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 +398 -67
  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 +714 -105
  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/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { afterEach, 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 {
@@ -13,6 +13,8 @@ import {
13
13
  isValidNick,
14
14
  nickReducer,
15
15
  passReducer,
16
+ resetServerPasswordCompareSpy,
17
+ setServerPasswordCompare,
16
18
  userReducer,
17
19
  } from '../../src/commands/registration';
18
20
  import { DEFAULT_CREATED_TEXT } from '../../src/config';
@@ -27,14 +29,7 @@ import {
27
29
  SequentialIdFactory,
28
30
  StaticMotdProvider,
29
31
  } from '../../src/ports';
30
- import type {
31
- AccountStore,
32
- AwayStore,
33
- MotdProvider,
34
- SaslPayload,
35
- SaslResult,
36
- ServicesStore,
37
- } from '../../src/ports';
32
+ import type { AwayStore, MotdProvider, ServicesStore } from '../../src/ports';
38
33
  import { type ConnectionState, createConnection } from '../../src/state/connection';
39
34
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
40
35
 
@@ -555,6 +550,340 @@ describe('userReducer', () => {
555
550
  });
556
551
  });
557
552
 
553
+ // ============================================================================
554
+ // userReducer — USER validation (charset + length + control chars)
555
+ // ============================================================================
556
+
557
+ /**
558
+ * Builds the expected NOTICE line for a USER rejection addressed to the
559
+ * connection's current nick (or `*` when unregistered).
560
+ */
561
+ function userRejectNotice(nick: string, text: string): RawLine {
562
+ return L(`:irc.example.com NOTICE ${nick} :${text}`);
563
+ }
564
+
565
+ /** Username-rejection NOTICE text (matches the reducer's tailored notice). */
566
+ const USERNAME_INVALID_TEXT =
567
+ 'Invalid USER: username must be 1-16 bytes with no spaces, !, @, or control characters';
568
+
569
+ /** Realname-rejection NOTICE text (matches the reducer's tailored notice). */
570
+ const REALNAME_INVALID_TEXT =
571
+ 'Invalid USER: realname must be at most 64 bytes with no control characters';
572
+
573
+ describe('userReducer — USER validation (charset + length)', () => {
574
+ // ------------------------------------------------------------------------
575
+ // Username charset rejections (params[0])
576
+ // ------------------------------------------------------------------------
577
+
578
+ it('rejects a username containing "!" with NOTICE + Disconnect (hostmask safety)', () => {
579
+ const state = makeState();
580
+ const ctx = makeCtx(state);
581
+ const out = userReducer(
582
+ state,
583
+ { command: 'USER', params: ['al!ce', '0', '*', 'Alice'], tags: {} },
584
+ ctx,
585
+ );
586
+ expect(out.state.user).toBeUndefined();
587
+ expect(out.state.realname).toBeUndefined();
588
+ expect(out.state.registration).toBe('pre-registration');
589
+ expect(out.effects).toEqual<EffectType[]>([
590
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
591
+ Effect.disconnect('c1', 'invalid USER'),
592
+ ]);
593
+ });
594
+
595
+ it('rejects a username containing "@" with NOTICE + Disconnect (hostmask safety)', () => {
596
+ const state = makeState();
597
+ const ctx = makeCtx(state);
598
+ const out = userReducer(
599
+ state,
600
+ { command: 'USER', params: ['al@ce', '0', '*', 'Alice'], tags: {} },
601
+ ctx,
602
+ );
603
+ expect(out.state.user).toBeUndefined();
604
+ expect(out.effects).toEqual<EffectType[]>([
605
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
606
+ Effect.disconnect('c1', 'invalid USER'),
607
+ ]);
608
+ });
609
+
610
+ it('rejects a username containing a space with NOTICE + Disconnect', () => {
611
+ const state = makeState();
612
+ const ctx = makeCtx(state);
613
+ const out = userReducer(
614
+ state,
615
+ { command: 'USER', params: ['al ce', '0', '*', 'Alice'], tags: {} },
616
+ ctx,
617
+ );
618
+ expect(out.state.user).toBeUndefined();
619
+ expect(out.effects).toEqual<EffectType[]>([
620
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
621
+ Effect.disconnect('c1', 'invalid USER'),
622
+ ]);
623
+ });
624
+
625
+ it('rejects a username containing NUL (\\0) with NOTICE + Disconnect', () => {
626
+ const state = makeState();
627
+ const ctx = makeCtx(state);
628
+ const out = userReducer(
629
+ state,
630
+ { command: 'USER', params: ['al\0ce', '0', '*', 'Alice'], tags: {} },
631
+ ctx,
632
+ );
633
+ expect(out.state.user).toBeUndefined();
634
+ expect(out.effects).toEqual<EffectType[]>([
635
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
636
+ Effect.disconnect('c1', 'invalid USER'),
637
+ ]);
638
+ });
639
+
640
+ it('rejects a username containing a bare CR (\\r) with NOTICE + Disconnect', () => {
641
+ const state = makeState();
642
+ const ctx = makeCtx(state);
643
+ const out = userReducer(
644
+ state,
645
+ { command: 'USER', params: ['al\rce', '0', '*', 'Alice'], tags: {} },
646
+ ctx,
647
+ );
648
+ expect(out.state.user).toBeUndefined();
649
+ expect(out.effects).toEqual<EffectType[]>([
650
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
651
+ Effect.disconnect('c1', 'invalid USER'),
652
+ ]);
653
+ });
654
+
655
+ it('rejects a username containing a bare LF (\\n) with NOTICE + Disconnect', () => {
656
+ const state = makeState();
657
+ const ctx = makeCtx(state);
658
+ const out = userReducer(
659
+ state,
660
+ { command: 'USER', params: ['al\nce', '0', '*', 'Alice'], tags: {} },
661
+ ctx,
662
+ );
663
+ expect(out.state.user).toBeUndefined();
664
+ expect(out.effects).toEqual<EffectType[]>([
665
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
666
+ Effect.disconnect('c1', 'invalid USER'),
667
+ ]);
668
+ });
669
+
670
+ it('rejects a username containing a control char (\\x01) with NOTICE + Disconnect', () => {
671
+ const state = makeState();
672
+ const ctx = makeCtx(state);
673
+ const out = userReducer(
674
+ state,
675
+ { command: 'USER', params: ['al\x01ce', '0', '*', 'Alice'], tags: {} },
676
+ ctx,
677
+ );
678
+ expect(out.state.user).toBeUndefined();
679
+ expect(out.effects).toEqual<EffectType[]>([
680
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
681
+ Effect.disconnect('c1', 'invalid USER'),
682
+ ]);
683
+ });
684
+
685
+ // ------------------------------------------------------------------------
686
+ // Username length rejection (USERLEN = 16 byte cap)
687
+ // ------------------------------------------------------------------------
688
+
689
+ it('rejects a 17-byte username with NOTICE + Disconnect (USERLEN = 16)', () => {
690
+ const state = makeState();
691
+ const ctx = makeCtx(state);
692
+ const longUser = 'a'.repeat(17);
693
+ const out = userReducer(
694
+ state,
695
+ { command: 'USER', params: [longUser, '0', '*', 'Alice'], tags: {} },
696
+ ctx,
697
+ );
698
+ expect(out.state.user).toBeUndefined();
699
+ expect(out.effects).toEqual<EffectType[]>([
700
+ Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
701
+ Effect.disconnect('c1', 'invalid USER'),
702
+ ]);
703
+ });
704
+
705
+ it('accepts a username at exactly the 16-byte USERLEN cap (boundary)', () => {
706
+ const state = makeState();
707
+ const ctx = makeCtx(state);
708
+ const exactUser = 'a'.repeat(16);
709
+ const out = userReducer(
710
+ state,
711
+ { command: 'USER', params: [exactUser, '0', '*', 'Alice'], tags: {} },
712
+ ctx,
713
+ );
714
+ expect(out.state.user).toBe(exactUser);
715
+ expect(out.state.realname).toBe('Alice');
716
+ });
717
+
718
+ // ------------------------------------------------------------------------
719
+ // Realname charset rejections (params[3])
720
+ // ------------------------------------------------------------------------
721
+
722
+ it('rejects a realname containing NUL (\\0) with NOTICE + Disconnect', () => {
723
+ const state = makeState();
724
+ const ctx = makeCtx(state);
725
+ const out = userReducer(
726
+ state,
727
+ { command: 'USER', params: ['alice', '0', '*', 'Al\0ice'], tags: {} },
728
+ ctx,
729
+ );
730
+ expect(out.state.user).toBeUndefined();
731
+ expect(out.state.realname).toBeUndefined();
732
+ expect(out.effects).toEqual<EffectType[]>([
733
+ Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
734
+ Effect.disconnect('c1', 'invalid USER'),
735
+ ]);
736
+ });
737
+
738
+ it('rejects a realname containing a bare CR (\\r) with NOTICE + Disconnect (CRLF injection)', () => {
739
+ const state = makeState();
740
+ const ctx = makeCtx(state);
741
+ const out = userReducer(
742
+ state,
743
+ { command: 'USER', params: ['alice', '0', '*', 'Al\rice'], tags: {} },
744
+ ctx,
745
+ );
746
+ expect(out.state.realname).toBeUndefined();
747
+ expect(out.effects).toEqual<EffectType[]>([
748
+ Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
749
+ Effect.disconnect('c1', 'invalid USER'),
750
+ ]);
751
+ });
752
+
753
+ it('rejects a realname containing a bare LF (\\n) with NOTICE + Disconnect', () => {
754
+ const state = makeState();
755
+ const ctx = makeCtx(state);
756
+ const out = userReducer(
757
+ state,
758
+ { command: 'USER', params: ['alice', '0', '*', 'Al\nice'], tags: {} },
759
+ ctx,
760
+ );
761
+ expect(out.state.realname).toBeUndefined();
762
+ expect(out.effects).toEqual<EffectType[]>([
763
+ Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
764
+ Effect.disconnect('c1', 'invalid USER'),
765
+ ]);
766
+ });
767
+
768
+ it('rejects a realname containing a control char (\\x01) with NOTICE + Disconnect', () => {
769
+ const state = makeState();
770
+ const ctx = makeCtx(state);
771
+ const out = userReducer(
772
+ state,
773
+ { command: 'USER', params: ['alice', '0', '*', 'Al\x01ice'], tags: {} },
774
+ ctx,
775
+ );
776
+ expect(out.state.realname).toBeUndefined();
777
+ expect(out.effects).toEqual<EffectType[]>([
778
+ Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
779
+ Effect.disconnect('c1', 'invalid USER'),
780
+ ]);
781
+ });
782
+
783
+ // ------------------------------------------------------------------------
784
+ // Realname length rejection (REALLEN = 64 byte cap, multi-byte aware)
785
+ // ------------------------------------------------------------------------
786
+
787
+ it('rejects a 65-byte realname with NOTICE + Disconnect (REALLEN = 64)', () => {
788
+ const state = makeState();
789
+ const ctx = makeCtx(state);
790
+ const longReal = 'A'.repeat(65);
791
+ const out = userReducer(
792
+ state,
793
+ { command: 'USER', params: ['alice', '0', '*', longReal], tags: {} },
794
+ ctx,
795
+ );
796
+ expect(out.state.realname).toBeUndefined();
797
+ expect(out.effects).toEqual<EffectType[]>([
798
+ Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
799
+ Effect.disconnect('c1', 'invalid USER'),
800
+ ]);
801
+ });
802
+
803
+ it('rejects a multi-byte UTF-8 realname exceeding the 64-byte budget', () => {
804
+ const state = makeState();
805
+ const ctx = makeCtx(state);
806
+ // Each '€' is 3 bytes in UTF-8; 22 × 3 = 66 bytes > 64.
807
+ const overBudget = '€'.repeat(22);
808
+ const out = userReducer(
809
+ state,
810
+ { command: 'USER', params: ['alice', '0', '*', overBudget], tags: {} },
811
+ ctx,
812
+ );
813
+ expect(out.state.realname).toBeUndefined();
814
+ expect(out.effects).toEqual<EffectType[]>([
815
+ Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
816
+ Effect.disconnect('c1', 'invalid USER'),
817
+ ]);
818
+ });
819
+
820
+ it('accepts a multi-byte UTF-8 realname within the 64-byte budget (regression guard)', () => {
821
+ const state = makeState();
822
+ const ctx = makeCtx(state);
823
+ // 21 × 3 = 63 bytes ≤ 64.
824
+ const withinBudget = '€'.repeat(21);
825
+ const out = userReducer(
826
+ state,
827
+ { command: 'USER', params: ['alice', '0', '*', withinBudget], tags: {} },
828
+ ctx,
829
+ );
830
+ expect(out.state.user).toBe('alice');
831
+ expect(out.state.realname).toBe(withinBudget);
832
+ });
833
+
834
+ it('accepts a realname at exactly the 64-byte REALLEN cap (boundary)', () => {
835
+ const state = makeState();
836
+ const ctx = makeCtx(state);
837
+ const exactReal = 'A'.repeat(64);
838
+ const out = userReducer(
839
+ state,
840
+ { command: 'USER', params: ['alice', '0', '*', exactReal], tags: {} },
841
+ ctx,
842
+ );
843
+ expect(out.state.realname).toBe(exactReal);
844
+ });
845
+
846
+ it('does not half-store state when the username is valid but the realname is invalid', () => {
847
+ const state = makeState();
848
+ const ctx = makeCtx(state);
849
+ const out = userReducer(
850
+ state,
851
+ { command: 'USER', params: ['alice', '0', '*', 'Al\rice'], tags: {} },
852
+ ctx,
853
+ );
854
+ expect(out.state.user).toBeUndefined();
855
+ expect(out.state.realname).toBeUndefined();
856
+ });
857
+
858
+ it('uses the current nick (not *) in the rejection NOTICE when partially registered', () => {
859
+ const state = makeState();
860
+ state.nick = 'bob';
861
+ state.registration = 'registering';
862
+ const ctx = makeCtx(state);
863
+ const out = userReducer(
864
+ state,
865
+ { command: 'USER', params: ['b!ob', '0', '*', 'Bob'], tags: {} },
866
+ ctx,
867
+ );
868
+ expect(out.effects).toEqual<EffectType[]>([
869
+ Effect.send('c1', [userRejectNotice('bob', USERNAME_INVALID_TEXT)]),
870
+ Effect.disconnect('c1', 'invalid USER'),
871
+ ]);
872
+ });
873
+
874
+ it('still allows a realname containing spaces (valid realname)', () => {
875
+ const state = makeState();
876
+ const ctx = makeCtx(state);
877
+ const out = userReducer(
878
+ state,
879
+ { command: 'USER', params: ['alice', '0', '*', 'Alice Wonderland'], tags: {} },
880
+ ctx,
881
+ );
882
+ expect(out.state.user).toBe('alice');
883
+ expect(out.state.realname).toBe('Alice Wonderland');
884
+ });
885
+ });
886
+
558
887
  // ============================================================================
559
888
  // passReducer
560
889
  // ============================================================================
@@ -615,8 +944,9 @@ describe('passReducer', () => {
615
944
  describe('passReducer — post-registration PASS <nick>:<password>', () => {
616
945
  it('logs in when PASS <nick>:<password> matches an account after registration', () => {
617
946
  const state = registeredState();
618
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
619
- const ctx = makeCtxWithAccounts(state, accounts);
947
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
948
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
949
+ const ctx = makeCtxWithServices(state, services);
620
950
  const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
621
951
 
622
952
  expect(out.state.account).toBe('alice');
@@ -632,8 +962,9 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
632
962
 
633
963
  it('does not log in or disconnect when the password is wrong', () => {
634
964
  const state = registeredState();
635
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
636
- const ctx = makeCtxWithAccounts(state, accounts);
965
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
966
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
967
+ const ctx = makeCtxWithServices(state, services);
637
968
  const out = passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
638
969
 
639
970
  expect(out.state.account).toBeUndefined();
@@ -645,8 +976,8 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
645
976
  it('is indistinguishable from an unknown nick (no auth numerics, no disconnect)', () => {
646
977
  const state = registeredState();
647
978
  state.nick = 'nobody';
648
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
649
- const ctx = makeCtxWithAccounts(state, accounts);
979
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
980
+ const ctx = makeCtxWithServices(state, services);
650
981
  const out = passReducer(state, { command: 'PASS', params: ['nobody:wrong'], tags: {} }, ctx);
651
982
 
652
983
  expect(out.state.account).toBeUndefined();
@@ -655,55 +986,63 @@ describe('passReducer — post-registration PASS <nick>:<password>', () => {
655
986
 
656
987
  it('equalises timing with an extra dummy verify on failure', () => {
657
988
  const state = registeredState();
658
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
659
- const ctx = makeCtxWithAccounts(state, accounts);
989
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
990
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
991
+ const verifyNick = vi.spyOn(services, 'verifyNick');
992
+ const ctx = makeCtxWithServices(state, services);
660
993
  passReducer(state, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
661
994
 
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');
995
+ // Two calls: the real (failed) verify against `alice`, then the
996
+ // timing-equalising dummy verify.
997
+ expect(verifyNick).toHaveBeenCalledTimes(2);
998
+ expect(verifyNick.mock.calls[0]?.[0]).toBe('alice');
999
+ expect(verifyNick.mock.calls[0]?.[1]).toBe('wrong');
1000
+ // The second call is the dummy; its nick is the sentinel, not 'alice'.
1001
+ expect(verifyNick.mock.calls[1]?.[0]).not.toBe('alice');
669
1002
  });
670
1003
 
671
1004
  it('does not log in when the payload nick does not match state.nick', () => {
672
1005
  const state = registeredState();
673
- const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
674
- const ctx = makeCtxWithAccounts(state, accounts);
1006
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1007
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
1008
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1009
+ const ctx = makeCtxWithServices(state, services);
675
1010
  const out = passReducer(state, { command: 'PASS', params: ['bob:hunter2'], tags: {} }, ctx);
676
1011
 
677
1012
  expect(out.state.account).toBeUndefined();
678
- expect(accounts.calls).toHaveLength(0);
1013
+ expect(verifyNick).not.toHaveBeenCalled();
679
1014
  expect(out.effects).toEqual([]);
680
1015
  });
681
1016
 
682
1017
  it('ignores a bare PASS value (no colon) after registration — no 462, no disconnect', () => {
683
1018
  const state = registeredState();
684
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
685
- const ctx = makeCtxWithAccounts(state, accounts);
1019
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1020
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1021
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1022
+ const ctx = makeCtxWithServices(state, services);
686
1023
  const out = passReducer(state, { command: 'PASS', params: ['serverpw'], tags: {} }, ctx);
687
1024
 
688
1025
  expect(out.state.account).toBeUndefined();
689
1026
  expect(out.effects).toEqual([]);
690
- expect(accounts.calls).toHaveLength(0);
1027
+ expect(verifyNick).not.toHaveBeenCalled();
691
1028
  expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
692
1029
  });
693
1030
 
694
1031
  it('ignores a late PASS when the connection is already identified', () => {
695
1032
  const state = registeredState();
696
1033
  state.account = 'alice';
697
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
698
- const ctx = makeCtxWithAccounts(state, accounts);
1034
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1035
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1036
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1037
+ const ctx = makeCtxWithServices(state, services);
699
1038
  const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
700
1039
 
701
- expect(accounts.calls).toHaveLength(0);
1040
+ expect(verifyNick).not.toHaveBeenCalled();
702
1041
  expect(out.effects).toEqual([]);
703
1042
  expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
704
1043
  });
705
1044
 
706
- it('ignores a late PASS <nick>:<password> when no AccountStore is bound', () => {
1045
+ it('ignores a late PASS <nick>:<password> when no ServicesStore is bound', () => {
707
1046
  const state = registeredState();
708
1047
  const ctx = makeCtx(state);
709
1048
  const out = passReducer(state, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
@@ -1094,6 +1433,132 @@ describe('emitWelcomeIfReady — server-password gate', () => {
1094
1433
  });
1095
1434
  });
1096
1435
 
1436
+ // ============================================================================
1437
+ // server-password gate — constant-time comparison
1438
+ // ----------------------------------------------------------------------------
1439
+
1440
+ /**
1441
+ * Runs one gate evaluation against the configured password `s3cret` and
1442
+ * returns the effects, so the tests below can focus on comparator calls.
1443
+ */
1444
+ function runGate(passAttempt: string | undefined): EffectType[] {
1445
+ const state = makeState();
1446
+ state.nick = 'alice';
1447
+ state.user = 'alice';
1448
+ if (passAttempt !== undefined) state.passAttempt = passAttempt;
1449
+ return emitWelcomeIfReady(state, makeCtxWithPassword(state, 's3cret'));
1450
+ }
1451
+
1452
+ /**
1453
+ * Installs a call-counting comparator that behaves like string equality so
1454
+ * gate outcomes stay behavioral while the delegation is observable.
1455
+ */
1456
+ function installCountingCompare(): ReturnType<typeof vi.fn> {
1457
+ const compare = vi.fn((a: string, b: string) => a === b);
1458
+ setServerPasswordCompare(compare);
1459
+ return compare;
1460
+ }
1461
+
1462
+ describe('passwordGateFailure — constant-time comparison', () => {
1463
+ afterEach(() => {
1464
+ resetServerPasswordCompareSpy();
1465
+ });
1466
+
1467
+ it('compares the attempt against the configured password via constantTimeEquals', () => {
1468
+ const compare = installCountingCompare();
1469
+ try {
1470
+ runGate('wrong');
1471
+ expect(compare).toHaveBeenCalledTimes(1);
1472
+ expect(compare).toHaveBeenCalledWith('wrong', 's3cret');
1473
+ } finally {
1474
+ setServerPasswordCompare(null);
1475
+ }
1476
+ });
1477
+
1478
+ it('compares exactly once on the equal-password bucket (gate satisfied)', () => {
1479
+ const compare = installCountingCompare();
1480
+ try {
1481
+ const effects = runGate('s3cret');
1482
+ expect(compare).toHaveBeenCalledTimes(1);
1483
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
1484
+ } finally {
1485
+ setServerPasswordCompare(null);
1486
+ }
1487
+ });
1488
+
1489
+ it('compares exactly once on the same-length unequal bucket (464 + Disconnect)', () => {
1490
+ const compare = installCountingCompare();
1491
+ try {
1492
+ const effects = runGate('X3cret');
1493
+ expect(compare).toHaveBeenCalledTimes(1);
1494
+ expect(effects).toEqual<EffectType[]>([
1495
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
1496
+ Effect.disconnect('c1', 'Bad Password'),
1497
+ ]);
1498
+ } finally {
1499
+ setServerPasswordCompare(null);
1500
+ }
1501
+ });
1502
+
1503
+ it('compares exactly once on the different-length bucket — no length pre-check bypass', () => {
1504
+ const compare = installCountingCompare();
1505
+ try {
1506
+ const effects = runGate('s3cr');
1507
+ expect(compare).toHaveBeenCalledTimes(1);
1508
+ expect(compare).toHaveBeenCalledWith('s3cr', 's3cret');
1509
+ expect(effects).toEqual<EffectType[]>([
1510
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
1511
+ Effect.disconnect('c1', 'Bad Password'),
1512
+ ]);
1513
+ } finally {
1514
+ setServerPasswordCompare(null);
1515
+ }
1516
+ });
1517
+
1518
+ it('compares the empty placeholder once when no PASS was sent', () => {
1519
+ const compare = installCountingCompare();
1520
+ try {
1521
+ runGate(undefined);
1522
+ expect(compare).toHaveBeenCalledTimes(1);
1523
+ expect(compare).toHaveBeenCalledWith('', 's3cret');
1524
+ } finally {
1525
+ setServerPasswordCompare(null);
1526
+ }
1527
+ });
1528
+
1529
+ it('does not compare when the gate is satisfied by SASL (account set)', () => {
1530
+ const state = makeState();
1531
+ state.nick = 'alice';
1532
+ state.user = 'alice';
1533
+ state.account = 'alice';
1534
+ const compare = installCountingCompare();
1535
+ try {
1536
+ const effects = emitWelcomeIfReady(state, makeCtxWithPassword(state, 's3cret'));
1537
+ expect(compare).not.toHaveBeenCalled();
1538
+ expect(state.registration).toBe('registered');
1539
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
1540
+ } finally {
1541
+ setServerPasswordCompare(null);
1542
+ }
1543
+ });
1544
+
1545
+ it('does not compare when no server password is configured (gate disabled)', () => {
1546
+ const state = makeState();
1547
+ state.nick = 'alice';
1548
+ state.user = 'alice';
1549
+ const compare = installCountingCompare();
1550
+ try {
1551
+ const ctx = makeCtxWithPassword(state, '');
1552
+ const effects = emitWelcomeIfReady(state, ctx);
1553
+ expect(compare).not.toHaveBeenCalled();
1554
+ expect(state.registration).toBe('registered');
1555
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
1556
+ } finally {
1557
+ setServerPasswordCompare(null);
1558
+ }
1559
+ });
1560
+ });
1561
+
1097
1562
  // ============================================================================
1098
1563
  // userReducer / nickReducer — server-password gate integration
1099
1564
  // ============================================================================
@@ -1630,32 +2095,6 @@ describe('emitWelcomeIfReady — OperServ AKILL admission', () => {
1630
2095
  // PASS-based account auth (NICK + PASS <nick>:<password> + USER → identified)
1631
2096
  // ============================================================================
1632
2097
 
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
2098
  const SV = 'irc.example.com';
1660
2099
  const L2 = (text: string): RawLine => ({ text });
1661
2100
 
@@ -1666,8 +2105,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
1666
2105
  state.user = 'alice';
1667
2106
  state.host = 'example.com';
1668
2107
  state.passAttempt = 'alice:hunter2';
1669
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1670
- const ctx = makeCtxWithAccounts(state, accounts);
2108
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2109
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2110
+ const ctx = makeCtxWithServices(state, services);
1671
2111
 
1672
2112
  const effects = emitWelcomeIfReady(state, ctx);
1673
2113
 
@@ -1684,11 +2124,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (success)', () => {
1684
2124
 
1685
2125
  // ---- helpers for the broader PASS-auth coverage ----
1686
2126
 
1687
- /** Flexible ctx that binds any combination of account/services/away stores. */
2127
+ /** Flexible ctx that binds any combination of services/away stores. */
1688
2128
  function makeCtxPassAuth(
1689
2129
  state: ConnectionState,
1690
2130
  opts: {
1691
- accounts?: AccountStore;
1692
2131
  services?: ServicesStore;
1693
2132
  away?: AwayStore;
1694
2133
  serverPassword?: string;
@@ -1703,7 +2142,6 @@ function makeCtxPassAuth(
1703
2142
  ids: new SequentialIdFactory(),
1704
2143
  motd: EmptyMotdProvider,
1705
2144
  connection: state,
1706
- ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
1707
2145
  ...(opts.services !== undefined ? { services: opts.services } : {}),
1708
2146
  ...(opts.away !== undefined ? { away: opts.away } : {}),
1709
2147
  });
@@ -1715,16 +2153,16 @@ function sentTexts(effects: EffectType[]): string[] {
1715
2153
  }
1716
2154
 
1717
2155
  describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity with SASL)', () => {
1718
- it('forwards the parsed credentials to the AccountStore as a PLAIN verify', () => {
2156
+ it('forwards the parsed credentials to services.verifyNick as a PLAIN verify', () => {
1719
2157
  const state = makeState();
1720
2158
  state.nick = 'alice';
1721
2159
  state.user = 'alice';
1722
2160
  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
- ]);
2161
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2162
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2163
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2164
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
2165
+ expect(verifyNick).toHaveBeenCalledWith('alice', 'hunter2');
1728
2166
  });
1729
2167
 
1730
2168
  it('stamps read-only user mode `r` on a successful PASS-auth', () => {
@@ -1732,8 +2170,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1732
2170
  state.nick = 'alice';
1733
2171
  state.user = 'alice';
1734
2172
  state.passAttempt = 'alice:hunter2';
1735
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1736
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
2173
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2174
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2175
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1737
2176
  expect(state.userModes.registered).toBe(true);
1738
2177
  });
1739
2178
 
@@ -1742,10 +2181,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1742
2181
  state.nick = 'alice';
1743
2182
  state.user = 'alice';
1744
2183
  state.passAttempt = 'alice:hunter2';
1745
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1746
2184
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2185
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1747
2186
  services.setLastReadMarker('alice', '#foo', 'm1');
1748
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, services }));
2187
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1749
2188
  expect(state.lastReadMarkers?.get('#foo')).toBe('m1');
1750
2189
  });
1751
2190
 
@@ -1754,10 +2193,11 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1754
2193
  state.nick = 'alice';
1755
2194
  state.user = 'alice';
1756
2195
  state.passAttempt = 'alice:hunter2';
1757
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2196
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2197
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1758
2198
  const away = new InMemoryAwayStore();
1759
2199
  away.set('alice', 'brb');
1760
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts, away }));
2200
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services, away }));
1761
2201
  expect(state.away).toBe('brb');
1762
2202
  expect(sentTexts(effects)).toContain(`:${SV} 306 alice :You have been marked as being away`);
1763
2203
  });
@@ -1771,8 +2211,7 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1771
2211
  services.registerNick('alice', 'hunter2', 'alice@example.com');
1772
2212
  services.registerNick('bob', 'pw', 'bob@example.com');
1773
2213
  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 }));
2214
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1776
2215
  expect(sentTexts(effects).some((t) => t.includes('[Memo #') && t.includes('hi'))).toBe(true);
1777
2216
  });
1778
2217
 
@@ -1782,10 +2221,10 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1782
2221
  state.user = 'alice';
1783
2222
  state.host = 'real.example.net';
1784
2223
  state.passAttempt = 'alice:hunter2';
1785
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1786
2224
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2225
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1787
2226
  services.setVhost('alice', 'cool.example.net');
1788
- const ctx = makeCtxPassAuth(state, { accounts, services });
2227
+ const ctx = makeCtxPassAuth(state, { services });
1789
2228
 
1790
2229
  const effects = emitWelcomeIfReady(state, ctx);
1791
2230
 
@@ -1804,9 +2243,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1804
2243
  state.user = 'alice';
1805
2244
  state.host = 'real.example.net';
1806
2245
  state.passAttempt = 'alice:hunter2';
1807
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1808
2246
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
1809
- const ctx = makeCtxPassAuth(state, { accounts, services });
2247
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2248
+ const ctx = makeCtxPassAuth(state, { services });
1810
2249
 
1811
2250
  emitWelcomeIfReady(state, ctx);
1812
2251
 
@@ -1820,8 +2259,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1820
2259
  state.user = 'alice';
1821
2260
  state.host = '203.0.113.5';
1822
2261
  state.passAttempt = 'alice:hunter2';
1823
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1824
2262
  const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2263
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1825
2264
  services.setVhost('alice', 'cool.example.net');
1826
2265
  const ctx: Ctx = buildCtx({
1827
2266
  serverConfig: { ...serverConfig, cloaking: { enabled: true, secret: 's3cret' } },
@@ -1829,7 +2268,6 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1829
2268
  ids: new SequentialIdFactory(),
1830
2269
  motd: EmptyMotdProvider,
1831
2270
  connection: state,
1832
- accounts,
1833
2271
  services,
1834
2272
  });
1835
2273
 
@@ -1851,8 +2289,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (account-effects parity
1851
2289
  state.nick = 'alice';
1852
2290
  state.user = 'alice';
1853
2291
  state.passAttempt = 'alice:hunter2';
1854
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1855
- emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
2292
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2293
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2294
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1856
2295
  expect(state.account).toBe('alice');
1857
2296
  });
1858
2297
  });
@@ -1863,8 +2302,9 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1863
2302
  state.nick = 'alice';
1864
2303
  state.user = 'alice';
1865
2304
  state.passAttempt = 'alice:wrong';
1866
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1867
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
2305
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2306
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2307
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1868
2308
 
1869
2309
  expect(state.account).toBeUndefined();
1870
2310
  expect(state.userModes.registered).toBe(false);
@@ -1880,8 +2320,8 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1880
2320
  state.nick = 'nobody';
1881
2321
  state.user = 'nobody';
1882
2322
  state.passAttempt = 'nobody:wrong';
1883
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
1884
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
2323
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2324
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1885
2325
 
1886
2326
  expect(state.account).toBeUndefined();
1887
2327
  expect(state.registration).toBe('registered');
@@ -1897,17 +2337,15 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1897
2337
  state.nick = 'alice';
1898
2338
  state.user = 'alice';
1899
2339
  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');
2340
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2341
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2342
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2343
+ emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
2344
+
2345
+ expect(verifyNick).toHaveBeenCalledTimes(2);
2346
+ expect(verifyNick.mock.calls[0]?.[0]).toBe('alice');
2347
+ expect(verifyNick.mock.calls[0]?.[1]).toBe('wrong');
2348
+ expect(verifyNick.mock.calls[1]?.[0]).not.toBe('alice');
1911
2349
  });
1912
2350
 
1913
2351
  it('treats a payload nick that does not match state.nick as a failed auth (no silent login)', () => {
@@ -1915,19 +2353,21 @@ describe('emitWelcomeIfReady — PASS-based account auth (failures)', () => {
1915
2353
  state.nick = 'alice';
1916
2354
  state.user = 'alice';
1917
2355
  state.passAttempt = 'bob:hunter2';
1918
- const accounts = new FakeAccountStore({ ok: true, account: 'bob' });
1919
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
2356
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2357
+ services.registerNick('bob', 'hunter2', 'bob@example.com');
2358
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2359
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1920
2360
 
1921
2361
  expect(state.account).toBeUndefined();
1922
2362
  expect(state.registration).toBe('registered');
1923
2363
  // The store is never consulted on a nick mismatch.
1924
- expect(accounts.calls).toHaveLength(0);
2364
+ expect(verifyNick).not.toHaveBeenCalled();
1925
2365
  expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
1926
2366
  });
1927
2367
  });
1928
2368
 
1929
2369
  describe('emitWelcomeIfReady — PASS-auth interaction with the server-password gate', () => {
1930
- it('ignores the <nick>:<password> form when no AccountStore is bound (open gate)', () => {
2370
+ it('ignores the <nick>:<password> form when no ServicesStore is bound (open gate)', () => {
1931
2371
  const state = makeState();
1932
2372
  state.nick = 'alice';
1933
2373
  state.user = 'alice';
@@ -1939,7 +2379,28 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1939
2379
  expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
1940
2380
  });
1941
2381
 
1942
- it('collapses to the server-password gate (464) when no AccountStore is bound and the colon form does not match', () => {
2382
+ it('a SASL-identified connection (state.account set) satisfies the gate with no credential-store read', () => {
2383
+ const state = makeState();
2384
+ state.nick = 'alice';
2385
+ state.user = 'alice';
2386
+ state.account = 'alice'; // SASL-identified before registration completion.
2387
+ state.passAttempt = 'alice:hunter2';
2388
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2389
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2390
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2391
+ const effects = emitWelcomeIfReady(
2392
+ state,
2393
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
2394
+ );
2395
+
2396
+ // The gate is satisfied by state.account; no PASS-auth verify fires.
2397
+ expect(verifyNick).not.toHaveBeenCalled();
2398
+ expect(state.registration).toBe('registered');
2399
+ expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
2400
+ expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
2401
+ });
2402
+
2403
+ it('collapses to the server-password gate (464) when no ServicesStore is bound and the colon form does not match', () => {
1943
2404
  const state = makeState();
1944
2405
  state.nick = 'alice';
1945
2406
  state.user = 'alice';
@@ -1958,10 +2419,11 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1958
2419
  state.nick = 'alice';
1959
2420
  state.user = 'alice';
1960
2421
  state.passAttempt = 'alice:wrong';
1961
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
2422
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2423
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1962
2424
  const effects = emitWelcomeIfReady(
1963
2425
  state,
1964
- makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
2426
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1965
2427
  );
1966
2428
 
1967
2429
  expect(state.registration).not.toBe('registered');
@@ -1978,10 +2440,12 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1978
2440
  state.user = 'alice';
1979
2441
  state.account = 'alice'; // SASL-identified before registration completion
1980
2442
  state.passAttempt = 'alice:hunter2';
1981
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
1982
- const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { accounts }));
2443
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2444
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2445
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2446
+ const effects = emitWelcomeIfReady(state, makeCtxPassAuth(state, { services }));
1983
2447
 
1984
- expect(accounts.calls).toHaveLength(0);
2448
+ expect(verifyNick).not.toHaveBeenCalled();
1985
2449
  expect(state.registration).toBe('registered');
1986
2450
  // No 900/903 are re-emitted by PASS-auth; SASL already emitted them.
1987
2451
  expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(false);
@@ -1992,13 +2456,15 @@ describe('emitWelcomeIfReady — PASS-auth interaction with the server-password
1992
2456
  state.nick = 'alice';
1993
2457
  state.user = 'alice';
1994
2458
  state.passAttempt = 's3cret';
1995
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2459
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2460
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2461
+ const verifyNick = vi.spyOn(services, 'verifyNick');
1996
2462
  const effects = emitWelcomeIfReady(
1997
2463
  state,
1998
- makeCtxPassAuth(state, { accounts, serverPassword: 's3cret' }),
2464
+ makeCtxPassAuth(state, { services, serverPassword: 's3cret' }),
1999
2465
  );
2000
2466
 
2001
- expect(accounts.calls).toHaveLength(0);
2467
+ expect(verifyNick).not.toHaveBeenCalled();
2002
2468
  expect(state.account).toBeUndefined();
2003
2469
  expect(state.registration).toBe('registered');
2004
2470
  expect(sentTexts(effects).some((t) => t.includes(' 464 '))).toBe(false);
@@ -2011,8 +2477,9 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
2011
2477
  state.user = 'alice';
2012
2478
  // state.nick intentionally undefined — PASS arrived before NICK.
2013
2479
  state.passAttempt = 'alice:hunter2';
2014
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2015
- const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }));
2480
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2481
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2482
+ const effects = passBasedAccountAuth(state, makeCtxPassAuth(state, { services }));
2016
2483
 
2017
2484
  expect(state.account).toBe('alice');
2018
2485
  expect(effects.some((e) => e.tag === 'Send')).toBe(true);
@@ -2021,26 +2488,31 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
2021
2488
  it('returns no effects when no PASS was sent', () => {
2022
2489
  const state = makeState();
2023
2490
  state.nick = 'alice';
2024
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2025
- expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { accounts }))).toEqual([]);
2491
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2492
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2493
+ expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
2026
2494
  });
2027
2495
 
2028
2496
  it('returns no effects for a bare PASS value (no colon)', () => {
2029
2497
  const state = makeState();
2030
2498
  state.nick = 'alice';
2031
2499
  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);
2500
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2501
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2502
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2503
+ expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
2504
+ expect(verifyNick).not.toHaveBeenCalled();
2035
2505
  });
2036
2506
 
2037
2507
  it('returns no effects when the nick portion is empty (`:password`)', () => {
2038
2508
  const state = makeState();
2039
2509
  state.nick = 'alice';
2040
2510
  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);
2511
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2512
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
2513
+ const verifyNick = vi.spyOn(services, 'verifyNick');
2514
+ expect(passBasedAccountAuth(state, makeCtxPassAuth(state, { services }))).toEqual([]);
2515
+ expect(verifyNick).not.toHaveBeenCalled();
2044
2516
  });
2045
2517
 
2046
2518
  it('splits only on the first colon so a password may contain colons', () => {
@@ -2048,16 +2520,13 @@ describe('passBasedAccountAuth — unit (PASS before NICK, parsing, edge cases)'
2048
2520
  state.nick = 'alice';
2049
2521
  state.user = 'alice';
2050
2522
  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
- });
2523
+ const services = new InMemoryServicesStore({ clock: new FakeClock(1_000) });
2524
+ services.registerNick('alice', 'p:ass:word', 'alice@example.com');
2525
+ passBasedAccountAuth(state, makeCtxPassAuth(state, { services }));
2526
+ expect(state.account).toBe('alice');
2058
2527
  });
2059
2528
 
2060
- it('returns no effects when no AccountStore is bound', () => {
2529
+ it('returns no effects when no ServicesStore is bound', () => {
2061
2530
  const state = makeState();
2062
2531
  state.nick = 'alice';
2063
2532
  state.passAttempt = 'alice:hunter2';