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
@@ -37,6 +37,7 @@ const queueServerConfig: ServerConfig = {
37
37
  const autoApproveServerConfig: ServerConfig = {
38
38
  ...serverConfig,
39
39
  hostservAutoApproveVhosts: true,
40
+ hostservAutoApproveAllowlist: ['cool.example.net', 'one.example.net', 'two.example.net'],
40
41
  };
41
42
 
42
43
  function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
@@ -548,6 +549,321 @@ describe('hostservReducer — REQUEST (auto-approve knob explicitly on)', () =>
548
549
  });
549
550
  });
550
551
 
552
+ // ============================================================================
553
+ // REQUEST — auto-approve hardening (denylist + allowlist)
554
+ // ============================================================================
555
+
556
+ describe('hostservReducer — REQUEST (auto-approve denylist)', () => {
557
+ /**
558
+ * Auto-approve config whose allowlist deliberately contains every probed
559
+ * vhost: each test proves the built-in denylist is enforced regardless
560
+ * of (and ahead of) the allowlist.
561
+ */
562
+ const denyConfig: ServerConfig = {
563
+ ...serverConfig,
564
+ hostservAutoApproveVhosts: true,
565
+ hostservAutoApproveAllowlist: [
566
+ 'admin.example.net',
567
+ 'oper.example.net',
568
+ 'staff.example.net',
569
+ 'irc.example.net',
570
+ 'www.example.net',
571
+ 'root.example.net',
572
+ 'security.example.net',
573
+ 'user.admin.example.net',
574
+ 'example.net.staff',
575
+ 'ADMIN.example.net',
576
+ 'hub.example.net',
577
+ 'user.hub.example.net',
578
+ 'clean.example.net',
579
+ ],
580
+ };
581
+
582
+ /** Same hardening config on a serverName free of reserved labels, so the
583
+ * own-hostname suffix check is probed in isolation. */
584
+ const suffixConfig: ServerConfig = { ...denyConfig, serverName: 'hub.example.net' };
585
+
586
+ it.each(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security'])(
587
+ 'denies a vhost whose first label is the reserved label %s (even when allowlisted)',
588
+ (label) => {
589
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
590
+ const conn = identifiedConn('alice');
591
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
592
+
593
+ const out = hostservReducer(conn, privmsg(`REQUEST ${label}.example.net`), ctx);
594
+
595
+ expect(services.getVhost('alice')).toBeUndefined();
596
+ expect(sentTexts(out.effects)).toContain(
597
+ `:HostServ!HostServ@services NOTICE alice :Vhost ${label}.example.net is not permitted (reserved label or this server's hostname).`,
598
+ );
599
+ },
600
+ );
601
+
602
+ it('denies a vhost with a reserved label in a middle position', () => {
603
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
604
+ const conn = identifiedConn('alice');
605
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
606
+
607
+ const out = hostservReducer(conn, privmsg('REQUEST user.admin.example.net'), ctx);
608
+
609
+ expect(services.getVhost('alice')).toBeUndefined();
610
+ expect(sentTexts(out.effects)).toContain(
611
+ ":HostServ!HostServ@services NOTICE alice :Vhost user.admin.example.net is not permitted (reserved label or this server's hostname).",
612
+ );
613
+ });
614
+
615
+ it('denies a vhost with a reserved label in the last position', () => {
616
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
617
+ const conn = identifiedConn('alice');
618
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
619
+
620
+ const out = hostservReducer(conn, privmsg('REQUEST example.net.staff'), ctx);
621
+
622
+ expect(services.getVhost('alice')).toBeUndefined();
623
+ expect(sentTexts(out.effects)).toContain(
624
+ ":HostServ!HostServ@services NOTICE alice :Vhost example.net.staff is not permitted (reserved label or this server's hostname).",
625
+ );
626
+ });
627
+
628
+ it('matches reserved labels case-insensitively', () => {
629
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
630
+ const conn = identifiedConn('alice');
631
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), denyConfig);
632
+
633
+ const out = hostservReducer(conn, privmsg('REQUEST ADMIN.example.net'), ctx);
634
+
635
+ expect(services.getVhost('alice')).toBeUndefined();
636
+ expect(sentTexts(out.effects)).toContain(
637
+ ":HostServ!HostServ@services NOTICE alice :Vhost ADMIN.example.net is not permitted (reserved label or this server's hostname).",
638
+ );
639
+ });
640
+
641
+ it("denies the server's own hostname exactly (even when allowlisted)", () => {
642
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
643
+ const conn = identifiedConn('alice');
644
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), suffixConfig);
645
+
646
+ const out = hostservReducer(conn, privmsg('REQUEST hub.example.net'), ctx);
647
+
648
+ expect(services.getVhost('alice')).toBeUndefined();
649
+ expect(sentTexts(out.effects)).toContain(
650
+ ":HostServ!HostServ@services NOTICE alice :Vhost hub.example.net is not permitted (reserved label or this server's hostname).",
651
+ );
652
+ });
653
+
654
+ it("denies a vhost ending with the server's own hostname", () => {
655
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
656
+ const conn = identifiedConn('alice');
657
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), suffixConfig);
658
+
659
+ const out = hostservReducer(conn, privmsg('REQUEST user.hub.example.net'), ctx);
660
+
661
+ expect(services.getVhost('alice')).toBeUndefined();
662
+ expect(sentTexts(out.effects)).toContain(
663
+ ":HostServ!HostServ@services NOTICE alice :Vhost user.hub.example.net is not permitted (reserved label or this server's hostname).",
664
+ );
665
+ });
666
+
667
+ it("denies the server's own hostname case-insensitively", () => {
668
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
669
+ const conn = identifiedConn('alice');
670
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), {
671
+ ...suffixConfig,
672
+ serverName: 'HUB.example.net',
673
+ });
674
+
675
+ hostservReducer(conn, privmsg('REQUEST hub.example.net'), ctx);
676
+
677
+ expect(services.getVhost('alice')).toBeUndefined();
678
+ });
679
+ });
680
+
681
+ describe('hostservReducer — REQUEST (auto-approve allowlist)', () => {
682
+ const allowConfig: ServerConfig = {
683
+ ...serverConfig,
684
+ hostservAutoApproveVhosts: true,
685
+ hostservAutoApproveAllowlist: ['users.example.net', '*.vhosts.example.net'],
686
+ };
687
+
688
+ it('approves a vhost that exactly matches an allowlist entry', () => {
689
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
690
+ const conn = identifiedConn('alice');
691
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
692
+
693
+ const out = hostservReducer(conn, privmsg('REQUEST users.example.net'), ctx);
694
+
695
+ expect(services.getVhost('alice')).toBe('users.example.net');
696
+ expect(sentTexts(out.effects)).toContain(
697
+ ':HostServ!HostServ@services NOTICE alice :Vhost users.example.net has been requested and approved.',
698
+ );
699
+ });
700
+
701
+ it('matches an exact allowlist entry case-insensitively', () => {
702
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
703
+ const conn = identifiedConn('alice');
704
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
705
+
706
+ hostservReducer(conn, privmsg('REQUEST USERS.Example.NET'), ctx);
707
+
708
+ expect(services.getVhost('alice')).toBe('USERS.Example.NET');
709
+ });
710
+
711
+ it('denies a vhost with no allowlist entry (nothing recorded)', () => {
712
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
713
+ const conn = identifiedConn('alice');
714
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
715
+
716
+ const out = hostservReducer(conn, privmsg('REQUEST other.example.net'), ctx);
717
+
718
+ expect(services.getVhost('alice')).toBeUndefined();
719
+ expect(services.getPendingVhost('alice')).toBeUndefined();
720
+ expect(sentTexts(out.effects)).toContain(
721
+ ':HostServ!HostServ@services NOTICE alice :Vhost other.example.net is not in the auto-approve allowlist.',
722
+ );
723
+ });
724
+
725
+ it('approves a vhost covered by a wildcard entry (*.suffix)', () => {
726
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
727
+ const conn = identifiedConn('alice');
728
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
729
+
730
+ const out = hostservReducer(conn, privmsg('REQUEST a.vhosts.example.net'), ctx);
731
+
732
+ expect(services.getVhost('alice')).toBe('a.vhosts.example.net');
733
+ expect(sentTexts(out.effects)).toContain(
734
+ ':HostServ!HostServ@services NOTICE alice :Vhost a.vhosts.example.net has been requested and approved.',
735
+ );
736
+ });
737
+
738
+ it('approves a vhost at any depth beneath a wildcard entry', () => {
739
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
740
+ const conn = identifiedConn('alice');
741
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
742
+
743
+ hostservReducer(conn, privmsg('REQUEST x.y.vhosts.example.net'), ctx);
744
+
745
+ expect(services.getVhost('alice')).toBe('x.y.vhosts.example.net');
746
+ });
747
+
748
+ it('matches a wildcard entry case-insensitively', () => {
749
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
750
+ const conn = identifiedConn('alice');
751
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
752
+
753
+ hostservReducer(conn, privmsg('REQUEST A.VHOSTS.Example.NET'), ctx);
754
+
755
+ expect(services.getVhost('alice')).toBe('A.VHOSTS.Example.NET');
756
+ });
757
+
758
+ it('does NOT match the bare suffix of a wildcard entry', () => {
759
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
760
+ const conn = identifiedConn('alice');
761
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), allowConfig);
762
+
763
+ const out = hostservReducer(conn, privmsg('REQUEST vhosts.example.net'), ctx);
764
+
765
+ expect(services.getVhost('alice')).toBeUndefined();
766
+ expect(sentTexts(out.effects)).toContain(
767
+ ':HostServ!HostServ@services NOTICE alice :Vhost vhosts.example.net is not in the auto-approve allowlist.',
768
+ );
769
+ });
770
+
771
+ it('checks every allowlist entry until one matches', () => {
772
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
773
+ const conn = identifiedConn('alice');
774
+ const cfg: ServerConfig = {
775
+ ...serverConfig,
776
+ hostservAutoApproveVhosts: true,
777
+ hostservAutoApproveAllowlist: ['first.example.net', 'users.example.net'],
778
+ };
779
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), cfg);
780
+
781
+ hostservReducer(conn, privmsg('REQUEST users.example.net'), ctx);
782
+
783
+ expect(services.getVhost('alice')).toBe('users.example.net');
784
+ });
785
+
786
+ it('fails closed when the allowlist is missing entirely (inline literal)', () => {
787
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
788
+ const conn = identifiedConn('alice');
789
+ // A booted server can never reach this shape (config parsing refuses
790
+ // auto-approve without an allowlist); the reducer must still deny
791
+ // everything rather than approve everything.
792
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), {
793
+ ...serverConfig,
794
+ hostservAutoApproveVhosts: true,
795
+ });
796
+
797
+ const out = hostservReducer(conn, privmsg('REQUEST clean.example.net'), ctx);
798
+
799
+ expect(services.getVhost('alice')).toBeUndefined();
800
+ expect(sentTexts(out.effects)).toContain(
801
+ ':HostServ!HostServ@services NOTICE alice :Vhost clean.example.net is not in the auto-approve allowlist.',
802
+ );
803
+ });
804
+ });
805
+
806
+ describe('hostservReducer — hardening does not apply to oper review', () => {
807
+ it('queues a reserved-label vhost in queue mode (opers vet it)', () => {
808
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
809
+ const conn = identifiedConn('alice');
810
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), queueServerConfig);
811
+
812
+ const out = hostservReducer(conn, privmsg('REQUEST admin.example.net'), ctx);
813
+
814
+ expect(services.getPendingVhost('alice')).toBe('admin.example.net');
815
+ expect(services.getVhost('alice')).toBeUndefined();
816
+ expect(sentTexts(out.effects)).toContain(
817
+ ':HostServ!HostServ@services NOTICE alice :Vhost admin.example.net is pending oper approval.',
818
+ );
819
+ });
820
+
821
+ it('queues a vhost outside the allowlist in queue mode', () => {
822
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
823
+ const conn = identifiedConn('alice');
824
+ const cfg: ServerConfig = {
825
+ ...queueServerConfig,
826
+ hostservAutoApproveAllowlist: ['users.example.net'],
827
+ };
828
+ const ctx = makeCtx(conn, services, new FakeClock(NOW), cfg);
829
+
830
+ const out = hostservReducer(conn, privmsg('REQUEST other.example.net'), ctx);
831
+
832
+ expect(services.getPendingVhost('alice')).toBe('other.example.net');
833
+ expect(sentTexts(out.effects)).toContain(
834
+ ':HostServ!HostServ@services NOTICE alice :Vhost other.example.net is pending oper approval.',
835
+ );
836
+ });
837
+
838
+ it('lets an oper APPROVE a reserved-label vhost from the queue', () => {
839
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
840
+ services.requestVhost('alice', 'admin.example.net');
841
+ const conn = operConn('admin');
842
+ const ctx = makeCtx(conn, services);
843
+
844
+ const out = hostservReducer(conn, privmsg('APPROVE alice'), ctx);
845
+
846
+ expect(services.getVhost('alice')).toBe('admin.example.net');
847
+ expect(sentTexts(out.effects)).toContain(
848
+ ':HostServ!HostServ@services NOTICE admin :Vhost admin.example.net for alice has been approved and activated.',
849
+ );
850
+ });
851
+
852
+ it('lets an oper SET a reserved-label vhost directly', () => {
853
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
854
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
855
+ const conn = operConn('admin');
856
+ const ctx = makeCtx(conn, services);
857
+
858
+ const out = hostservReducer(conn, privmsg('SET alice staff.example.net'), ctx);
859
+
860
+ expect(services.getVhost('alice')).toBe('staff.example.net');
861
+ expect(sentTexts(out.effects)).toContain(
862
+ ':HostServ!HostServ@services NOTICE admin :Vhost for alice is now set to staff.example.net.',
863
+ );
864
+ });
865
+ });
866
+
551
867
  // ============================================================================
