serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { cloakHost } from '../../src/cloak';
2
3
  import { generateIsupport } from '../../src/commands/isupport';
3
4
  import {
4
5
  buildWelcomeLines,
@@ -75,9 +76,9 @@ function expectedWelcome(nick: string, user: string, host: string | undefined):
75
76
  );
76
77
  return [
77
78
  L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
78
- L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.1.0`),
79
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.2.0`),
79
80
  L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
80
- L(`${prefix} 004 ${nick} ${sv} 0.1.0 iosw biklmnstp`),
81
+ L(`${prefix} 004 ${nick} ${sv} 0.2.0 iosw biklmnstp`),
81
82
  ...isupport,
82
83
  L(`${prefix} 375 ${nick} :- ${sv} Message of the day -`),
83
84
  L(`${prefix} 372 ${nick} :- MOTD placeholder`),
@@ -567,33 +568,300 @@ describe('emitWelcomeIfReady', () => {
567
568
  state.user = 'alice';
568
569
  const ctx = makeCtx(state);
569
570
  const effect = emitWelcomeIfReady(state, ctx);
570
- expect(effect).toEqual(Effect.send('c1', expectedWelcome('alice', 'alice', undefined)));
571
+ expect(effect).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
571
572
  expect(state.registration).toBe('registered');
572
573
  });
573
574
 
