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
@@ -3,6 +3,7 @@ import { namesReducer } from '../../src/commands/names';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
5
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { byteLength } from '../../src/protocol/bytes';
6
7
  import { type ChannelState, createChannel } from '../../src/state/channel';
7
8
  import { type ConnectionState, createConnection } from '../../src/state/connection';
8
9
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
@@ -238,6 +239,43 @@ describe('namesReducer — rejections', () => {
238
239
  ]);
239
240
  });
240
241
 
242
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
243
+ const chan = makeChan('#foo');
244
+ const conn = makeConn();
245
+ const ctx = makeCtx(conn);
246
+
247
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo\0bar'], tags: {} }, ctx);
248
+
249
+ expect(out.effects).toEqual<EffectType[]>([
250
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
251
+ ]);
252
+ });
253
+
254
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
255
+ const chan = makeChan('#foo');
256
+ const conn = makeConn();
257
+ const ctx = makeCtx(conn);
258
+
259
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo\x1fbar'], tags: {} }, ctx);
260
+
261
+ expect(out.effects).toEqual<EffectType[]>([
262
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo\x1fbar :No such channel')]),
263
+ ]);
264
+ });
265
+
266
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
267
+ const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
268
+ const chan = makeChan(long);
269
+ const conn = makeConn();
270
+ const ctx = makeCtx(conn);
271
+
272
+ const out = namesReducer(chan, { command: 'NAMES', params: [long], tags: {} }, ctx);
273
+
274
+ expect(out.effects).toEqual<EffectType[]>([
275
+ Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
276
+ ]);
277
+ });
278
+
241
279
  it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
242
280
  const chan = makeChan('#foo');
243
281
  const conn = makeConn();
@@ -340,3 +378,158 @@ describe('namesReducer — rejections', () => {
340
378
  ]);
341
379
  });
342
380
  });