552
868
  // APPROVE / ACTIVATE (oper-only)
553
869
  // ============================================================================
@@ -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();
@@ -115,6 +115,24 @@ describe('markreadChannelReducer — cap gating', () => {
115
115
  Effect.send('c1', [L(':irc.example.com 421 alice MARKREAD :Unknown command')]),
116
116
  ]);
117
117
  });
118
+
119
+ it('substitutes * for the nick in the 421 numeric when the connection has no nick', () => {
120
+ const chan = makeChan();
121
+ // A bare connection: no nick set and no draft/read-marker cap negotiated.
122
+ // The 421 numeric's `ctx.connection.nick ?? '*'` falls back to `*`.
123
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
124
+ const ctx = makeCtx(conn);
125
+
126
+ const out = markreadChannelReducer(
127
+ chan,
128
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
129
+ ctx,
130
+ );
131
+
132
+ expect(out.effects).toEqual<EffectType[]>([
133
+ Effect.send('c1', [L(':irc.example.com 421 * MARKREAD :Unknown command')]),
134
+ ]);
135
+ });
118
136
  });
119
137
 
120
138
  // ============================================================================
@@ -168,6 +186,42 @@ describe('markreadChannelReducer — parameter errors', () => {
168
186
  Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
169
187
  ]);
170
188
  });