574
- it('returns null and does not transition when nick is missing', () => {
575
+ it('returns an empty array and does not transition when nick is missing', () => {
575
576
  const state = makeState();
576
577
  state.user = 'alice';
577
578
  const ctx = makeCtx(state);
578
579
  const effect = emitWelcomeIfReady(state, ctx);
579
- expect(effect).toBeNull();
580
+ expect(effect).toEqual([]);
580
581
  expect(state.registration).toBe('pre-registration');
581
582
  });
582
583
 
583
- it('returns null and does not transition when user is missing', () => {
584
+ it('returns an empty array and does not transition when user is missing', () => {
584
585
  const state = makeState();
585
586
  state.nick = 'alice';
586
587
  const ctx = makeCtx(state);
587
588
  const effect = emitWelcomeIfReady(state, ctx);
588
- expect(effect).toBeNull();
589
+ expect(effect).toEqual([]);
589
590
  expect(state.registration).toBe('pre-registration');
590
591
  });
591
592
 
592
- it('returns null when already registered', () => {
593
+ it('returns an empty array when already registered', () => {
593
594
  const state = registeredState();
594
595
  const ctx = makeCtx(state);
595
596
  const effect = emitWelcomeIfReady(state, ctx);
596
- expect(effect).toBeNull();
597
+ expect(effect).toEqual([]);
597
598
  expect(state.registration).toBe('registered');
598
599
  });
599
600
  });
601
+
602
+ // ============================================================================
603
+ // emitWelcomeIfReady — server-password gate
604
+ // ============================================================================
605
+
606
+ /** Builds a ctx whose serverConfig has a serverPassword set. */
607
+ function makeCtxWithPassword(state: ConnectionState, password: string): Ctx {
608
+ return buildCtx({
609
+ serverConfig: { ...serverConfig, serverPassword: password },
610
+ clock: new FakeClock(1_000),
611
+ ids: new SequentialIdFactory(),
612
+ motd: EmptyMotdProvider,
613
+ connection: state,
614
+ });
615
+ }
616
+
617
+ describe('emitWelcomeIfReady — server-password gate', () => {
618
+ it('proceeds with registration when no serverPassword is configured', () => {
619
+ const state = makeState();
620
+ state.nick = 'alice';
621
+ state.user = 'alice';
622
+ const ctx = makeCtx(state);
623
+ const effects = emitWelcomeIfReady(state, ctx);
624
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
625
+ expect(state.registration).toBe('registered');
626
+ });
627
+
628
+ it('proceeds with registration when serverPassword is empty (disabled)', () => {
629
+ const state = makeState();
630
+ state.nick = 'alice';
631
+ state.user = 'alice';
632
+ const ctx = makeCtxWithPassword(state, '');
633
+ const effects = emitWelcomeIfReady(state, ctx);
634
+ expect(state.registration).toBe('registered');
635
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
636
+ });
637
+
638
+ it('blocks registration with 464 + Disconnect when passAttempt is missing', () => {
639
+ const state = makeState();
640
+ state.nick = 'alice';
641
+ state.user = 'alice';
642
+ const ctx = makeCtxWithPassword(state, 's3cret');
643
+ const effects = emitWelcomeIfReady(state, ctx);
644
+ expect(state.registration).not.toBe('registered');
645
+ expect(effects).toEqual<EffectType[]>([
646
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
647
+ Effect.disconnect('c1', 'Bad Password'),
648
+ ]);
649
+ });
650
+
651
+ it('blocks registration with 464 + Disconnect when passAttempt is wrong', () => {
652
+ const state = makeState();
653
+ state.nick = 'alice';
654
+ state.user = 'alice';
655
+ state.passAttempt = 'wrong';
656
+ const ctx = makeCtxWithPassword(state, 's3cret');
657
+ const effects = emitWelcomeIfReady(state, ctx);
658
+ expect(state.registration).not.toBe('registered');
659
+ expect(effects).toEqual<EffectType[]>([
660
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
661
+ Effect.disconnect('c1', 'Bad Password'),
662
+ ]);
663
+ });
664
+
665
+ it('proceeds with registration when passAttempt matches serverPassword', () => {
666
+ const state = makeState();
667
+ state.nick = 'alice';
668
+ state.user = 'alice';
669
+ state.passAttempt = 's3cret';
670
+ const ctx = makeCtxWithPassword(state, 's3cret');
671
+ const effects = emitWelcomeIfReady(state, ctx);
672
+ expect(state.registration).toBe('registered');
673
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
674
+ });
675
+
676
+ it('satisfies the gate when SASL has authenticated an account (no PASS needed)', () => {
677
+ const state = makeState();
678
+ state.nick = 'alice';
679
+ state.user = 'alice';
680
+ state.account = 'alice';
681
+ const ctx = makeCtxWithPassword(state, 's3cret');
682
+ const effects = emitWelcomeIfReady(state, ctx);
683
+ expect(state.registration).toBe('registered');
684
+ expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
685
+ });
686
+
687
+ it('does not register when nick is missing even if password is configured', () => {
688
+ const state = makeState();
689
+ state.user = 'alice';
690
+ const ctx = makeCtxWithPassword(state, 's3cret');
691
+ const effects = emitWelcomeIfReady(state, ctx);
692
+ expect(effects).toEqual([]);
693
+ expect(state.registration).toBe('pre-registration');
694
+ });
695
+
696
+ it('does not register when capNegotiating is true even if password is configured', () => {
697
+ const state = makeState();
698
+ state.nick = 'alice';
699
+ state.user = 'alice';
700
+ state.capNegotiating = true;
701
+ const ctx = makeCtxWithPassword(state, 's3cret');
702
+ const effects = emitWelcomeIfReady(state, ctx);
703
+ expect(effects).toEqual([]);
704
+ expect(state.registration).toBe('pre-registration');
705
+ });
706
+ });
707
+
708
+ // ============================================================================
709
+ // userReducer / nickReducer — server-password gate integration
710
+ // ============================================================================
711
+
712
+ describe('userReducer — server-password gate integration', () => {
713
+ it('emits 464 + Disconnect on the completing USER when passAttempt is wrong', () => {
714
+ const state = makeState();
715
+ state.nick = 'alice';
716
+ state.registration = 'registering';
717
+ const ctx = makeCtxWithPassword(state, 's3cret');
718
+ const out = userReducer(
719
+ state,
720
+ { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },
721
+ ctx,
722
+ );
723
+ expect(state.registration).not.toBe('registered');
724
+ expect(out.effects).toEqual<EffectType[]>([
725
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
726
+ Effect.disconnect('c1', 'Bad Password'),
727
+ ]);
728
+ });
729
+
730
+ it('completes registration on the completing USER when passAttempt matches', () => {
731
+ const state = makeState();
732
+ state.nick = 'alice';
733
+ state.passAttempt = 's3cret';
734
+ state.registration = 'registering';
735
+ const ctx = makeCtxWithPassword(state, 's3cret');
736
+ const out = userReducer(
737
+ state,
738
+ { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },
739
+ ctx,
740
+ );
741
+ expect(state.registration).toBe('registered');
742
+ expect(out.effects).toEqual<EffectType[]>([
743
+ Effect.send('c1', expectedWelcome('alice', 'alice', undefined)),
744
+ ]);
745
+ });
746
+ });
747
+
748
+ // ============================================================================
749
+ // emitWelcomeIfReady — hostmask cloaking integration
750
+ // ============================================================================
751
+
752
+ function makeCtxWithCloaking(
753
+ state: ConnectionState,
754
+ cloaking: { enabled: boolean; secret: string; cloakedSuffix?: string },
755
+ ): Ctx {
756
+ return buildCtx({
757
+ serverConfig: { ...serverConfig, cloaking },
758
+ clock: new FakeClock(1_000),
759
+ ids: new SequentialIdFactory(),
760
+ motd: EmptyMotdProvider,
761
+ connection: state,
762
+ });
763
+ }
764
+
765
+ describe('emitWelcomeIfReady — hostmask cloaking', () => {
766
+ it('leaves host untouched when cloaking is disabled', () => {
767
+ const state = makeState();
768
+ state.nick = 'alice';
769
+ state.user = 'alice';
770
+ state.host = '203.0.113.5';
771
+ const ctx = makeCtxWithCloaking(state, { enabled: false, secret: 's3cret' });
772
+ emitWelcomeIfReady(state, ctx);
773
+ expect(state.host).toBe('203.0.113.5');
774
+ });
775
+
776
+ it('replaces the real host with a deterministic cloak when enabled', () => {
777
+ const state = makeState();
778
+ state.nick = 'alice';
779
+ state.user = 'alice';
780
+ state.host = '203.0.113.5';
781
+ const ctx = makeCtxWithCloaking(state, { enabled: true, secret: 's3cret' });
782
+ emitWelcomeIfReady(state, ctx);
783
+ expect(state.host).toBe(
784
+ cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'ExampleNet'),
785
+ );
786
+ expect(state.host).not.toBe('203.0.113.5');
787
+ });
788
+
789
+ it('uses the configured cloakedSuffix when supplied', () => {
790
+ const state = makeState();
791
+ state.nick = 'alice';
792
+ state.user = 'alice';
793
+ state.host = '203.0.113.5';
794
+ const ctx = makeCtxWithCloaking(state, {
795
+ enabled: true,
796
+ secret: 's3cret',
797
+ cloakedSuffix: 'hidden.lan',
798
+ });
799
+ emitWelcomeIfReady(state, ctx);
800
+ expect(state.host).toBe(
801
+ cloakHost(
802
+ '203.0.113.5',
803
+ { enabled: true, secret: 's3cret', cloakedSuffix: 'hidden.lan' },
804
+ 'ExampleNet',
805
+ ),
806
+ );
807
+ expect(state.host.endsWith('.hidden.lan')).toBe(true);
808
+ });
809
+
810
+ it('cloaks the host before building the welcome hostmask so 001 shows the cloak', () => {
811
+ const state = makeState();
812
+ state.nick = 'alice';
813
+ state.user = 'alice';
814
+ state.host = '203.0.113.5';
815
+ const ctx = makeCtxWithCloaking(state, { enabled: true, secret: 's3cret' });
816
+ const effects = emitWelcomeIfReady(state, ctx);
817
+ const expectedCloak = cloakHost(
818
+ '203.0.113.5',
819
+ { enabled: true, secret: 's3cret' },
820
+ 'ExampleNet',
821
+ );
822
+ const expectedHostmask = `alice!alice@${expectedCloak}`;
823
+ expect(effects).toEqual([
824
+ Effect.send('c1', expectedWelcomeWithHost('alice', expectedHostmask)),
825
+ ]);
826
+ });
827
+
828
+ it('does not cloak when host is already absent (nothing to cloak)', () => {
829
+ const state = makeState();
830
+ state.nick = 'alice';
831
+ state.user = 'alice';
832
+ const ctx = makeCtxWithCloaking(state, { enabled: true, secret: 's3cret' });
833
+ emitWelcomeIfReady(state, ctx);
834
+ expect(state.host).toBeUndefined();
835
+ });
836
+ });
837
+
838
+ /** Builds the welcome block with an explicit cloaked host (used for cloak tests). */
839
+ function expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {
840
+ const sv = 'irc.example.com';
841
+ const prefix = `:${sv}`;
842
+ const isupport = generateIsupport(
843
+ {
844
+ serverName: sv,
845
+ networkName: 'ExampleNet',
846
+ maxChannelsPerUser: 30,
847
+ maxTargetsPerCommand: 10,
848
+ maxListEntries: 50,
849
+ nickLen: 30,
850
+ channelLen: 50,
851
+ topicLen: 390,
852
+ quitMessage: 'Client Quit',
853
+ },
854
+ new Set<string>(),
855
+ nick,
856
+ );
857
+ return [
858
+ L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
859
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.2.0`),
860
+ L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
861
+ L(`${prefix} 004 ${nick} ${sv} 0.2.0 iosw biklmnstp`),
862
+ ...isupport,
863
+ L(`${prefix} 375 ${nick} :- ${sv} Message of the day -`),
864
+ L(`${prefix} 372 ${nick} :- MOTD placeholder`),
865
+ L(`${prefix} 376 ${nick} :End of MOTD command`),
866
+ ];
867
+ }