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
@@ -4,6 +4,7 @@ import {
4
4
  DEFAULT_CREATED_TEXT,
5
5
  ServerConfigSchema,
6
6
  formatCreatedAt,
7
+ narrowOperCred,
7
8
  parseServerConfig,
8
9
  resolveServerVersion,
9
10
  } from '../src/config';
@@ -327,7 +328,8 @@ describe('ServerConfigSchema — operCreds', () => {
327
328
  });
328
329
  expect(cfg.operCreds).toHaveLength(2);
329
330
  expect(cfg.operCreds[0]?.user).toBe('op1');
330
- expect(cfg.operCreds[1]?.password).toBe('p2');
331
+ const second = cfg.operCreds[1];
332
+ expect(second !== undefined && 'password' in second ? second.password : undefined).toBe('p2');
331
333
  });
332
334
 
333
335
  it('defaults operCreds to an empty array when omitted', () => {
@@ -384,6 +386,120 @@ describe('ServerConfigSchema — operCreds', () => {
384
386
  }),
385
387
  ).toThrowError(/operCreds/u);
386
388
  });
389
+
390
+ it('accepts the new hashed form {user, salt, hash}', () => {
391
+ const cfg = parseServerConfig({
392
+ serverName: 's',
393
+ networkName: 'n',
394
+ operCreds: [{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }],
395
+ });
396
+ expect(cfg.operCreds).toHaveLength(1);
397
+ expect(cfg.operCreds[0]).toEqual({
398
+ user: 'admin',
399
+ salt: 'c2FsdA==',
400
+ hash: 'aGFzaA==',
401
+ });
402
+ });
403
+
404
+ it('accepts a mix of legacy plaintext and hashed creds', () => {
405
+ const cfg = parseServerConfig({
406
+ serverName: 's',
407
+ networkName: 'n',
408
+ operCreds: [
409
+ { user: 'legacy', password: 'p' },
410
+ { user: 'hashed', salt: 'c2FsdA==', hash: 'aGFzaA==' },
411
+ ],
412
+ });
413
+ expect(cfg.operCreds).toHaveLength(2);
414
+ });
415
+
416
+ it('rejects a hashed cred missing salt', () => {
417
+ expect(() =>
418
+ parseServerConfig({
419
+ serverName: 's',
420
+ networkName: 'n',
421
+ operCreds: [{ user: 'op', hash: 'aGFzaA==' }],
422
+ }),
423
+ ).toThrowError(/salt/u);
424
+ });
425
+
426
+ it('rejects a hashed cred missing hash', () => {
427
+ expect(() =>
428
+ parseServerConfig({
429
+ serverName: 's',
430
+ networkName: 'n',
431
+ operCreds: [{ user: 'op', salt: 'c2FsdA==' }],
432
+ }),
433
+ ).toThrowError(/hash/u);
434
+ });
435
+
436
+ it('rejects a hashed cred with empty salt', () => {
437
+ expect(() =>
438
+ parseServerConfig({
439
+ serverName: 's',
440
+ networkName: 'n',
441
+ operCreds: [{ user: 'op', salt: '', hash: 'aGFzaA==' }],
442
+ }),
443
+ ).toThrowError(/salt/u);
444
+ });
445
+
446
+ it('rejects a hashed cred with empty hash', () => {
447
+ expect(() =>
448
+ parseServerConfig({
449
+ serverName: 's',
450
+ networkName: 'n',
451
+ operCreds: [{ user: 'op', salt: 'c2FsdA==', hash: '' }],
452
+ }),
453
+ ).toThrowError(/hash/u);
454
+ });
455
+
456
+ it('rejects a cred that mixes plaintext password and hashed salt/hash', () => {
457
+ expect(() =>
458
+ parseServerConfig({
459
+ serverName: 's',
460
+ networkName: 'n',
461
+ operCreds: [{ user: 'op', password: 'p', salt: 'c2FsdA==', hash: 'aGFzaA==' }],
462
+ }),
463
+ ).toThrowError(/password/u);
464
+ });
465
+
466
+ it('rejects a cred that mixes plaintext password with only salt', () => {
467
+ expect(() =>
468
+ parseServerConfig({
469
+ serverName: 's',
470
+ networkName: 'n',
471
+ operCreds: [{ user: 'op', password: 'p', salt: 'c2FsdA==' }],
472
+ }),
473
+ ).toThrowError(/password/u);
474
+ });
475
+ });
476
+
477
+ // ============================================================================
478
+ // narrowOperCred — loose parse output → OperCred union narrowing
479
+ // ============================================================================
480
+
481
+ describe('narrowOperCred', () => {
482
+ it('narrows to the plaintext shape when password is present', () => {
483
+ expect(narrowOperCred({ user: 'op', password: 'p' })).toEqual({
484
+ user: 'op',
485
+ password: 'p',
486
+ });
487
+ });
488
+
489
+ it('narrows to the hashed shape when salt + hash are present', () => {
490
+ expect(narrowOperCred({ user: 'op', salt: 's', hash: 'h' })).toEqual({
491
+ user: 'op',
492
+ salt: 's',
493
+ hash: 'h',
494
+ });
495
+ });
496
+
497
+ it('is total: a bare {user} input falls back to empty salt/hash (defensive only)', () => {
498
+ // Unreachable through parseServerConfig (the superRefine rejects a
499
+ // cred with no authentication fields); asserted so the defensive
500
+ // fallback branch is covered and the helper is total.
501
+ expect(narrowOperCred({ user: 'op' })).toEqual({ user: 'op', salt: '', hash: '' });
502
+ });
387
503
  });
