serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  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-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. 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.8.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,
@@ -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,
@@ -228,3 +228,35 @@ describe('InMemoryClientHarness.close', () => {
228
228
  await h.close();
229
229
  });
230
230
  });
231
+
232
+ describe('InMemoryHarness — server-password gate opt-in', () => {
233
+ it('rejects registration with 464 + disconnect when serverPassword is set and no PASS is sent', async () => {
234
+ const h = new InMemoryHarness('ws', { serverPassword: 's3cret' });
235
+ const c = await h.spawnClient();
236
+ await c.send('NICK alice');
237
+ await c.send('USER alice 0 * :alice');
238
+ // No PASS → gate fires: 464 then the connection is torn down.
239
+ const line464 = await c.waitForNumeric('464');
240
+ expect(line464).toMatch(/464 alice :Password mismatch/u);
241
+ await h.close();
242
+ });
243
+
244
+ it('completes registration when PASS matches the configured serverPassword', async () => {
245
+ const h = new InMemoryHarness('ws', { serverPassword: 's3cret' });
246
+ const c = await h.spawnClient();
247
+ await c.send('PASS s3cret');
248
+ await c.send('NICK bob');
249
+ await c.send('USER bob 0 * :bob');
250
+ await c.waitForNumeric('001');
251
+ await h.close();
252
+ });
253
+
254
+ it('leaves the gate disabled by default (no serverPassword option)', async () => {
255
+ const h = new InMemoryHarness();
256
+ const c = await h.spawnClient();
257
+ await c.send('NICK carol');
258
+ await c.send('USER carol 0 * :carol');
259
+ await c.waitForNumeric('001');
260
+ await h.close();
261
+ });
262
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/ci-hardening",
3
- "version": "0.7.0",
3
+ "version": "0.8.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.8.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/tcp-ws-forwarder",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Local TCP to ws/wss forwarder so standard IRC clients can reach the WebSocket-only ServerlessIRCd transport",
6
6
  "license": "BSD-3-Clause",
@@ -367,8 +367,8 @@ describe('forwarder — TCP→WS framing', () => {
367
367
  await client.opened();
368
368
  client.sendRaw('PING :bare\n');
369
369
 
370
- await target.waitForConnection();
371
- await new Promise<void>((res) => setTimeout(res, 25));
370
+ const ws = await target.waitForConnection();
371
+ await new Promise<void>((res) => ws.once('message', () => res()));
372
372
  expect(target.frames).toEqual(['PING :bare']);
373
373
  });
374
374
 
@@ -1,18 +0,0 @@
1
- <!doctype html>
2
- <!--
3
- Test-only fixture mirroring the real `apps/web/dist/app/index.html` produced
4
- by the Kiwi SPA build (`pnpm --filter web build`). The cf-worker smoke suite
5
- points its `[assets]` binding here so the worker's fall-through routing
6
- (WS → ConnectionDO, /health → plaintext, else → ASSETS) is exercised without
7
- depending on the (slow, submodule-heavy) Kiwi build. Production points at
8
- `apps/web/dist`; this fixture only stands in for the test run.
9
- -->
10
- <html lang="en">
11
- <head>
12
- <meta charset="utf-8" />
13
- <title>KiwiIRC</title>
14
- </head>
15
- <body>
16
- <div id="app"></div>
17
- </body>
18
- </html>
@@ -1,108 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { InMemoryReadMarkerStore, type ReadMarkerStore } from '../src/ports';
3
-
4
- describe('InMemoryReadMarkerStore — port conformance', () => {
5
- it('implements the ReadMarkerStore port', () => {
6
- const store: ReadMarkerStore = new InMemoryReadMarkerStore();
7
- expect(store).toBeDefined();
8
- });
9
- });
10
-
11
- describe('InMemoryReadMarkerStore — set + get', () => {
12
- it('returns undefined for an unknown (account, channel) pair', () => {
13
- const store = new InMemoryReadMarkerStore();
14
- expect(store.get('alice', '#foo')).toBeUndefined();
15
- });
16
-
17
- it('stores and returns the msgid for a known (account, channel) pair', () => {
18
- const store = new InMemoryReadMarkerStore();
19
- store.set('alice', '#foo', 'm1');
20
- expect(store.get('alice', '#foo')).toBe('m1');
21
- });
22
-
23
- it('overwrites the marker on a subsequent set for the same pair', () => {
24
- const store = new InMemoryReadMarkerStore();
25
- store.set('alice', '#foo', 'm1');
26
- store.set('alice', '#foo', 'm2');
27
- expect(store.get('alice', '#foo')).toBe('m2');
28
- });
29
-
30
- it('keys channels case-insensitively (rfc1459 fold)', () => {
31
- const store = new InMemoryReadMarkerStore();
32
- store.set('alice', '#Foo', 'm1');
33
- expect(store.get('alice', '#foo')).toBe('m1');
34
- expect(store.get('alice', '#FOO')).toBe('m1');
35
- });
36
-
37
- it('keys accounts case-insensitively (rfc1459 fold)', () => {
38
- const store = new InMemoryReadMarkerStore();
39
- store.set('Alice', '#foo', 'm1');
40
- expect(store.get('alice', '#foo')).toBe('m1');
41
- expect(store.get('ALICE', '#foo')).toBe('m1');
42
- });
43
-
44
- it('keeps markers for distinct accounts on the same channel separate', () => {
45
- const store = new InMemoryReadMarkerStore();
46
- store.set('alice', '#foo', 'm1');
47
- store.set('bob', '#foo', 'm9');
48
- expect(store.get('alice', '#foo')).toBe('m1');
49
- expect(store.get('bob', '#foo')).toBe('m9');
50
- });
51
-
52
- it('keeps markers for distinct channels on the same account separate', () => {
53
- const store = new InMemoryReadMarkerStore();
54
- store.set('alice', '#foo', 'm1');
55
- store.set('alice', '#bar', 'm2');
56
- expect(store.get('alice', '#foo')).toBe('m1');
57
- expect(store.get('alice', '#bar')).toBe('m2');
58
- });
59
- });
60
-
61
- describe('InMemoryReadMarkerStore — forAccount', () => {
62
- it('returns an empty array for an account with no markers', () => {
63
- const store = new InMemoryReadMarkerStore();
64
- expect(store.forAccount('alice')).toEqual([]);
65
- });
66
-
67
- it('returns every (channel, msgid) pair recorded for the account', () => {
68
- const store = new InMemoryReadMarkerStore();
69
- store.set('alice', '#foo', 'm1');
70
- store.set('alice', '#bar', 'm2');
71
-
72
- const entries = store.forAccount('alice');
73
- expect(entries).toHaveLength(2);
74
- const byChan = Object.fromEntries(entries.map((e) => [e.channel, e.msgid]));
75
- expect(byChan['#foo']).toBe('m1');
76
- expect(byChan['#bar']).toBe('m2');
77
- });
78
-
79
- it('reports the account name on each entry', () => {
80
- const store = new InMemoryReadMarkerStore();
81
- store.set('alice', '#foo', 'm1');
82
- const entries = store.forAccount('alice');
83
- expect(entries[0]?.account).toBe('alice');
84
- });
85
-
86
- it('reflects the most recent set when a pair is overwritten', () => {
87
- const store = new InMemoryReadMarkerStore();
88
- store.set('alice', '#foo', 'm1');
89
- store.set('alice', '#foo', 'm2');
90
- const entries = store.forAccount('alice');
91
- expect(entries).toHaveLength(1);
92
- expect(entries[0]?.msgid).toBe('m2');
93
- });
94
-
95
- it('does not leak markers belonging to a different account', () => {
96
- const store = new InMemoryReadMarkerStore();
97
- store.set('alice', '#foo', 'm1');
98
- store.set('bob', '#bar', 'm2');
99
- expect(store.forAccount('alice')).toHaveLength(1);
100
- expect(store.forAccount('bob')).toHaveLength(1);
101
- });
102
-
103
- it('folds the account name case-insensitively on lookup', () => {
104
- const store = new InMemoryReadMarkerStore();
105
- store.set('Alice', '#foo', 'm1');
106
- expect(store.forAccount('ALICE')).toHaveLength(1);
107
- });
108
- });