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
@@ -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
+ });
@@ -1,11 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  type HashedAccountCredential,
4
- HashedAccountStore,
5
4
  hashAccountCredential,
6
5
  verifyHashedPassword,
7
6
  } from '../src/credential-hashing';
8
- import type { AccountStore, SaslPayload } from '../src/ports';
9
7
 
10
8
  describe('hashAccountCredential', () => {
11
9
  it('produces an entry keyed by the username with scrypt', () => {
@@ -92,79 +90,3 @@ describe('verifyHashedPassword', () => {
92
90
  expect(verifyHashedPassword('s3cret', entry)).toBe(false);
93
91
  });
94
92
  });
95
-
96
- describe('HashedAccountStore', () => {
97
- function plain(username: string, password: string): SaslPayload {
98
- return { kind: 'PLAIN', username, password };
99
- }
100
-
101
- it('implements the AccountStore port', () => {
102
- const store: AccountStore = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
103
- expect(store).toBeDefined();
104
- });
105
-
106
- it('accepts valid PLAIN credentials and returns the account name', () => {
107
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
108
- expect(store.verify('PLAIN', plain('alice', 's3cret'))).toEqual({
109
- ok: true,
110
- account: 'alice',
111
- });
112
- });
113
-
114
- it('rejects a wrong password', () => {
115
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
116
- expect(store.verify('PLAIN', plain('alice', 'wrong')).ok).toBe(false);
117
- });
118
-
119
- it('rejects an unknown username', () => {
120
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
121
- expect(store.verify('PLAIN', plain('bob', 's3cret')).ok).toBe(false);
122
- });
123
-
124
- it('rejects a non-PLAIN mechanism', () => {
125
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
126
- expect(store.verify('EXTERNAL', plain('alice', 's3cret')).ok).toBe(false);
127
- });
128
-
129
- it('rejects a RAW payload', () => {
130
- const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
131
- expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
132
- });
133
-
134
- it('handles multiple loaded accounts', () => {
135
- const store = new HashedAccountStore([
136
- hashAccountCredential('alice', 'a-secret'),
137
- hashAccountCredential('bob', 'b-secret'),
138
- ]);
139
- expect(store.verify('PLAIN', plain('alice', 'a-secret'))).toEqual({
140
- ok: true,
141
- account: 'alice',
142
- });
143
- expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual({
144
- ok: true,
145
- account: 'bob',
146
- });
147
- });
148
-
149
- it('fails every verify when constructed with no entries', () => {
150
- const store = new HashedAccountStore([]);
151
- expect(store.verify('PLAIN', plain('alice', 's3cret')).ok).toBe(false);
152
- });
153
-
154
- it('reports the number of loaded entries via size', () => {
155
- const store = new HashedAccountStore([
156
- hashAccountCredential('alice', 'a'),
157
- hashAccountCredential('bob', 'b'),
158
- ]);
159
- expect(store.size).toBe(2);
160
- });
161
-
162
- it('round-trips an externally-constructed HashedAccountCredential', () => {
163
- const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
164
- const store = new HashedAccountStore([row]);
165
- expect(store.verify('PLAIN', plain('carol', 'p@ss'))).toEqual({
166
- ok: true,
167
- account: 'carol',
168
- });
169
- });
170
- });