388
504
 
389
505
  // ============================================================================
@@ -516,6 +632,41 @@ describe('ServerConfigSchema — limit knobs', () => {
516
632
  });
517
633
  });
518
634
 
635
+ // ============================================================================
636
+ // ServerConfigSchema — draft/multiline limits
637
+ // ============================================================================
638
+
639
+ describe('ServerConfigSchema — draft/multiline limits', () => {
640
+ it('defaults multilineMaxBytes to the IRCv3-recommended 4096', () => {
641
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
642
+ expect(cfg.multilineMaxBytes).toBe(4096);
643
+ });
644
+
645
+ it('defaults multilineMaxEntries to 100 (defense-in-depth entry cap)', () => {
646
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
647
+ expect(cfg.multilineMaxEntries).toBe(100);
648
+ });
649
+
650
+ it('accepts a custom multilineMaxEntries', () => {
651
+ const cfg = parseServerConfig({
652
+ serverName: 's',
653
+ networkName: 'n',
654
+ multilineMaxEntries: 250,
655
+ });
656
+ expect(cfg.multilineMaxEntries).toBe(250);
657
+ });
658
+
659
+ it('rejects non-positive multilineMaxEntries', () => {
660
+ expect(() =>
661
+ parseServerConfig({
662
+ serverName: 's',
663
+ networkName: 'n',
664
+ multilineMaxEntries: 0,
665
+ }),
666
+ ).toThrowError(/multilineMaxEntries/u);
667
+ });
668
+ });
669
+
519
670
  // ============================================================================
520
671
  // ServerConfigSchema — failure diagnostics
521
672
  // ============================================================================
@@ -830,6 +981,56 @@ describe('ServerConfigSchema — perIpConnectionRate', () => {
830
981
  });
831
982
  });
832
983
 
984
+ // ============================================================================
985
+ // ServerConfigSchema — oper (failed-OPER lockout tunables)
986
+ // ============================================================================
987
+
988
+ describe('ServerConfigSchema — oper lockout tunables', () => {
989
+ it('defaults oper.maxFailures to 3 and oper.failureWindowSeconds to 300 when omitted', () => {
990
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
991
+ expect(cfg.oper).toEqual({ maxFailures: 3, failureWindowSeconds: 300 });
992
+ });
993
+
994
+ it('accepts explicit positive integers for both fields', () => {
995
+ const cfg = parseServerConfig({
996
+ serverName: 's',
997
+ networkName: 'n',
998
+ oper: { maxFailures: 5, failureWindowSeconds: 60 },
999
+ });
1000
+ expect(cfg.oper).toEqual({ maxFailures: 5, failureWindowSeconds: 60 });
1001
+ });
1002
+
1003
+ it('rejects zero maxFailures', () => {
1004
+ expect(() =>
1005
+ parseServerConfig({
1006
+ serverName: 's',
1007
+ networkName: 'n',
1008
+ oper: { maxFailures: 0, failureWindowSeconds: 300 },
1009
+ }),
1010
+ ).toThrowError(/maxFailures/u);
1011
+ });
1012
+
1013
+ it('rejects a non-integer failureWindowSeconds', () => {
1014
+ expect(() =>
1015
+ parseServerConfig({
1016
+ serverName: 's',
1017
+ networkName: 'n',
1018
+ oper: { maxFailures: 3, failureWindowSeconds: 1.5 },
1019
+ }),
1020
+ ).toThrowError(/failureWindowSeconds/u);
1021
+ });
1022
+
1023
+ it('rejects a non-object oper value', () => {
1024
+ expect(() =>
1025
+ parseServerConfig({
1026
+ serverName: 's',
1027
+ networkName: 'n',
1028
+ oper: 'strict',
1029
+ }),
1030
+ ).toThrowError(/oper/u);
1031
+ });
1032
+ });
1033
+
833
1034
  // ============================================================================
