serverless-ircd 0.10.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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { handleJoinZero, isValidChannelName, joinReducer } from '../../src/commands/join';
2
+ import { handleJoinZero, joinReducer } from '../../src/commands/join';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
5
  import {
@@ -12,6 +12,7 @@ import {
12
12
  type ServicesStore,
13
13
  type StoredMessage,
14
14
  } from '../../src/ports';
15
+ import { isValidChannelName } from '../../src/protocol/channel-name';
15
16
  import { type ChannelState, createChannel } from '../../src/state/channel';
16
17
  import { type ConnectionState, createConnection } from '../../src/state/connection';
17
18
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
@@ -107,6 +108,30 @@ describe('isValidChannelName', () => {
107
108
  expect(isValidChannelName('#foo:bar', 50)).toBe(false);
108
109
  });
109
110
 
111
+ it('rejects a name containing NUL', () => {
112
+ expect(isValidChannelName('#foo\0bar', 50)).toBe(false);
113
+ });
114
+
115
+ it('rejects a name containing a control character', () => {
116
+ expect(isValidChannelName('#foo\x01bar', 50)).toBe(false);
117
+ });
118
+
119
+ it('rejects a multi-byte name whose UTF-8 byte size exceeds the cap', () => {
120
+ expect(isValidChannelName(`#${'é'.repeat(25)}`, 50)).toBe(false); // 51 bytes, 26 UTF-16 units
121
+ });
122
+
123
+ it('accepts a multi-byte name at exactly the UTF-8 byte cap', () => {
124
+ expect(isValidChannelName(`#${'é'.repeat(24)}x`, 50)).toBe(true); // 1 + 48 + 1 = 50 bytes
125
+ });
126
+
127
+ it('continues to accept ! in a channel name', () => {
128
+ expect(isValidChannelName('#foo!bar', 50)).toBe(true);
129
+ });
130
+
131
+ it('continues to accept @ in a channel name', () => {
132
+ expect(isValidChannelName('#foo@bar', 50)).toBe(true);
133
+ });
134
+
110
135
  it('rejects a name exceeding the length cap', () => {
111
136
  expect(isValidChannelName(`#${'a'.repeat(50)}`, 50)).toBe(false);
112
137
  });
@@ -395,6 +420,58 @@ describe('joinReducer — rejections', () => {
395
420
  ]);
396
421
  });
397
422
 
423
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
424
+ const chan = makeChan('#foo');
425
+ const conn = makeConn();
426
+ const ctx = makeCtx(conn);
427
+
428
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo\0bar'], tags: {} }, ctx);
429
+
430
+ expect(out.effects).toEqual<EffectType[]>([
431
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
432
+ ]);
433
+ expect(out.state.members.has('c1')).toBe(false);
434
+ });
435
+
436
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
437
+ const chan = makeChan('#foo');
438
+ const conn = makeConn();
439
+ const ctx = makeCtx(conn);
440
+
441
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo\x01bar'], tags: {} }, ctx);
442
+
443
+ expect(out.effects).toEqual<EffectType[]>([
444
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo\x01bar :No such channel')]),
445
+ ]);
446
+ expect(out.state.members.has('c1')).toBe(false);
447
+ });
448
+
449
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
450
+ const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
451
+ const chan = makeChan(long);
452
+ const conn = makeConn();
453
+ const ctx = makeCtx(conn);
454
+
455
+ const out = joinReducer(chan, { command: 'JOIN', params: [long], tags: {} }, ctx);
456
+
457
+ expect(out.effects).toEqual<EffectType[]>([
458
+ Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
459
+ ]);
460
+ expect(out.state.members.has('c1')).toBe(false);
461
+ });
462
+
463
+ it('joins a multi-byte channel name at exactly the 50-byte cap', () => {
464
+ const name = `#${'é'.repeat(24)}x`; // 1 + 48 + 1 = 50 UTF-8 bytes
465
+ const chan = makeChan(name);
466
+ const conn = makeConn();
467
+ const ctx = makeCtx(conn);
468
+
469
+ const out = joinReducer(chan, { command: 'JOIN', params: [name], tags: {} }, ctx);
470
+
471
+ expect(out.state.members.has('c1')).toBe(true);
472
+ expect(conn.joinedChannels.has(name.toLowerCase())).toBe(true);
473
+ });
474
+
398
475
  it('emits 405 ERR_TOOMANYCHANNELS when the connection is at the max-channels limit', () => {
399
476
  const chan = makeChan('#newchan');
400
477
  const conn = makeConn();
@@ -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) });