381
+
382
+ // ============================================================================
383
+ // namesReducer — long NAMES list splitting (RFC 2812 §3.2.5)
384
+ // ============================================================================
385
+
386
+ /** RFC 1459 §2.3: 512-byte line budget minus the trailing CRLF pair. */
387
+ const MAX_TEXT_BYTES = 510;
388
+
389
+ describe('namesReducer — long NAMES list splitting', () => {
390
+ /** Returns the Send effect's lines, or [] when the first effect is not a Send. */
391
+ function sentLines(out: { effects: EffectType[] }): RawLine[] {
392
+ const send = out.effects[0];
393
+ return send?.tag === 'Send' ? send.lines : [];
394
+ }
395
+
396
+ /** Returns the trailing parameter of a formatted numeric line (after the last ` :`). */
397
+ function trailingOf(text: string): string {
398
+ const idx = text.lastIndexOf(' :');
399
+ return idx === -1 ? '' : text.slice(idx + 2);
400
+ }
401
+
402
+ /** Returns only the 353 RPL_NAMREPLY lines, in order. */
403
+ function namReplyLines(lines: RawLine[]): RawLine[] {
404
+ return lines.filter((l) => l.text.includes(' 353 '));
405
+ }
406
+
407
+ function pad(i: number): string {
408
+ return i.toString().padStart(3, '0');
409
+ }
410
+
411
+ it('splits a 100-member roster across multiple complete 353 lines within the byte budget', () => {
412
+ const chan = makeChan('#foo');
413
+ const expected: string[] = [];
414
+ for (let i = 1; i <= 100; i++) {
415
+ const nick = `user${pad(i)}`;
416
+ addMember(chan, `c${i}`, nick);
417
+ expected.push(nick);
418
+ }
419
+ const conn = makeConn();
420
+ const ctx = makeCtx(conn);
421
+
422
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
423
+
424
+ const lines = sentLines(out);
425
+ const replies = namReplyLines(lines);
426
+ expect(replies.length).toBeGreaterThan(1);
427
+ for (const line of replies) {
428
+ expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
429
+ }
430
+ // No mid-nick truncation: the roster survives intact, in order, across lines.
431
+ expect(replies.map((l) => trailingOf(l.text)).join(' ')).toBe(expected.join(' '));
432
+ // Exactly one 366 terminator, emitted after the final 353.
433
+ const endLines = lines.filter((l) => l.text.includes(' 366 '));
434
+ expect(endLines.length).toBe(1);
435
+ expect(lines[lines.length - 1]?.text).toBe(
436
+ ':irc.example.com 366 alice #foo :End of /NAMES list.',
437
+ );
438
+ });
439
+
440
+ it('counts @/+ prefix bytes in the budget: no non-final 353 could have taken the next member', () => {
441
+ const chan = makeChan('#foo');
442
+ const expected: string[] = [];
443
+ for (let i = 1; i <= 100; i++) {
444
+ const nick = `user${pad(i)}`;
445
+ // Alternating op (`@userNNN`, 8 bytes) and op+voice (`@+userNNN`, 9 bytes).
446
+ addMember(chan, `c${i}`, nick, true, i % 2 === 0);
447
+ expected.push(i % 2 === 0 ? `@+${nick}` : `@${nick}`);
448
+ }
449
+ const conn = makeConn();
450
+ conn.caps.add('multi-prefix');
451
+ const ctx = makeCtx(conn);
452
+
453
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
454
+
455
+ const lines = sentLines(out);
456
+ const replies = namReplyLines(lines);
457
+ expect(replies.length).toBeGreaterThan(1);
458
+ for (const line of replies) {
459
+ expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
460
+ }
461
+ // Roster intact with prefixes.
462
+ expect(replies.map((l) => trailingOf(l.text)).join(' ')).toBe(expected.join(' '));
463
+ // Greedy maximality: appending the next line's first (prefixed) member to
464
+ // any non-final line would blow the budget — proof the prefix bytes were
465
+ // counted, not just the bare nicks.
466
+ for (let i = 0; i + 1 < replies.length; i++) {
467
+ const cur = replies[i]?.text ?? '';
468
+ const nextFirst = trailingOf(replies[i + 1]?.text ?? '').split(' ')[0] ?? '';
469
+ expect(nextFirst.length).toBeGreaterThan(0);
470
+ expect(byteLength(`${cur} ${nextFirst}`)).toBeGreaterThan(MAX_TEXT_BYTES);
471
+ }
472
+ });
473
+
474
+ it('measures the budget in UTF-8 bytes: multi-byte nicks split even when UTF-16 units fit', () => {
475
+ const chan = makeChan('#foo');
476
+ const nick = 'é'.repeat(60); // 60 UTF-16 units but 120 UTF-8 bytes per nick
477
+ for (let i = 1; i <= 7; i++) {
478
+ addMember(chan, `c${i}`, nick);
479
+ }
480
+ const conn = makeConn();
481
+ const ctx = makeCtx(conn);
482
+
483
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
484
+
485
+ const lines = sentLines(out);
486
+ const replies = namReplyLines(lines);
487
+ // Names alone: 7*120 + 6 = 846 bytes (but only 426 UTF-16 units) — a
488
+ // code-unit-based packer would emit one 881-byte line.
489
+ expect(replies.length).toBeGreaterThan(1);
490
+ for (const line of replies) {
491
+ expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
492
+ }
493
+ expect(replies.map((l) => trailingOf(l.text)).join(' ')).toBe(
494
+ Array<string>(7).fill(nick).join(' '),
495
+ );
496
+ });
497
+
498
+ it('emits exactly one 353 with an empty trailing + the 366 for a zero-member channel', () => {
499
+ const chan = makeChan('#foo'); // roster is completely empty
500
+ const conn = makeConn(); // requester is not a member either
501
+ const ctx = makeCtx(conn);
502
+
503
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
504
+
505
+ expect(sentLines(out)).toEqual<RawLine[]>([
506
+ L(':irc.example.com 353 alice = #foo :'),
507
+ L(':irc.example.com 366 alice #foo :End of /NAMES list.'),
508
+ ]);
509
+ });
510
+
511
+ it('wraps every split 353 line plus the 366 inside one BATCH frame when batch is negotiated', () => {
512
+ const chan = makeChan('#foo');
513
+ for (let i = 1; i <= 100; i++) {
514
+ addMember(chan, `c${i}`, `user${pad(i)}`);
515
+ }
516
+ const conn = makeConn();
517
+ conn.caps.add('batch');
518
+ const ctx = makeCtx(conn);
519
+
520
+ const out = namesReducer(chan, { command: 'NAMES', params: ['#foo'], tags: {} }, ctx);
521
+
522
+ const lines = sentLines(out);
523
+ expect(lines[0]?.text).toBe('BATCH +batch-0 names');
524
+ expect(lines[lines.length - 1]?.text).toBe('BATCH -batch-0');
525
+ const inner = lines.slice(1, -1);
526
+ const replies = namReplyLines(inner);
527
+ expect(replies.length).toBeGreaterThan(1);
528
+ for (const line of replies) {
529
+ expect(byteLength(line.text)).toBeLessThanOrEqual(MAX_TEXT_BYTES);
530
+ }
531
+ expect(inner[inner.length - 1]?.text).toBe(
532
+ ':irc.example.com 366 alice #foo :End of /NAMES list.',
533
+ );
534
+ });
535
+ });
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import { NICKSERV_NICK, enforceRegisteredNick, nickservReducer } from '../../src/commands/nickserv';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType } from '../../src/effects';
5
- import type { ServicesStore } from '../../src/ports';
5
+ import type { ServicesAuthResult, ServicesStore } from '../../src/ports';
6
6
  import {
7
7
  EmptyMotdProvider,
8
8
  FakeClock,
@@ -41,9 +41,10 @@ function makeCtx(
41
41
  state: ConnectionState,
42
42
  services?: ServicesStore,
43
43
  clock = new FakeClock(NOW),
44
+ configOverrides: Partial<ServerConfig> = {},
44
45
  ): Ctx {
45
46
  return buildCtx({
46
- serverConfig,
47
+ serverConfig: { ...serverConfig, ...configOverrides },
47
48
  clock,
48
49
  ids: new SequentialIdFactory(),
49
50
  motd: EmptyMotdProvider,
@@ -283,6 +284,185 @@ describe('nickservReducer — IDENTIFY', () => {
283
284
  });
284
285
  });
285
286
 
287
+ /**
288
+ * InMemoryServicesStore subclass that counts `verifyNick` calls so the
289
+ * identify-freeze tests can assert the frozen attempt is rejected BEFORE
290
+ * scrypt runs (the freeze must short-circuit the verification path).
291
+ */
292
+ class VerifyCountingStore extends InMemoryServicesStore {
293
+ verifyCalls = 0;
294
+ override verifyNick(nick: string, password: string): ServicesAuthResult {
295
+ this.verifyCalls++;
296
+ return super.verifyNick(nick, password);
297
+ }
298
+ }
299
+
300
+ describe('nickservReducer — IDENTIFY freeze (per-account throttle)', () => {
301
+ it('short-circuits the 6th failed IDENTIFY with a freeze notice (no scrypt)', () => {
302
+ const clock = new FakeClock(NOW);
303
+ const services = new VerifyCountingStore({ clock });
304
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
305
+ const conn = makeConn();
306
+ const ctx = makeCtx(conn, services, clock);
307
+
308
+ for (let i = 0; i < 5; i++) {
309
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
310
+ }
311
+ const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
312
+
313
+ expect(services.verifyCalls).toBe(5);
314
+ expect(sentTexts(out.effects)).toContain(
315
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
316
+ );
317
+ });
318
+
319
+ it('lifts the freeze once the failures age out of the 300s window', () => {
320
+ const clock = new FakeClock(NOW);
321
+ const services = new VerifyCountingStore({ clock });
322
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
323
+ const conn = makeConn();
324
+ const ctx = makeCtx(conn, services, clock);
325
+
326
+ for (let i = 0; i < 5; i++) {
327
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
328
+ }
329
+ // Exactly at the window boundary the failures have aged out.
330
+ clock.advance(300_000);
331
+ const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
332
+
333
+ expect(services.verifyCalls).toBe(6);
334
+ expect(sentTexts(out.effects)).toContain(
335
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
336
+ );
337
+ });
338
+
339
+ it('keeps the freeze while the failures are still inside the window', () => {
340
+ const clock = new FakeClock(NOW);
341
+ const services = new VerifyCountingStore({ clock });
342
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
343
+ const conn = makeConn();
344
+ const ctx = makeCtx(conn, services, clock);
345
+
346
+ for (let i = 0; i < 5; i++) {
347
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
348
+ }
349
+ clock.advance(300_000 - 1);
350
+ const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
351
+
352
+ expect(services.verifyCalls).toBe(5);
353
+ expect(sentTexts(out.effects)).toContain(
354
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
355
+ );
356
+ });
357
+
358
+ it('delivers a queued owner NOTICE on the next successful identify after the freeze', () => {
359
+ const clock = new FakeClock(NOW);
360
+ const services = new VerifyCountingStore({ clock });
361
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
362
+ const conn = makeConn();
363
+ const ctx = makeCtx(conn, services, clock);
364
+
365
+ for (let i = 0; i < 5; i++) {
366
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
367
+ }
368
+ clock.advance(300_000);
369
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
370
+
371
+ expect(conn.account).toBe('alice');
372
+ expect(sentTexts(out.effects)).toContain(
373
+ ':NickServ!NickServ@services NOTICE alice :Warning: 5 failed identify attempts recorded against your account since your last successful login.',
374
+ );
375
+
376
+ // The notice is delivered once — a second successful identify (fresh
377
+ // connection) does not repeat it.
378
+ const conn2 = makeConn('c2', 'alice');
379
+ const ctx2 = makeCtx(conn2, services, clock);
380
+ const out2 = nickservReducer(conn2, privmsg('IDENTIFY hunter2'), ctx2);
381
+ expect(sentTexts(out2.effects).some((t) => t.includes('Warning:'))).toBe(false);
382
+ });
383
+
384
+ it('delivers the owner NOTICE with singular wording for a single failed identify', () => {
385
+ const clock = new FakeClock(NOW);
386
+ const services = new VerifyCountingStore({ clock });
387
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
388
+ const conn = makeConn();
389
+ const ctx = makeCtx(conn, services, clock);
390
+
391
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
392
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
393
+
394
+ expect(sentTexts(out.effects)).toContain(
395
+ ':NickServ!NickServ@services NOTICE alice :Warning: 1 failed identify attempt recorded against your account since your last successful login.',
396
+ );
397
+ });
398
+
399
+ it('resets the failure counter on a successful identify', () => {
400
+ const clock = new FakeClock(NOW);
401
+ const services = new VerifyCountingStore({ clock });
402
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
403
+ const conn = makeConn();
404
+ const ctx = makeCtx(conn, services, clock);
405
+
406
+ for (let i = 0; i < 3; i++) {
407
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
408
+ }
409
+ nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
410
+
411
+ // Fresh connection (the first is now identified). Without a reset the
412
+ // counter would still hold 3 failures and the account would freeze
413
+ // after only 2 more; with the reset all 5 new attempts must reach
414
+ // scrypt and return the plain invalid-password notice.
415
+ const conn2 = makeConn('c2', 'alice');
416
+ const ctx2 = makeCtx(conn2, services, clock);
417
+ for (let i = 0; i < 5; i++) {
418
+ const out = nickservReducer(conn2, privmsg('IDENTIFY wrongpass'), ctx2);
419
+ expect(sentTexts(out.effects)).toContain(
420
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
421
+ );
422
+ }
423
+ expect(services.verifyCalls).toBe(3 + 1 + 5);
424
+
425
+ // The 6th new failure crosses the (reset) threshold → frozen.
426
+ const frozen = nickservReducer(conn2, privmsg('IDENTIFY wrongpass'), ctx2);
427
+ expect(services.verifyCalls).toBe(9);
428
+ expect(sentTexts(frozen.effects)).toContain(
429
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
430
+ );
431
+ });
432
+
433
+ it('honours nickServ.maxIdentifyFailures and identifyFreezeSeconds overrides', () => {
434
+ const clock = new FakeClock(NOW);
435
+ const services = new VerifyCountingStore({ clock });
436
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
437
+ const conn = makeConn();
438
+ const ctx = makeCtx(conn, services, clock, {
439
+ nickServ: { maxIdentifyFailures: 2, identifyFreezeSeconds: 60 },
440
+ });
441
+
442
+ nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
443
+ const second = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
444
+ expect(services.verifyCalls).toBe(2);
445
+ expect(sentTexts(second.effects)).toContain(
446
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
447
+ );
448
+
449
+ // Threshold 2 reached → the next attempt is frozen without scrypt.
450
+ const frozen = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
451
+ expect(services.verifyCalls).toBe(2);
452
+ expect(sentTexts(frozen.effects)).toContain(
453
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is temporarily frozen due to too many failed identify attempts.',
454
+ );
455
+
456
+ // The configured 60s window ages the failures out sooner than default.
457
+ clock.advance(60_000);
458
+ const after = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
459
+ expect(services.verifyCalls).toBe(3);
460
+ expect(sentTexts(after.effects)).toContain(
461
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
462
+ );
463
+ });
464
+ });
465
+
286
466
  describe('nickservReducer — ID (alias for IDENTIFY)', () => {
287
467
  it('sets +r and account on correct password (ID <password>)', () => {
288
468
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
@@ -622,7 +802,7 @@ describe('nickservReducer — misc', () => {
622
802
  const out = nickservReducer(conn, privmsg('FROBNICATE foo bar'), ctx);
623
803
 
624
804
  expect(sentTexts(out.effects)).toContain(
625
- ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET',
805
+ ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
626
806
  );
627
807
  });
628
808
 
@@ -857,6 +1037,242 @@ describe('nickservReducer — SET ENFORCE', () => {
857
1037
  });
858
1038
  });
859
1039
 
1040
+ // ============================================================================
1041
+ // SET PASSWORD
1042
+ // ============================================================================
1043
+
1044
+ describe('nickservReducer — SET PASSWORD', () => {
1045
+ it('changes the password when the caller supplies the correct old one', () => {
1046
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1047
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1048
+ const conn = makeConn();
1049
+ conn.account = 'alice';
1050
+ conn.userModes.registered = true;
1051
+ const ctx = makeCtx(conn, services);
1052
+
1053
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1054
+
1055
+ expect(sentTexts(out.effects)).toContain(
1056
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1057
+ );
1058
+ // The new password verifies, the old one no longer does.
1059
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(true);
1060
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(false);
1061
+ });
1062
+
1063
+ it('rejects a wrong old password and leaves the credential unchanged', () => {
1064
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1065
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1066
+ const conn = makeConn();
1067
+ conn.account = 'alice';
1068
+ conn.userModes.registered = true;
1069
+ const ctx = makeCtx(conn, services);
1070
+
1071
+ const out = nickservReducer(conn, privmsg('SET PASSWORD wrongold newpassword'), ctx);
1072
+
1073
+ expect(sentTexts(out.effects)).toContain(
1074
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
1075
+ );
1076
+ // The original password still verifies; the new one does not.
1077
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1078
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
1079
+ });
1080
+
1081
+ it('requires identification before SET PASSWORD (unidentified caller)', () => {
1082
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1083
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1084
+ const conn = makeConn();
1085
+ // No account / +r.
1086
+ const ctx = makeCtx(conn, services);
1087
+
1088
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1089
+
1090
+ expect(sentTexts(out.effects)).toContain(
1091
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing your password.',
1092
+ );
1093
+ // Credential unchanged.
1094
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1095
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
1096
+ });
1097
+
1098
+ it('rejects SET PASSWORD when identified as a different account', () => {
1099
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1100
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1101
+ // bob is identified, but is using alice's nick.
1102
+ services.registerNick('bob', 'bobpw', 'bob@example.com');
1103
+ const conn = makeConn('c1', 'alice');
1104
+ conn.account = 'bob';
1105
+ conn.userModes.registered = true;
1106
+ const ctx = makeCtx(conn, services);
1107
+
1108
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1109
+
1110
+ expect(sentTexts(out.effects)).toContain(
1111
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing your password.',
1112
+ );
1113
+ // Credential unchanged.
1114
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1115
+ expect(services.verifyNick('alice', 'newpassword').ok).toBe(false);
1116
+ });
1117
+
1118
+ it('emits the syntax notice when args are missing', () => {
1119
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1120
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1121
+ const conn = makeConn();
1122
+ conn.account = 'alice';
1123
+ conn.userModes.registered = true;
1124
+ const ctx = makeCtx(conn, services);
1125
+
1126
+ const noArgs = nickservReducer(conn, privmsg('SET PASSWORD'), ctx);
1127
+ expect(sentTexts(noArgs.effects)).toContain(
1128
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET PASSWORD <old-password> <new-password>',
1129
+ );
1130
+
1131
+ const oneArg = nickservReducer(conn, privmsg('SET PASSWORD only-one'), ctx);
1132
+ expect(sentTexts(oneArg.effects)).toContain(
1133
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET PASSWORD <old-password> <new-password>',
1134
+ );
1135
+ // Credential unchanged.
1136
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1137
+ });
1138
+
1139
+ it('rejects a new password shorter than the default minimum (8)', () => {
1140
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1141
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1142
+ const conn = makeConn();
1143
+ conn.account = 'alice';
1144
+ conn.userModes.registered = true;
1145
+ const ctx = makeCtx(conn, services);
1146
+
1147
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 short'), ctx);
1148
+
1149
+ expect(sentTexts(out.effects)).toContain(
1150
+ ':NickServ!NickServ@services NOTICE alice :Password is too short (minimum 8 characters).',
1151
+ );
1152
+ // Credential unchanged.
1153
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1154
+ expect(services.verifyNick('alice', 'short').ok).toBe(false);
1155
+ });
1156
+
1157
+ it('honours a configured nickServ.minPasswordLength override', () => {
1158
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1159
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1160
+ const conn = makeConn();
1161
+ conn.account = 'alice';
1162
+ conn.userModes.registered = true;
1163
+ const ctx: Ctx = buildCtx({
1164
+ serverConfig: { ...serverConfig, nickServ: { minPasswordLength: 12 } },
1165
+ clock: new FakeClock(NOW),
1166
+ ids: new SequentialIdFactory(),
1167
+ motd: EmptyMotdProvider,
1168
+ connection: conn,
1169
+ services,
1170
+ });
1171
+
1172
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 elevenchars'), ctx);
1173
+
1174
+ expect(sentTexts(out.effects)).toContain(
1175
+ ':NickServ!NickServ@services NOTICE alice :Password is too short (minimum 12 characters).',
1176
+ );
1177
+ // Credential unchanged.
1178
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1179
+ });
1180
+
1181
+ it('rejects a new password longer than the maximum (256)', () => {
1182
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1183
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1184
+ const conn = makeConn();
1185
+ conn.account = 'alice';
1186
+ conn.userModes.registered = true;
1187
+ const ctx = makeCtx(conn, services);
1188
+
1189
+ const tooLong = 'x'.repeat(257);
1190
+ const out = nickservReducer(conn, privmsg(`SET PASSWORD hunter2 ${tooLong}`), ctx);
1191
+
1192
+ expect(sentTexts(out.effects)).toContain(
1193
+ ':NickServ!NickServ@services NOTICE alice :Password is too long (maximum 256 characters).',
1194
+ );
1195
+ // Credential unchanged.
1196
+ expect(services.verifyNick('alice', 'hunter2').ok).toBe(true);
1197
+ });
1198
+
1199
+ it('keeps the session +r after a successful change (no re-identify required)', () => {
1200
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1201
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1202
+ const conn = makeConn();
1203
+ conn.account = 'alice';
1204
+ conn.userModes.registered = true;
1205
+ const ctx = makeCtx(conn, services);
1206
+
1207
+ nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1208
+
1209
+ expect(conn.userModes.registered).toBe(true);
1210
+ expect(conn.account).toBe('alice');
1211
+ });
1212
+
1213
+ it('accepts a new password exactly at the minimum length boundary (8)', () => {
1214
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1215
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1216
+ const conn = makeConn();
1217
+ conn.account = 'alice';
1218
+ conn.userModes.registered = true;
1219
+ const ctx = makeCtx(conn, services);
1220
+
1221
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 exactly8'), ctx);
1222
+
1223
+ expect(sentTexts(out.effects)).toContain(
1224
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1225
+ );
1226
+ expect(services.verifyNick('alice', 'exactly8').ok).toBe(true);
1227
+ });
1228
+
1229
+ it('accepts a new password exactly at the maximum length boundary (256)', () => {
1230
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1231
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1232
+ const conn = makeConn();
1233
+ conn.account = 'alice';
1234
+ conn.userModes.registered = true;
1235
+ const ctx = makeCtx(conn, services);
1236
+
1237
+ const boundary = 'x'.repeat(256);
1238
+ const out = nickservReducer(conn, privmsg(`SET PASSWORD hunter2 ${boundary}`), ctx);
1239
+
1240
+ expect(sentTexts(out.effects)).toContain(
1241
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1242
+ );
1243
+ expect(services.verifyNick('alice', boundary).ok).toBe(true);
1244
+ });
1245
+
1246
+ it('is case-insensitive on the PASSWORD subkey', () => {
1247
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1248
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
1249
+ const conn = makeConn();
1250
+ conn.account = 'alice';
1251
+ conn.userModes.registered = true;
1252
+ const ctx = makeCtx(conn, services);
1253
+
1254
+ const out = nickservReducer(conn, privmsg('set password hunter2 newpassword'), ctx);
1255
+
1256
+ expect(sentTexts(out.effects)).toContain(
1257
+ ':NickServ!NickServ@services NOTICE alice :Password changed.',
1258
+ );
1259
+ });
1260
+
1261
+ it('rejects SET PASSWORD on an unregistered nick', () => {
1262
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
1263
+ const conn = makeConn();
1264
+ conn.account = 'alice';
1265
+ conn.userModes.registered = true;
1266
+ const ctx = makeCtx(conn, services);
1267
+
1268
+ const out = nickservReducer(conn, privmsg('SET PASSWORD hunter2 newpassword'), ctx);
1269
+
1270
+ expect(sentTexts(out.effects)).toContain(
1271
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is not registered.',
1272
+ );
1273
+ });
1274
+ });
1275
+
860
1276
  // ============================================================================
861
1277
  // enforceRegisteredNick — the enforcement hook fired by the NICK path
862
1278
  // ============================================================================