834
1035
  // ServerConfigSchema — sts (Strict Transport Security)
835
1036
  // ============================================================================
@@ -909,6 +1110,56 @@ describe('ServerConfigSchema — sts', () => {
909
1110
  });
910
1111
  });
911
1112
 
1113
+ // ============================================================================
1114
+ // ServerConfigSchema — chathistory (limit ceiling)
1115
+ // ============================================================================
1116
+
1117
+ describe('ServerConfigSchema — chathistory limit ceiling', () => {
1118
+ it('defaults chathistory.maxLimit to 100 when omitted', () => {
1119
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
1120
+ expect(cfg.chathistory).toEqual({ maxLimit: 100 });
1121
+ });
1122
+
1123
+ it('accepts an explicit positive integer maxLimit', () => {
1124
+ const cfg = parseServerConfig({
1125
+ serverName: 's',
1126
+ networkName: 'n',
1127
+ chathistory: { maxLimit: 250 },
1128
+ });
1129
+ expect(cfg.chathistory?.maxLimit).toBe(250);
1130
+ });
1131
+
1132
+ it('rejects zero maxLimit', () => {
1133
+ expect(() =>
1134
+ parseServerConfig({
1135
+ serverName: 's',
1136
+ networkName: 'n',
1137
+ chathistory: { maxLimit: 0 },
1138
+ }),
1139
+ ).toThrowError(/maxLimit/u);
1140
+ });
1141
+
1142
+ it('rejects a non-integer maxLimit', () => {
1143
+ expect(() =>
1144
+ parseServerConfig({
1145
+ serverName: 's',
1146
+ networkName: 'n',
1147
+ chathistory: { maxLimit: 1.5 },
1148
+ }),
1149
+ ).toThrowError(/maxLimit/u);
1150
+ });
1151
+
1152
+ it('rejects a non-object chathistory value', () => {
1153
+ expect(() =>
1154
+ parseServerConfig({
1155
+ serverName: 's',
1156
+ networkName: 'n',
1157
+ chathistory: 'unlimited',
1158
+ }),
1159
+ ).toThrowError(/chathistory/u);
1160
+ });
1161
+ });
1162
+
912
1163
  // ============================================================================
913
1164
  // ServerConfigSchema — hostservAutoApproveVhosts
914
1165
  // ============================================================================
@@ -919,11 +1170,12 @@ describe('ServerConfigSchema — hostservAutoApproveVhosts', () => {
919
1170
  expect(cfg.hostservAutoApproveVhosts).toBe(false);
920
1171
  });
921
1172
 