189
+
190
+ it('FAILs with INVALID_PARAMS when the timestamp prefix is present but the value is empty', () => {
191
+ const chan = makeChan();
192
+ addMember(chan, 'c1', 'alice');
193
+ const store = new InMemoryMessageStore();
194
+ seed(store, '#foo', 3);
195
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
196
+
197
+ const out = markreadChannelReducer(
198
+ chan,
199
+ { command: 'MARKREAD', params: ['#foo', 'timestamp='], tags: {} },
200
+ ctx,
201
+ );
202
+
203
+ expect(out.effects).toEqual<EffectType[]>([
204
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
205
+ ]);
206
+ });
207
+
208
+ it('FAILs with INVALID_PARAMS when the timestamp value is not a parseable date', () => {
209
+ const chan = makeChan();
210
+ addMember(chan, 'c1', 'alice');
211
+ const store = new InMemoryMessageStore();
212
+ seed(store, '#foo', 3);
213
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
214
+
215
+ const out = markreadChannelReducer(
216
+ chan,
217
+ { command: 'MARKREAD', params: ['#foo', 'timestamp=not-a-date'], tags: {} },
218
+ ctx,
219
+ );
220
+
221
+ expect(out.effects).toEqual<EffectType[]>([
222
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
223
+ ]);
224
+ });
171
225
  });
172
226
 
173
227
  // ============================================================================
@@ -71,6 +71,25 @@ describe('memoservReducer — services availability', () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe('memoservReducer — empty body', () => {
75
+ it('replies with the help notice when the PRIVMSG carries no body parameter', () => {
76
+ const conn = makeConn();
77
+ const services = new InMemoryServicesStore();
78
+ const ctx = makeCtx(conn, services);
79
+ // params: [MEMOSERV_NICK] only — params[1] is undefined, exercising the
80
+ // `msg.params[1] ?? ''` fallback. The empty body resolves to an empty
81
+ // subcommand, which emits the help notice.
82
+ const out = memoservReducer(
83
+ conn,
84
+ { command: 'PRIVMSG', params: [MEMOSERV_NICK], tags: {} },
85
+ ctx,
86
+ );
87
+ expect(sentTexts(out.effects)).toContain(
88
+ ':MemoServ!MemoServ@services NOTICE alice :Available commands: SEND, LIST, READ, DEL',
89
+ );
90
+ });
91
+ });
92
+
74
93
  describe('memoservReducer — SEND', () => {
75
94
  it('requires the sender to be identified', () => {
76
95
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });