serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -1,7 +1,7 @@
1
1
  import { Effect } from '@serverless-ircd/irc-core';
2
2
  import { CapturingLogger, LogLevel } from '@serverless-ircd/irc-core';
3
3
  import type { ChannelDelta, ConnId, RawLine } from '@serverless-ircd/irc-core';
4
- import { describe, expect, it } from 'vitest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
5
  import { dispatch } from '../src/dispatch';
6
6
  import type { IrcRuntime } from '../src/runtime';
7
7
 
@@ -251,6 +251,12 @@ describe('dispatch', () => {
251
251
 
252
252
  interface MemberInfo {
253
253
  caps: ReadonlySet<string>;
254
+ /**
255
+ * Optional SASL/NickServ account the member is identified as. When set,
256
+ * the fake runtime's `getConnectionInfo` surfaces it so account-scoped
257
+ * broadcast filtering (draft/read-marker privacy) can be exercised.
258
+ */
259
+ account?: string;
254
260
  }
255
261
 
256
262
  /**
@@ -300,9 +306,11 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
300
306
  wallops: false,
301
307
  serverNotices: false,
302
308
  tls: false,
309
+ registered: false,
303
310
  },
304
311
  lastSeen: 0,
305
312
  connectedSince: 0,
313
+ ...(info.account !== undefined ? { account: info.account } : {}),
306
314
  };
307
315
  },
308
316
  async getConnection() {
@@ -323,6 +331,9 @@ function capAwareRuntime(members: ReadonlyMap<ConnId, MemberInfo>): {
323
331
  moderated: false,
324
332
  secret: false,
325
333
  private: false,
334
+ registered: false,
335
+ blockUnidentified: false,
336
+ moderatedIdent: false,
326
337
  },
327
338
  banMasks: new Set<string>(),
328
339
  members: roster,
@@ -491,6 +502,7 @@ describe('dispatch — Broadcast cap / capLines', () => {
491
502
  wallops: false,
492
503
  serverNotices: false,
493
504
  tls: false,
505
+ registered: false,
494
506
  },
495
507
  lastSeen: 0,
496
508
  connectedSince: 0,
@@ -516,6 +528,9 @@ describe('dispatch — Broadcast cap / capLines', () => {
516
528
  moderated: false,
517
529
  secret: false,
518
530
  private: false,
531
+ registered: false,
532
+ blockUnidentified: false,
533
+ moderatedIdent: false,
519
534
  },
520
535
  banMasks: new Set<string>(),
521
536
  members: roster,
@@ -690,7 +705,183 @@ describe('dispatch — Broadcast caps (OR semantics)', () => {
690
705
  });
691
706
 
692
707
  // ===========================================================================
693
- // dispatch — Broadcast client-tag gating (message-tags)
708
+ // dispatch — Broadcast account-scoped recipient filter (draft/read-marker)
709
+ //
710
+ // IRCv3 `draft/read-marker` privacy: a read marker is private to the
711
+ // originator's account and MUST NOT be disclosed to other users. The reducer
712
+ // annotates the mark TAGMSG Broadcast with `account`; dispatch restricts the
713
+ // recipient set to channel members authenticated as that account (case-
714
+ // insensitive), still honouring `except` and the OR-semantics `caps` filter.
715
+ // Unidentified peers (no account) never receive a mark bound to an account.
716
+ // ===========================================================================
717
+
718
+ describe('dispatch — Broadcast account-scoped filter (draft/read-marker)', () => {
719
+ it('delivers ONLY to same-account members; cross-account members are excluded', async () => {
720
+ const members = memberMap([
721
+ ['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
722
+ ['c2', { caps: new Set(['message-tags']), account: 'alice' }], // same account
723
+ ['c3', { caps: new Set(['message-tags']), account: 'bob' }], // different account
724
+ ]);
725
+ const { runtime, sends, broadcasts } = capAwareRuntime(members);
726
+
727
+ await dispatch(
728
+ [
729
+ Effect.broadcast(
730
+ '#foo',
731
+ [L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
732
+ 'c1',
733
+ undefined,
734
+ undefined,
735
+ ['message-tags', 'draft/read-marker'],
736
+ 'alice',
737
+ ),
738
+ ],
739
+ runtime,
740
+ );
741
+
742
+ // No fast-path bulk broadcast: dispatch routes per-recipient.
743
+ expect(broadcasts).toHaveLength(0);
744
+ // Only c2 (same account) receives; c3 (different account) is excluded.
745
+ expect(sends.map((s) => s.to).sort()).toEqual(['c2']);
746
+ });
747
+
748
+ it('excludes unidentified members (no account) from an account-scoped mark', async () => {
749
+ const members = memberMap([
750
+ ['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
751
+ ['c2', { caps: new Set(['message-tags']) }], // unidentified
752
+ ]);
753
+ const { runtime, sends } = capAwareRuntime(members);
754
+
755
+ await dispatch(
756
+ [
757
+ Effect.broadcast(
758
+ '#foo',
759
+ [L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
760
+ 'c1',
761
+ undefined,
762
+ undefined,
763
+ ['message-tags', 'draft/read-marker'],
764
+ 'alice',
765
+ ),
766
+ ],
767
+ runtime,
768
+ );
769
+
770
+ expect(sends).toHaveLength(0);
771
+ });
772
+
773
+ it('matches the account case-insensitively (Alice vs alice)', async () => {
774
+ const members = memberMap([
775
+ ['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
776
+ ['c2', { caps: new Set(['message-tags']), account: 'ALICE' }], // same account, diff case
777
+ ]);
778
+ const { runtime, sends } = capAwareRuntime(members);
779
+
780
+ await dispatch(
781
+ [
782
+ Effect.broadcast(
783
+ '#foo',
784
+ [L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
785
+ 'c1',
786
+ undefined,
787
+ undefined,
788
+ ['message-tags', 'draft/read-marker'],
789
+ 'alice',
790
+ ),
791
+ ],
792
+ runtime,
793
+ );
794
+
795
+ expect(sends.map((s) => s.to)).toEqual(['c2']);
796
+ });
797
+
798
+ it('still applies the OR-caps filter alongside the account filter', async () => {
799
+ const members = memberMap([
800
+ ['c1', { caps: new Set(['message-tags']), account: 'alice' }], // originator (except)
801
+ // same account but no relevant cap — excluded by caps filter:
802
+ ['c2', { caps: new Set<string>(), account: 'alice' }],
803
+ // same account, draft/read-marker cap — delivered:
804
+ ['c3', { caps: new Set(['draft/read-marker']), account: 'alice' }],
805
+ ]);
806
+ const { runtime, sends } = capAwareRuntime(members);
807
+
808
+ await dispatch(
809
+ [
810
+ Effect.broadcast(
811
+ '#foo',
812
+ [L('@+draft/read-marker=m1 :a!u@h TAGMSG #foo')],
813
+ 'c1',
814
+ undefined,
815
+ undefined,
816
+ ['message-tags', 'draft/read-marker'],
817
+ 'alice',
818
+ ),
819
+ ],
820
+ runtime,
821
+ );
822
+
823
+ expect(sends.map((s) => s.to)).toEqual(['c3']);
824
+ });
825
+
826
+ it('ignores the account filter when account is unset (channel-wide broadcast)', async () => {
827
+ const members = memberMap([
828
+ ['c1', { caps: new Set(['message-tags']), account: 'alice' }],
829
+ ['c2', { caps: new Set(['message-tags']), account: 'bob' }],
830
+ ['c3', { caps: new Set(['message-tags']), account: 'carol' }],
831
+ ]);
832
+ const { runtime, sends } = capAwareRuntime(members);
833
+
834
+ await dispatch(
835
+ [
836
+ Effect.broadcast(
837
+ '#foo',
838
+ [L('@+draft/typing=active :a!u@h TAGMSG #foo')],
839
+ 'c1',
840
+ undefined,
841
+ undefined,
842
+ ['message-tags', 'draft/typing'],
843
+ ),
844
+ ],
845
+ runtime,
846
+ );
847
+
848
+ // No account filter → every other member receives regardless of account.
849
+ expect(sends.map((s) => s.to).sort()).toEqual(['c2', 'c3']);
850
+ });
851
+
852
+ it('delivers to every same-account member regardless of caps when only account is set', async () => {
853
+ // account-only broadcast (no cap / caps): the recipient set is purely
854
+ // account-scoped, so caps play no role — every same-account member
855
+ // receives the line, even with an empty cap set.
856
+ const members = memberMap([
857
+ ['c1', { caps: new Set<string>(), account: 'alice' }], // originator (except)
858
+ ['c2', { caps: new Set<string>(), account: 'alice' }], // same account, no caps
859
+ ['c3', { caps: new Set<string>(), account: 'bob' }], // different account
860
+ ]);
861
+ const { runtime, sends, broadcasts } = capAwareRuntime(members);
862
+
863
+ await dispatch(
864
+ [
865
+ Effect.broadcast(
866
+ '#foo',
867
+ [L(':a!u@h TAGMSG #foo')],
868
+ 'c1',
869
+ undefined,
870
+ undefined,
871
+ undefined,
872
+ 'alice',
873
+ ),
874
+ ],
875
+ runtime,
876
+ );
877
+
878
+ // No fast-path bulk broadcast: dispatch routes per-recipient.
879
+ expect(broadcasts).toHaveLength(0);
880
+ // c2 (same account, no caps) receives; c3 (different account) is excluded.
881
+ expect(sends.map((s) => s.to)).toEqual(['c2']);
882
+ });
883
+ });
884
+
694
885
  //
695
886
  // Recipients that did NOT negotiate `message-tags` must never receive a line
696
887
  // beginning with `@+<client-tag>`. The dispatch broadcast per-recipient path
@@ -875,3 +1066,80 @@ describe('dispatch — observability', () => {
875
1066
  expect(errs[0]?.fields?.err).toBe('string-error');
876
1067
  });
877
1068
  });
1069
+
1070
+ describe('dispatch — EnforceNick', () => {
1071
+ it('disconnects immediately for the kill policy', async () => {
1072
+ const calls: RecordedCall[] = [];
1073
+ await dispatch([Effect.enforceNick('c2', 'alice', 'kill', 0)], fakeRuntime(calls));
1074
+ expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
1075
+ });
1076
+
1077
+ it('is a no-op for the none policy (no runtime calls)', async () => {
1078
+ const calls: RecordedCall[] = [];
1079
+ await dispatch([Effect.enforceNick('c2', 'alice', 'none', 0)], fakeRuntime(calls));
1080
+ expect(calls).toEqual([]);
1081
+ });
1082
+
1083
+ it('schedules the disconnect after graceMs for ghost via the injected scheduler', async () => {
1084
+ const calls: RecordedCall[] = [];
1085
+ const scheduled: { ms: number }[] = [];
1086
+ let scheduledCallback: (() => void) | undefined;
1087
+ const scheduler = (cb: () => void, ms: number): void => {
1088
+ scheduled.push({ ms });
1089
+ scheduledCallback = cb;
1090
+ };
1091
+
1092
+ await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 30_000)], fakeRuntime(calls), {
1093
+ scheduler,
1094
+ });
1095
+
1096
+ // Disconnect must NOT have fired yet (still within the grace window).
1097
+ expect(calls).toEqual([]);
1098
+ expect(scheduled).toEqual([{ ms: 30_000 }]);
1099
+ expect(scheduledCallback).toBeTypeOf('function');
1100
+
1101
+ // Flush the grace timer → disconnect fires with the canonical reason.
1102
+ scheduledCallback?.();
1103
+ await Promise.resolve();
1104
+
1105
+ expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
1106
+ });
1107
+
1108
+ it('disconnects immediately for ghost when graceMs is 0', async () => {
1109
+ const calls: RecordedCall[] = [];
1110
+ const scheduler = (): void => {
1111
+ // Should never be called when graceMs is 0.
1112
+ throw new Error('scheduler must not be invoked for graceMs=0');
1113
+ };
1114
+
1115
+ await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 0)], fakeRuntime(calls), {
1116
+ scheduler,
1117
+ });
1118
+
1119
+ expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
1120
+ });
1121
+
1122
+ it('still disconnects for ghost when no scheduler is supplied (setTimeout fallback)', async () => {
1123
+ // graceMs=0 takes the immediate path, so the missing scheduler is fine.
1124
+ const calls: RecordedCall[] = [];
1125
+ await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 0)], fakeRuntime(calls));
1126
+ expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
1127
+ });
1128
+
1129
+ it('uses the default setTimeout scheduler when no scheduler is injected (ghost with grace)', async () => {
1130
+ vi.useFakeTimers();
1131
+ try {
1132
+ const calls: RecordedCall[] = [];
1133
+ // dispatch resolves once the timer is scheduled; the disconnect is deferred.
1134
+ await dispatch([Effect.enforceNick('c2', 'alice', 'ghost', 30_000)], fakeRuntime(calls));
1135
+ expect(calls).toEqual([]);
1136
+
1137
+ // Flushing the fake timer fires the scheduled disconnect.
1138
+ await vi.advanceTimersByTimeAsync(30_000);
1139
+
1140
+ expect(calls).toEqual([{ method: 'disconnect', args: ['c2', 'Nick enforced by NickServ'] }]);
1141
+ } finally {
1142
+ vi.useRealTimers();
1143
+ }
1144
+ });
1145
+ });
@@ -114,16 +114,22 @@ describe('buildRoutedReducers', () => {
114
114
  'away',
115
115
  'cap',
116
116
  'channelMode',
117
+ 'chanserv',
118
+ 'hostserv',
117
119
  'info',
118
120
  'invite',
119
121
  'join',
120
122
  'kick',
123
+ 'markreadChannel',
121
124
  'motd',
122
125
  'names',
123
126
  'nick',
127
+ 'nickserv',
128
+ 'memoserv',
124
129
  'noticeChannel',
125
130
  'noticeUser',
126
131
  'oper',
132
+ 'operserv',
127
133
  'part',
128
134
  'pass',
129
135
  'ping',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-test-support",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Shared IRC scenario runner + harness seam used by every adapter's integration suite",
6
6
  "license": "BSD-3-Clause",
@@ -21,6 +21,7 @@ import {
21
21
  FakeClock,
22
22
  type RawLine,
23
23
  SequentialIdFactory,
24
+ type ServerConfig,
24
25
  StaticMotdProvider,
25
26
  createConnection,
26
27
  } from '@serverless-ircd/irc-core';
@@ -44,7 +45,14 @@ const POLL_MS = 10;
44
45
  /** Default timeout for waitFor* implementations (ms). */
45
46
  const DEFAULT_TIMEOUT_MS = 1000;
46
47
 
47
- /** Shared server config used by the harness. Hard-coded — tests assert on it. */
48
+ /**
49
+ * Shared server config used by the harness. Hard-coded — tests assert on it.
50
+ *
51
+ * `serverPassword` is intentionally NOT set here so the default harness
52
+ * leaves the registration gate disabled and every existing scenario
53
+ * registers without `PASS`. Construct the harness with
54
+ * `{ serverPassword: '...' }` to opt a scenario into the gated path.
55
+ */
48
56
  export const HARNESS_SERVER_CONFIG = {
49
57
  serverName: 'irc.example.com',
50
58
  networkName: 'ExampleNet',
@@ -57,6 +65,17 @@ export const HARNESS_SERVER_CONFIG = {
57
65
  quitMessage: 'Client Quit',
58
66
  } as const;
59
67
 
68
+ /**
69
+ * Optional harness configuration. `serverPassword` opts a scenario into
70
+ * the server-password gate: when set, every spawned client must supply
71
+ * the matching `PASS <value>` (or SASL-identify) before `001
72
+ * RPL_WELCOME`. Default unset → gate disabled (existing scenarios
73
+ * unchanged).
74
+ */
75
+ export interface InMemoryHarnessOptions {
76
+ serverPassword?: string;
77
+ }
78
+
60
79
  /**
61
80
  * Default MOTD lines exposed to every spawned client. The registration
62
81
  * welcome block renders these lines as `375`/`372`×N/`376` numerics, and
@@ -120,6 +139,7 @@ export class InMemoryHarness implements IrcHarness {
120
139
  private readonly motd = new StaticMotdProvider(HARNESS_MOTD_LINES);
121
140
  private readonly clients = new Map<ConnId, TrackedClient>();
122
141
  private readonly transportKind: TransportKind;
142
+ private readonly serverConfig: ServerConfig;
123
143
  private nextId = 0;
124
144
  private closed = false;
125
145
 
@@ -130,9 +150,15 @@ export class InMemoryHarness implements IrcHarness {
130
150
  * {@link TcpByteStreamTransport} so chunk-boundary scenarios exercise
131
151
  * real byte-stream framing. One transport instance per client (TCP
132
152
  * transports own a per-connection partial-line buffer).
153
+ * @param opts Optional harness-wide config. `serverPassword` opts a
154
+ * scenario into the registration gate (default off).
133
155
  */
134
- constructor(transportKind: TransportKind = 'ws') {
156
+ constructor(transportKind: TransportKind = 'ws', opts: InMemoryHarnessOptions = {}) {
135
157
  this.transportKind = transportKind;
158
+ this.serverConfig =
159
+ opts.serverPassword === undefined
160
+ ? HARNESS_SERVER_CONFIG
161
+ : { ...HARNESS_SERVER_CONFIG, serverPassword: opts.serverPassword };
136
162
  }
137
163
 
138
164
  async spawnClient(opts?: SpawnClientOptions): Promise<ClientHarness> {
@@ -162,7 +188,7 @@ export class InMemoryHarness implements IrcHarness {
162
188
  state,
163
189
  runtime: this.runtime,
164
190
  channels: this.runtime,
165
- serverConfig: HARNESS_SERVER_CONFIG,
191
+ serverConfig: this.serverConfig,
166
192
  clock: this.clock,
167
193
  ids: this.ids,
168
194
  motd: this.motd,
@@ -237,12 +263,13 @@ class InMemoryClientHarness implements ClientHarness {
237
263
  }
238
264
 
239
265
  async feed(chunk: string): Promise<void> {
240
- /* istanbul ignore next -- defensive: bindActor is called by the
266
+ /* istanbul ignore start -- defensive: bindActor is called by the
241
267
  harness before the client is returned, so the unbound-actor branch
242
268
  is unreachable through the public API. */
243
269
  if (this.actor === undefined) {
244
270
  throw new Error(`client ${this.id} has no bound actor`);
245
271
  }
272
+ /* istanbul ignore stop */
246
273
  await this.actor.receive(chunk);
247
274
  }
248
275
 
@@ -279,16 +306,18 @@ class InMemoryClientHarness implements ClientHarness {
279
306
  this.closed = true;
280
307
  // Best-effort QUIT so peers see the part broadcast; the runtime's
281
308
  // disconnect handler is wired by the harness to unregister.
309
+ /* istanbul ignore start -- defensive: spawnClient binds the actor
310
+ before returning the client, so `this.actor` is never undefined
311
+ here. The guard exists purely as a teardown safety net. */
282
312
  if (this.actor !== undefined) {
283
313
  try {
284
314
  await this.actor.receive('QUIT :client closed\r\n');
285
- /* istanbul ignore next -- defensive: receive swallows
286
- every error internally; the catch is purely a teardown safety
287
- net for catastrophic runtime failures. */
288
315
  } catch {
289
- // no-op
316
+ // no-op -- receive swallows every error internally; this catch is
317
+ // purely a teardown safety net for catastrophic runtime failures.
290
318
  }
291
319
  }
320
+ /* istanbul ignore stop */
292
321
  }
293
322
  }
294
323
 
@@ -20,6 +20,7 @@ export {
20
20
  HARNESS_MOTD_LINES,
21
21
  HARNESS_SERVER_CONFIG,
22
22
  InMemoryHarness,
23
+ type InMemoryHarnessOptions,
23
24
  inMemoryHarnessFactory,
24
25
  inMemoryTcpHarnessFactory,
25
26
  inMemoryWsHarnessFactory,
@@ -182,7 +182,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
182
182
  await alice.waitForNumeric('366');
183
183
  const namesLine = alice.received.find((l) => numericOf(l) === '353');
184
184
  expect(namesLine).toBeDefined();
185
- expect(namesLine ?? '').toContain('@alice');
185
+ // namesLine is still `string | undefined` to TS (expect() does not
186
+ // narrow), so we route it back through expect() rather than a
187
+ // non-null assertion — toContain fails loudly if it were undefined.
188
+ expect(namesLine).toContain('@alice');
186
189
  await harness.close();
187
190
  });
188
191
 
@@ -246,7 +249,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
246
249
  await alice.send('JOIN #room');
247
250
  await bob.send('JOIN #room');
248
251
  await untilAliceSeesBobJoin(alice, '#room');
249
- alice.received.length = 0;
252
+ // NOTE: alice.received is intentionally NOT cleared here. Keeping
253
+ // the registration + JOIN burst makes the no-self-echo assertion
254
+ // below non-vacuous: it scans real traffic and confirms the
255
+ // sender never receives her own PRIVMSG among any of it.
250
256
  bob.received.length = 0;
251
257
  await alice.send('PRIVMSG #room :hello team');
252
258
  await bob.waitForLine((l) => l.includes('PRIVMSG #room :hello team'));
@@ -453,7 +459,10 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
453
459
  await alice.send('JOIN #notice');
454
460
  await bob.send('JOIN #notice');
455
461
  await untilAliceSeesBobJoin(alice, '#notice');
456
- alice.received.length = 0;
462
+ // NOTE: alice.received is intentionally NOT cleared here. Keeping
463
+ // the registration + JOIN burst makes the NOTICE-no-error filter
464
+ // below non-vacuous: it scans real numeric traffic (353/366/…)
465
+ // and confirms none of it is a NOTICE error code.
457
466
  bob.received.length = 0;
458
467
  await alice.send('NOTICE #notice :quiet note');
459
468
  await bob.waitForLine((l) => l.includes('NOTICE #notice :quiet note'));
@@ -469,13 +478,19 @@ export function runIrcScenarios(factories: readonly IrcHarnessFactory[]): void {
469
478
  await harness.close();
470
479
  });
471
480
 
472
- it('scenario 27: PASS after registration is rejected with 462', async () => {
481
+ it('scenario 27: bare PASS after registration is a silent no-op (no 462)', async () => {
473
482
  harness = await factory.create();
474
483
  const c = await harness.spawnClient({ nick: 'alice' });
475
484
  await c.waitForNumeric('001');
476
485
  c.received.length = 0;
477
- await c.send('PASS hunter2');
478
- await c.waitForNumeric('462');
486
+ // A bare server-password PASS is meaningless once registration has
487
+ // completed: it must be ignored (no 462, no disconnect). Send a
488
+ // PING in the same frame so ordering is guaranteed — by the time
489
+ // the PONG arrives, any 462 from the PASS would already be queued.
490
+ await c.send('PASS hunter2\r\nPING :post-pass');
491
+ await c.waitForLine((l) => l === 'PONG :post-pass');
492
+ const errAlreadyRegistered = c.received.filter((l) => numericOf(l) === '462');
493
+ expect(errAlreadyRegistered).toEqual([]);
479
494
  await harness.close();
480
495
  });
481
496
 
@@ -191,6 +191,25 @@ describe('InMemoryClientHarness.waitForNumeric', () => {
191
191
  expect(result).toContain('004');
192
192
  await h.close();
193
193
  });
194
+
195
+ it('ignores received lines that carry no :server prefix when scanning', async () => {
196
+ // A PONG reply is emitted without a `:server` prefix (`PONG :tok`, not
197
+ // `:srv PONG :tok`). waitForNumeric must scan such a line without
198
+ // false-matching: numericEquals takes its no-prefix branch (start=0),
199
+ // rejects the line, and keeps scanning until the real numeric lands.
200
+ const h = new InMemoryHarness();
201
+ const c = await h.spawnClient({ nick: 'alice' });
202
+ await c.waitForNumeric('001');
203
+ // Isolate a bare (no-prefix) line first, then drive a prefixed numeric
204
+ // after it, so the scan MUST walk past the PONG to reach 375.
205
+ c.received.length = 0;
206
+ await c.send('PING :bare');
207
+ await c.waitForLine((l) => l === 'PONG :bare');
208
+ await c.send('MOTD');
209
+ const result = await c.waitForNumeric('375');
210
+ expect(result).toContain('375');
211
+ await h.close();
212
+ });
194
213
  });
195
214
 
196
215
  describe('InMemoryClientHarness.close', () => {
@@ -228,3 +247,35 @@ describe('InMemoryClientHarness.close', () => {
228
247
  await h.close();
229
248
  });
230
249
  });
250
+
251
+ describe('InMemoryHarness — server-password gate opt-in', () => {
252
+ it('rejects registration with 464 + disconnect when serverPassword is set and no PASS is sent', async () => {
253
+ const h = new InMemoryHarness('ws', { serverPassword: 's3cret' });
254
+ const c = await h.spawnClient();
255
+ await c.send('NICK alice');
256
+ await c.send('USER alice 0 * :alice');
257
+ // No PASS → gate fires: 464 then the connection is torn down.
258
+ const line464 = await c.waitForNumeric('464');
259
+ expect(line464).toMatch(/464 alice :Password mismatch/u);
260
+ await h.close();
261
+ });
262
+
263
+ it('completes registration when PASS matches the configured serverPassword', async () => {
264
+ const h = new InMemoryHarness('ws', { serverPassword: 's3cret' });
265
+ const c = await h.spawnClient();
266
+ await c.send('PASS s3cret');
267
+ await c.send('NICK bob');
268
+ await c.send('USER bob 0 * :bob');
269
+ await c.waitForNumeric('001');
270
+ await h.close();
271
+ });
272
+
273
+ it('leaves the gate disabled by default (no serverPassword option)', async () => {
274
+ const h = new InMemoryHarness();
275
+ const c = await h.spawnClient();
276
+ await c.send('NICK carol');
277
+ await c.send('USER carol 0 * :carol');
278
+ await c.waitForNumeric('001');
279
+ await h.close();
280
+ });
281
+ });
@@ -7,7 +7,12 @@ export default defineConfig({
7
7
  provider: 'v8',
8
8
  all: false,
9
9
  include: ['src/**/*.ts'],
10
- exclude: ['src/**/index.ts'],
10
+ // harness.ts exports types/interfaces only — it compiles to a bare
11
+ // `export {}` with zero runtime statements, so v8 reports it as a
12
+ // 0/0 (0%-statements) file that drags the file list down. Excluding
13
+ // it keeps the report honest: only modules with runtime code are
14
+ // scored. index.ts is excluded as the pure re-export barrel.
15
+ exclude: ['src/**/index.ts', 'src/harness.ts'],
11
16
  reporter: ['text', 'html', 'json-summary'],
12
17
  thresholds: {
13
18
  lines: 90,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "CI hardening helpers: coverage-gate config validator + mutation-testing driver for irc-core",
6
6
  "license": "BSD-3-Clause",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/load-test",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Synthetic WebSocket IRC client farm for load testing ServerlessIRCd (concurrency, latency p50/p95/p99, drop rate)",
6
6
  "license": "BSD-3-Clause",