922
- it('accepts an explicit true (auto-approve mode)', () => {
1173
+ it('accepts an explicit true (auto-approve mode) with a non-empty allowlist', () => {
923
1174
  const cfg = parseServerConfig({
924
1175
  serverName: 's',
925
1176
  networkName: 'n',
926
1177
  hostservAutoApproveVhosts: true,
1178
+ hostservAutoApproveAllowlist: ['users.example.com'],
927
1179
  });
928
1180
  expect(cfg.hostservAutoApproveVhosts).toBe(true);
929
1181
  });
@@ -937,6 +1189,73 @@ describe('ServerConfigSchema — hostservAutoApproveVhosts', () => {
937
1189
  expect(cfg.hostservAutoApproveVhosts).toBe(false);
938
1190
  });
939
1191
 
1192
+ it('refuses to boot auto-approve mode when the allowlist is omitted', () => {
1193
+ expect(() =>
1194
+ parseServerConfig({
1195
+ serverName: 's',
1196
+ networkName: 'n',
1197
+ hostservAutoApproveVhosts: true,
1198
+ }),
1199
+ ).toThrowError(/hostservAutoApproveAllowlist/u);
1200
+ });
1201
+
1202
+ it('refuses to boot auto-approve mode when the allowlist is empty', () => {
1203
+ expect(() =>
1204
+ parseServerConfig({
1205
+ serverName: 's',
1206
+ networkName: 'n',
1207
+ hostservAutoApproveVhosts: true,
1208
+ hostservAutoApproveAllowlist: [],
1209
+ }),
1210
+ ).toThrowError(/hostservAutoApproveAllowlist/u);
1211
+ });
1212
+
1213
+ it('accepts auto-approve mode when the allowlist is non-empty', () => {
1214
+ const cfg = parseServerConfig({
1215
+ serverName: 's',
1216
+ networkName: 'n',
1217
+ hostservAutoApproveVhosts: true,
1218
+ hostservAutoApproveAllowlist: ['users.example.com'],
1219
+ });
1220
+ expect(cfg.hostservAutoApproveVhosts).toBe(true);
1221
+ expect(cfg.hostservAutoApproveAllowlist).toEqual(['users.example.com']);
1222
+ });
1223
+
1224
+ it('defaults the allowlist to empty in queue mode (allowlist unconstrained)', () => {
1225
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
1226
+ expect(cfg.hostservAutoApproveAllowlist).toEqual([]);
1227
+ });
1228
+
1229
+ it('accepts a non-empty allowlist even in queue mode (unused but legal)', () => {
1230
+ const cfg = parseServerConfig({
1231
+ serverName: 's',
1232
+ networkName: 'n',
1233
+ hostservAutoApproveVhosts: false,
1234
+ hostservAutoApproveAllowlist: ['a.example.com', '*.b.example.com'],
1235
+ });
1236
+ expect(cfg.hostservAutoApproveAllowlist).toEqual(['a.example.com', '*.b.example.com']);
1237
+ });
1238
+
1239
+ it('rejects an allowlist entry that is not a non-empty string', () => {
1240
+ expect(() =>
1241
+ parseServerConfig({
1242
+ serverName: 's',
1243
+ networkName: 'n',
1244
+ hostservAutoApproveAllowlist: [''],
1245
+ }),
1246
+ ).toThrowError(/hostservAutoApproveAllowlist/u);
1247
+ });
1248
+
1249
+ it('rejects a non-array allowlist', () => {
1250
+ expect(() =>
1251
+ parseServerConfig({
1252
+ serverName: 's',
1253
+ networkName: 'n',
1254
+ hostservAutoApproveAllowlist: 'users.example.com',
1255
+ }),
1256
+ ).toThrowError(/hostservAutoApproveAllowlist/u);
1257
+ });
1258
+
940
1259
  it('rejects a non-boolean value', () => {
941
1260
  expect(() =>
942
1261
  parseServerConfig({
@@ -947,3 +1266,216 @@ describe('ServerConfigSchema — hostservAutoApproveVhosts', () => {
947
1266
  ).toThrowError(/hostservAutoApproveVhosts/u);
948
1267
  });
949
1268
  });
1269
+
1270
+ // ============================================================================
1271
+ // ServerConfigSchema — sasl (per-connection failure lockout tunables)
1272
+ // ============================================================================
1273
+
1274
+ describe('ServerConfigSchema — sasl', () => {
1275
+ it('defaults sasl to undefined when omitted (reducer falls back to 3 / 60)', () => {
1276
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
1277
+ expect(cfg.sasl).toBeUndefined();
1278
+ });
1279
+
1280
+ it('accepts an explicit sasl config with maxFailures + failureWindowSeconds', () => {
1281
+ const cfg = parseServerConfig({
1282
+ serverName: 's',
1283
+ networkName: 'n',
1284
+ sasl: { maxFailures: 5, failureWindowSeconds: 120 },
1285
+ });
1286
+ expect(cfg.sasl).toEqual({ maxFailures: 5, failureWindowSeconds: 120 });
1287
+ });
1288
+
1289
+ it('accepts a partial sasl config (each knob is independently optional)', () => {
1290
+ const cfg = parseServerConfig({
1291
+ serverName: 's',
1292
+ networkName: 'n',
1293
+ sasl: { maxFailures: 2 },
1294
+ });
1295
+ expect(cfg.sasl).toEqual({ maxFailures: 2 });
1296
+ });
1297
+
1298
+ it('leaves externalEnabled unset when the sasl section is absent (EXTERNAL off)', () => {
1299
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
1300
+ expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
1301
+ });
1302
+
1303
+ it('accepts sasl.externalEnabled true (operators opt in to SASL EXTERNAL)', () => {
1304
+ const cfg = parseServerConfig({
1305
+ serverName: 's',
1306
+ networkName: 'n',
1307
+ sasl: { externalEnabled: true },
1308
+ });
1309
+ expect(cfg.sasl?.externalEnabled).toBe(true);
1310
+ });
1311
+
1312
+ it('leaves externalEnabled unset within a partial sasl section (other knobs alone do not enable it)', () => {
1313
+ const cfg = parseServerConfig({
1314
+ serverName: 's',
1315
+ networkName: 'n',
1316
+ sasl: { maxFailures: 5, failureWindowSeconds: 120 },
1317
+ });
1318
+ expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
1319
+ });
1320
+
1321
+ it('accepts sasl.externalEnabled false (explicit opt-out)', () => {
1322
+ const cfg = parseServerConfig({
1323
+ serverName: 's',
1324
+ networkName: 'n',
1325
+ sasl: { externalEnabled: false },
1326
+ });
1327
+ expect(cfg.sasl?.externalEnabled).toBe(false);
1328
+ });
1329
+
1330
+ it('rejects a non-boolean externalEnabled', () => {
1331
+ expect(() =>
1332
+ parseServerConfig({ serverName: 's', networkName: 'n', sasl: { externalEnabled: 'yes' } }),
1333
+ ).toThrowError(/externalEnabled/u);
1334
+ });
1335
+
1336
+ it('accepts sasl.externalUnsupportedMessage (transport-level EXTERNAL non-support)', () => {
1337
+ const cfg = parseServerConfig({
1338
+ serverName: 's',
1339
+ networkName: 'n',
1340
+ sasl: { externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport' },
1341
+ });
1342
+ expect(cfg.sasl?.externalUnsupportedMessage).toBe(
1343
+ 'SASL EXTERNAL not supported on this transport',
1344
+ );
1345
+ });
1346
+
1347
+ it('rejects an empty-string externalUnsupportedMessage', () => {
1348
+ expect(() =>
1349
+ parseServerConfig({
1350
+ serverName: 's',
1351
+ networkName: 'n',
1352
+ sasl: { externalUnsupportedMessage: '' },
1353
+ }),
1354
+ ).toThrowError(/externalUnsupportedMessage/u);
1355
+ });
1356
+
1357
+ it('rejects a non-string externalUnsupportedMessage', () => {
1358
+ expect(() =>
1359
+ parseServerConfig({
1360
+ serverName: 's',
1361
+ networkName: 'n',
1362
+ sasl: { externalUnsupportedMessage: 42 },
1363
+ }),
1364
+ ).toThrowError(/externalUnsupportedMessage/u);
1365
+ });
1366
+
1367
+ it('rejects a zero maxFailures', () => {
1368
+ expect(() =>
1369
+ parseServerConfig({ serverName: 's', networkName: 'n', sasl: { maxFailures: 0 } }),
1370
+ ).toThrowError(/sasl/u);
1371
+ });
1372
+
1373
+ it('rejects a zero failureWindowSeconds', () => {
1374
+ expect(() =>
1375
+ parseServerConfig({ serverName: 's', networkName: 'n', sasl: { failureWindowSeconds: 0 } }),
1376
+ ).toThrowError(/sasl/u);
1377
+ });
1378
+
1379
+ it('rejects a non-number maxFailures', () => {
1380
+ expect(() =>
1381
+ parseServerConfig({ serverName: 's', networkName: 'n', sasl: { maxFailures: '3' } }),
1382
+ ).toThrowError(/sasl/u);
1383
+ });
1384
+
1385
+ it('rejects a non-object sasl value', () => {
1386
+ expect(() =>
1387
+ parseServerConfig({ serverName: 's', networkName: 'n', sasl: 'PLAIN' }),
1388
+ ).toThrowError(/sasl/u);
1389
+ });
1390
+ });
1391
+
1392
+ // ============================================================================
1393
+ // ServerConfigSchema — nickServ identify-freeze knobs
1394
+ // ============================================================================
1395
+
1396
+ describe('ServerConfigSchema — nickServ identify freeze', () => {
1397
+ it('leaves the freeze knobs unset when omitted (reducer applies defaults)', () => {
1398
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
1399
+ expect(cfg.nickServ?.maxIdentifyFailures).toBeUndefined();
1400
+ expect(cfg.nickServ?.identifyFreezeSeconds).toBeUndefined();
1401
+ });
1402
+
1403
+ it('accepts explicit maxIdentifyFailures + identifyFreezeSeconds', () => {
1404
+ const cfg = parseServerConfig({
1405
+ serverName: 's',
1406
+ networkName: 'n',
1407
+ nickServ: { maxIdentifyFailures: 3, identifyFreezeSeconds: 120 },
1408
+ });
1409
+ expect(cfg.nickServ?.maxIdentifyFailures).toBe(3);
1410
+ expect(cfg.nickServ?.identifyFreezeSeconds).toBe(120);
1411
+ });
1412
+
1413
+ it('rejects a non-positive maxIdentifyFailures', () => {
1414
+ expect(() =>
1415
+ parseServerConfig({
1416
+ serverName: 's',
1417
+ networkName: 'n',
1418
+ nickServ: { maxIdentifyFailures: 0 },
1419
+ }),
1420
+ ).toThrowError(/maxIdentifyFailures/u);
1421
+ });
1422
+
1423
+ it('rejects a non-integer identifyFreezeSeconds', () => {
1424
+ expect(() =>
1425
+ parseServerConfig({
1426
+ serverName: 's',
1427
+ networkName: 'n',
1428
+ nickServ: { identifyFreezeSeconds: 1.5 },
1429
+ }),
1430
+ ).toThrowError(/identifyFreezeSeconds/u);
1431
+ });
1432
+ });
1433
+
1434
+ // ============================================================================
1435
+ // ServerConfigSchema — adapter inbound frame-rate limit
1436
+ // ============================================================================
1437
+
1438
+ describe('ServerConfigSchema — adapter frame-rate limit', () => {
1439
+ it('defaults the adapter frame window to 50 frames / 5 seconds', () => {
1440
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
1441
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
1442
+ });
1443
+
1444
+ it('accepts explicit adapter frame-window overrides', () => {
1445
+ const cfg = parseServerConfig({
1446
+ serverName: 's',
1447
+ networkName: 'n',
1448
+ adapter: { maxFramesPerWindow: 10, frameWindowSeconds: 2 },
1449
+ });
1450
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 10, frameWindowSeconds: 2 });
1451
+ });
1452
+
1453
+ it('fills a partially-supplied adapter section with per-field defaults', () => {
1454
+ const cfg = parseServerConfig({
1455
+ serverName: 's',
1456
+ networkName: 'n',
1457
+ adapter: { frameWindowSeconds: 30 },
1458
+ });
1459
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 30 });
1460
+ });
1461
+
1462
+ it('rejects a non-positive maxFramesPerWindow', () => {
1463
+ expect(() =>
1464
+ parseServerConfig({
1465
+ serverName: 's',
1466
+ networkName: 'n',
1467
+ adapter: { maxFramesPerWindow: 0 },
1468
+ }),
1469
+ ).toThrowError(/maxFramesPerWindow/u);
1470
+ });
1471
+
1472
+ it('rejects a non-integer frameWindowSeconds', () => {
1473
+ expect(() =>
1474
+ parseServerConfig({
1475
+ serverName: 's',
1476
+ networkName: 'n',
1477
+ adapter: { frameWindowSeconds: 1.5 },
1478
+ }),
1479
+ ).toThrowError(/frameWindowSeconds/u);
1480
+ });
1481
+ });
@@ -95,6 +95,18 @@ describe('Effect constructors', () => {
95
95
  });
96
96
  });
97
97
 
98
+ it('BroadcastOperNotice carries lines and optional except', () => {
99
+ expect(Effect.broadcastOperNotice([line(':s NOTICE * :lockout')])).toEqual<EffectType>({
100
+ tag: 'BroadcastOperNotice',
101
+ lines: [{ text: ':s NOTICE * :lockout' }],
102
+ });
103
+ expect(Effect.broadcastOperNotice([line(':s NOTICE * :lockout')], 'c1')).toEqual<EffectType>({
104
+ tag: 'BroadcastOperNotice',
105
+ lines: [{ text: ':s NOTICE * :lockout' }],
106
+ except: 'c1',
107
+ });
108
+ });
109
+
98
110
  it('EnforceNick carries conn, nick, policy, and graceMs', () => {
99
111
  expect(Effect.enforceNick('c1', 'alice', 'kill', 0)).toEqual<EffectType>({
100
112
  tag: 'EnforceNick',
@@ -125,6 +137,7 @@ describe('Effect tag set', () => {
125
137
  'ApplyChannelDelta',
126
138
  'SendToNick',
127
139
  'BroadcastWallops',
140
+ 'BroadcastOperNotice',
128
141
  'EnforceNick',
129
142
  ];
130
143
 
@@ -139,6 +152,7 @@ describe('Effect tag set', () => {
139
152
  ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
140
153
  SendToNick: Effect.sendToNick('n', 'c', []),
141
154
  BroadcastWallops: Effect.broadcastWallops([]),
155
+ BroadcastOperNotice: Effect.broadcastOperNotice([]),
142
156
  EnforceNick: Effect.enforceNick('c', 'n', 'none', 0),
143
157
  };
144
158
  expect(constructors[tag].tag).toBe(tag);
@@ -286,13 +286,41 @@ describe('wrapWithFloodControl — generic over inner state', () => {
286
286
 
287
287
  describe('wrapWithFloodControl — per-command cost (commandCost)', () => {
288
288
  it('defaultCommandCost returns 0 for the control-plane exempt set and 1 otherwise', () => {
289
- for (const command of ['PING', 'PONG', 'CAP', 'AUTHENTICATE', 'QUIT']) {
289
+ for (const command of ['PING', 'PONG', 'CAP', 'QUIT']) {
290
290
  expect(defaultCommandCost(command)).toBe(0);
291
291
  }
292
292
  expect(defaultCommandCost('PRIVMSG')).toBe(1);
293
293
  expect(defaultCommandCost('JOIN')).toBe(1);
294
294
  });
295
295
 
296
+ it('AUTHENTICATE is NOT exempt — it consumes the default flood bucket', () => {
297
+ // SASL brute-force hardening: an attacker spamming AUTHENTICATE must
298
+ // not get a free pass. The per-connection SASL failure tracker in the
299
+ // SASL reducer is the second, orthogonal defense.
300
+ expect(defaultCommandCost('AUTHENTICATE')).toBe(1);
301
+ });
302
+
303
+ it('an AUTHENTICATE burst drains the bucket like PRIVMSG would', () => {
304
+ const state = makeState();
305
+ const clock = new FakeClock(1_000);
306
+ const ctx = makeCtx(state, clock);
307
+ const inner = countingReducer();
308
+ const wrapped = wrapWithFloodControl(inner.reducer, {
309
+ capacity: 2,
310
+ refillRatePerSecond: 1,
311
+ disconnectThreshold: 0,
312
+ commandCost: defaultCommandCost,
313
+ });
314
+
315
+ wrapped(state, ircMessage('AUTHENTICATE', ['PLAIN']), ctx);
316
+ wrapped(state, ircMessage('AUTHENTICATE', ['dGVzdA==']), ctx);
317
+ expect(inner.getCalls()).toBe(2);
318
+
319
+ const out = wrapped(state, ircMessage('AUTHENTICATE', ['*']), ctx);
320
+ expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c1', EXCESS_FLOOD_REASON)]);
321
+ expect(inner.getCalls()).toBe(2);
322
+ });
323
+
296
324
  it('delegates a cost-0 PING to the inner reducer even when the bucket is exhausted', () => {
297
325
  const state = makeState();
298
326
  const clock = new FakeClock(1_000);