serverless-ircd 0.1.0 → 0.3.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 (204) 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/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest';
2
2
  import { handleJoinZero, isValidChannelName, joinReducer } from '../../src/commands/join';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryMessageStore,
9
+ type MessageStore,
10
+ SequentialIdFactory,
11
+ type StoredMessage,
12
+ } from '../../src/ports';
6
13
  import { type ChannelState, createChannel } from '../../src/state/channel';
7
14
  import { type ConnectionState, createConnection } from '../../src/state/connection';
8
15
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
@@ -19,13 +26,18 @@ const serverConfig: ServerConfig = {
19
26
  quitMessage: 'Client Quit',
20
27
  };
21
28
 
22
- function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
29
+ function makeCtx(
30
+ conn: ConnectionState,
31
+ clock = new FakeClock(1_000),
32
+ messages?: MessageStore,
33
+ ): Ctx {
23
34
  return buildCtx({
24
35
  serverConfig,
25
36
  clock,
26
37
  ids: new SequentialIdFactory(),
27
38
  motd: EmptyMotdProvider,
28
39
  connection: conn,
40
+ ...(messages !== undefined ? { messages } : {}),
29
41
  });
30
42
  }
31
43
 
@@ -685,3 +697,235 @@ describe('joinReducer — extended-join', () => {
685
697
  expect(broadcast?.capLines).toBeUndefined();
686
698
  });
687
699
  });
700
+
701
+ // ============================================================================
702
+ // joinReducer — chathistory auto-playback
703
+ // ============================================================================
704
+
705
+ /** Connection negotiated draft/chathistory (+ the deps it implies). */
706
+ function makeCapConn(id = 'c1', nick = 'alice'): ConnectionState {
707
+ const conn = makeConn(id, nick);
708
+ conn.caps.add('draft/chathistory');
709
+ conn.caps.add('batch');
710
+ conn.caps.add('server-time');
711
+ conn.caps.add('message-tags');
712
+ return conn;
713
+ }
714
+
715
+ /** Records N messages into the store as if spoken by `bob`. */
716
+ function seedHistory(store: MessageStore, chan: string, count: number): StoredMessage[] {
717
+ const out: StoredMessage[] = [];
718
+ for (let i = 1; i <= count; i++) {
719
+ const m: StoredMessage = {
720
+ msgid: `m${i}`,
721
+ time: i * 1_000,
722
+ chan: chan.toLowerCase(),
723
+ command: 'PRIVMSG',
724
+ nick: 'bob',
725
+ user: 'bob',
726
+ host: 'ex.org',
727
+ text: `msg ${i}`,
728
+ };
729
+ store.record(m);
730
+ out.push(m);
731
+ }
732
+ return out;
733
+ }
734
+
735
+ describe('joinReducer — chathistory auto-playback', () => {
736
+ it('prepends a chathistory BATCH before the JOIN/353/366 lines for a cap-enabled joiner', () => {
737
+ const chan = makeChan('#foo');
738
+ const store = new InMemoryMessageStore();
739
+ const seeded = seedHistory(store, '#foo', 2);
740
+ const conn = makeCapConn();
741
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
742
+
743
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
744
+
745
+ // The first client-visible line must be the BATCH +start marker.
746
+ const firstSend = out.effects.find(
747
+ (e): e is Extract<EffectType, { tag: 'Send' }> => e.tag === 'Send',
748
+ );
749
+ const firstLine = firstSend?.lines[0]?.text;
750
+ expect(firstLine).toBe('BATCH +batch-0 chathistory #foo');
751
+
752
+ // The chathistory batch body carries the two replayed messages.
753
+ expect(firstSend?.lines.map((l) => l.text)).toEqual([
754
+ 'BATCH +batch-0 chathistory #foo',
755
+ `@time=1970-01-01T00:00:01.000Z;msgid=${seeded[0]?.msgid} :bob!bob@ex.org PRIVMSG #foo :msg 1`,
756
+ `@time=1970-01-01T00:00:02.000Z;msgid=${seeded[1]?.msgid} :bob!bob@ex.org PRIVMSG #foo :msg 2`,
757
+ 'BATCH -batch-0',
758
+ ]);
759
+
760
+ // The JOIN broadcast and NAMES follow the playback Send.
761
+ const tags = out.effects.map((e) => e.tag);
762
+ const sendIdx = tags.indexOf('Send');
763
+ const broadcastIdx = tags.indexOf('Broadcast');
764
+ expect(sendIdx).toBeLessThan(broadcastIdx);
765
+ expect(broadcastIdx).toBeGreaterThan(-1);
766
+ });
767
+
768
+ it('advances the last-read marker to the newest replayed msgid', () => {
769
+ const chan = makeChan('#foo');
770
+ const store = new InMemoryMessageStore();
771
+ seedHistory(store, '#foo', 3);
772
+ const conn = makeCapConn();
773
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
774
+
775
+ joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
776
+
777
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m3');
778
+ });
779
+
780
+ it('does NOT emit playback when the store is empty', () => {
781
+ const chan = makeChan('#foo');
782
+ const store = new InMemoryMessageStore();
783
+ const conn = makeCapConn();
784
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
785
+
786
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
787
+
788
+ // No Send carrying a BATCH chathistory start line.
789
+ const hasPlayback = out.effects.some(
790
+ (e) =>
791
+ e.tag === 'Send' &&
792
+ e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
793
+ );
794
+ expect(hasPlayback).toBe(false);
795
+ });
796
+
797
+ it('does NOT emit playback when the connection lacks the chathistory cap', () => {
798
+ const chan = makeChan('#foo');
799
+ const store = new InMemoryMessageStore();
800
+ seedHistory(store, '#foo', 3);
801
+ const conn = makeConn(); // no chathistory cap
802
+ conn.caps.add('batch');
803
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
804
+
805
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
806
+
807
+ const hasPlayback = out.effects.some(
808
+ (e) =>
809
+ e.tag === 'Send' &&
810
+ e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
811
+ );
812
+ expect(hasPlayback).toBe(false);
813
+ });
814
+
815
+ it('does NOT emit playback when no MessageStore is bound', () => {
816
+ const chan = makeChan('#foo');
817
+ const conn = makeCapConn();
818
+ const ctx = makeCtx(conn); // no store
819
+
820
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
821
+
822
+ const hasPlayback = out.effects.some(
823
+ (e) =>
824
+ e.tag === 'Send' &&
825
+ e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
826
+ );
827
+ expect(hasPlayback).toBe(false);
828
+ });
829
+
830
+ it('replays nothing on re-JOIN when no new messages arrived (marker unchanged)', () => {
831
+ const chan = makeChan('#foo');
832
+ const store = new InMemoryMessageStore();
833
+ seedHistory(store, '#foo', 2);
834
+ const conn = makeCapConn();
835
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
836
+
837
+ // First join: replays both, marker → m2.
838
+ joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
839
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
840
+
841
+ // Simulate PART (roster + joinedChannels cleared; marker persists).
842
+ chan.members.delete('c1');
843
+ conn.joinedChannels.delete('#foo');
844
+
845
+ // Re-JOIN with no new messages: marker still m2 → nothing newer.
846
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
847
+ const hasPlayback = out.effects.some(
848
+ (e) =>
849
+ e.tag === 'Send' &&
850
+ e.lines.some((l) => l.text.startsWith('BATCH +') && l.text.includes('chathistory')),
851
+ );
852
+ expect(hasPlayback).toBe(false);
853
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
854
+ });
855
+
856
+ it('replays exactly the K new messages on re-JOIN after K new messages', () => {
857
+ const chan = makeChan('#foo');
858
+ const store = new InMemoryMessageStore();
859
+ seedHistory(store, '#foo', 2);
860
+ const conn = makeCapConn();
861
+ const ctx = makeCtx(conn, new FakeClock(10_000), store);
862
+
863
+ joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
864
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m2');
865
+
866
+ // PART.
867
+ chan.members.delete('c1');
868
+ conn.joinedChannels.delete('#foo');
869
+
870
+ // Two new messages arrive.
871
+ store.record({
872
+ msgid: 'm3',
873
+ time: 3_000,
874
+ chan: '#foo',
875
+ command: 'PRIVMSG',
876
+ nick: 'bob',
877
+ user: 'bob',
878
+ host: 'ex.org',
879
+ text: 'msg 3',
880
+ });
881
+ store.record({
882
+ msgid: 'm4',
883
+ time: 4_000,
884
+ chan: '#foo',
885
+ command: 'PRIVMSG',
886
+ nick: 'bob',
887
+ user: 'bob',
888
+ host: 'ex.org',
889
+ text: 'msg 4',
890
+ });
891
+
892
+ // Re-JOIN: replay exactly m3, m4 (the K=2 new), then advance marker to m4.
893
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
894
+ const send = out.effects.find(
895
+ (e): e is Extract<EffectType, { tag: 'Send' }> =>
896
+ e.tag === 'Send' && e.lines.some((l) => l.text.includes('chathistory')),
897
+ );
898
+ const msgids = send?.lines
899
+ .filter((l) => l.text.startsWith('@time='))
900
+ .map((l) => l.text.match(/msgid=([^ ;]+)/)?.[1]);
901
+ expect(msgids).toEqual(['m3', 'm4']);
902
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m4');
903
+ });
904
+
905
+ it('respects chatHistoryPlaybackLimit from ServerConfig', () => {
906
+ const chan = makeChan('#foo');
907
+ const store = new InMemoryMessageStore();
908
+ seedHistory(store, '#foo', 5);
909
+ const conn = makeCapConn();
910
+ const ctx = buildCtx({
911
+ serverConfig: { ...serverConfig, chatHistoryPlaybackLimit: 2 },
912
+ clock: new FakeClock(10_000),
913
+ ids: new SequentialIdFactory(),
914
+ motd: EmptyMotdProvider,
915
+ messages: store,
916
+ connection: conn,
917
+ });
918
+
919
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
920
+ const send = out.effects.find(
921
+ (e): e is Extract<EffectType, { tag: 'Send' }> =>
922
+ e.tag === 'Send' && e.lines.some((l) => l.text.includes('chathistory')),
923
+ );
924
+ const msgids = send?.lines
925
+ .filter((l) => l.text.startsWith('@time='))
926
+ .map((l) => l.text.match(/msgid=([^ ;]+)/)?.[1]);
927
+ // Only the most recent 2 (m4, m5) and marker advances to m5.
928
+ expect(msgids).toEqual(['m4', 'm5']);
929
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m5');
930
+ });
931
+ });
@@ -134,6 +134,21 @@ describe('kickReducer — success', () => {
134
134
  );
135
135
  });
136
136
 
137
+ it('resolves the target using rfc1459 case-mapping ([ and { are equal)', () => {
138
+ const chan = makeChan('#foo');
139
+ addMember(chan, 'c1', 'alice', true);
140
+ addMember(chan, 'c2', 'foo[bar]');
141
+ const conn = makeConn('c1', 'alice');
142
+ const ctx = makeCtx(conn);
143
+
144
+ const out = kickReducer(chan, { command: 'KICK', params: ['#foo', 'FOO{BAR}'], tags: {} }, ctx);
145
+
146
+ expect(out.state.members.has('c2')).toBe(false);
147
+ expect(out.effects).toContainEqual<EffectType>(
148
+ Effect.applyChannelDelta('#foo', { memberships: [{ type: 'remove', conn: 'c2' }] }),
149
+ );
150
+ });
151
+
137
152
  it('updates the kicker lastSeen to ctx.clock.now()', () => {
138
153
  const chan = makeChan('#foo');
139
154
  addMember(chan, 'c1', 'alice', true);
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { matchOperCred, operReducer } from '../../src/commands/oper';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const baseServerConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ const serverConfigWithCreds: ServerConfig = {
22
+ ...baseServerConfig,
23
+ operCreds: [
24
+ { user: 'alice', password: 'secret' },
25
+ { user: 'bob', password: 'hunter2' },
26
+ ],
27
+ };
28
+
29
+ function makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWithCreds): Ctx {
30
+ return buildCtx({
31
+ serverConfig: config,
32
+ clock: new FakeClock(5_000),
33
+ ids: new SequentialIdFactory(),
34
+ motd: EmptyMotdProvider,
35
+ connection: state,
36
+ });
37
+ }
38
+
39
+ function makeState(): ConnectionState {
40
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
41
+ s.nick = 'alice';
42
+ s.user = 'alice';
43
+ s.host = 'example.com';
44
+ s.realname = 'Alice';
45
+ s.registration = 'registered';
46
+ return s;
47
+ }
48
+
49
+ const L = (text: string): RawLine => ({ text });
50
+
51
+ const oper = (name?: string, password?: string) =>
52
+ ({
53
+ command: 'OPER',
54
+ params: name === undefined ? [] : password === undefined ? [name] : [name, password],
55
+ tags: {},
56
+ }) as const;
57
+
58
+ describe('operReducer — successful authentication', () => {
59
+ it('sets userModes.oper and emits 381 RPL_YOUREOPER on matching credentials', () => {
60
+ const state = makeState();
61
+ const ctx = makeCtx(state);
62
+
63
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
64
+
65
+ expect(out.state.userModes.oper).toBe(true);
66
+ expect(out.effects).toEqual<EffectType[]>([
67
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
68
+ ]);
69
+ });
70
+
71
+ it('matches the second configured credential pair', () => {
72
+ const state = makeState();
73
+ const ctx = makeCtx(state);
74
+
75
+ const out = operReducer(state, oper('bob', 'hunter2'), ctx);
76
+
77
+ expect(out.state.userModes.oper).toBe(true);
78
+ });
79
+
80
+ it('returns the same state reference (mutation permitted, no copy)', () => {
81
+ const state = makeState();
82
+ const ctx = makeCtx(state);
83
+
84
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
85
+
86
+ expect(out.state).toBe(state);
87
+ });
88
+
89
+ it('updates connection lastSeen to ctx.clock.now()', () => {
90
+ const state = makeState();
91
+ expect(state.lastSeen).toBe(0);
92
+ const ctx = makeCtx(state);
93
+
94
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
95
+
96
+ expect(out.state.lastSeen).toBe(5_000);
97
+ });
98
+
99
+ it('accepts a lower-case command token', () => {
100
+ const state = makeState();
101
+ const ctx = makeCtx(state);
102
+
103
+ const out = operReducer(state, { command: 'oper', params: ['alice', 'secret'], tags: {} }, ctx);
104
+
105
+ expect(out.state.userModes.oper).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe('operReducer — already an operator (graceful no-op)', () => {
110
+ it('re-emits 381 without error when the connection is already oper', () => {
111
+ const state = makeState();
112
+ state.userModes.oper = true;
113
+ const ctx = makeCtx(state);
114
+
115
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
116
+
117
+ expect(out.state.userModes.oper).toBe(true);
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
120
+ ]);
121
+ });
122
+
123
+ it('does not require correct credentials when already oper', () => {
124
+ const state = makeState();
125
+ state.userModes.oper = true;
126
+ const ctx = makeCtx(state);
127
+
128
+ const out = operReducer(state, oper('alice', 'wrong'), ctx);
129
+
130
+ expect(out.state.userModes.oper).toBe(true);
131
+ expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
132
+ // Critical: never a 464 for an already-oper connection.
133
+ for (const e of out.effects) {
134
+ if (e.tag === 'Send') for (const line of e.lines) expect(line.text).not.toContain(' 464 ');
135
+ }
136
+ });
137
+ });
138
+
139
+ describe('operReducer — wrong credentials', () => {
140
+ it('emits 464 ERR_PASSWDMISMATCH for a wrong password', () => {
141
+ const state = makeState();
142
+ const ctx = makeCtx(state);
143
+
144
+ const out = operReducer(state, oper('alice', 'wrong'), ctx);
145
+
146
+ expect(out.state.userModes.oper).toBe(false);
147
+ expect(out.effects).toEqual<EffectType[]>([
148
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
149
+ ]);
150
+ });
151
+
152
+ it('emits 464 ERR_PASSWDMISMATCH for an unknown operator name', () => {
153
+ const state = makeState();
154
+ const ctx = makeCtx(state);
155
+
156
+ const out = operReducer(state, oper('mallory', 'whatever'), ctx);
157
+
158
+ expect(out.state.userModes.oper).toBe(false);
159
+ expect(out.effects).toEqual<EffectType[]>([
160
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
161
+ ]);
162
+ });
163
+ });
164
+
165
+ describe('operReducer — no credentials configured', () => {
166
+ it('emits 491 ERR_NOOPERHOST when operCreds is absent', () => {
167
+ const state = makeState();
168
+ const ctx = makeCtx(state, baseServerConfig);
169
+
170
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
171
+
172
+ expect(out.state.userModes.oper).toBe(false);
173
+ expect(out.effects).toEqual<EffectType[]>([
174
+ Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
175
+ ]);
176
+ });
177
+
178
+ it('emits 491 ERR_NOOPERHOST when operCreds is an empty array', () => {
179
+ const state = makeState();
180
+ const ctx = makeCtx(state, { ...baseServerConfig, operCreds: [] });
181
+
182
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
183
+
184
+ expect(out.state.userModes.oper).toBe(false);
185
+ expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
186
+ for (const e of out.effects) {
187
+ if (e.tag === 'Send') for (const line of e.lines) expect(line.text).toContain(' 491 ');
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('operReducer — missing parameters', () => {
193
+ it('emits 461 ERR_NEEDMOREPARAMS when no parameters are supplied', () => {
194
+ const state = makeState();
195
+ const ctx = makeCtx(state);
196
+
197
+ const out = operReducer(state, oper(), ctx);
198
+
199
+ expect(out.state.userModes.oper).toBe(false);
200
+ expect(out.effects).toEqual<EffectType[]>([
201
+ Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
202
+ ]);
203
+ });
204
+
205
+ it('emits 461 ERR_NEEDMOREPARAMS when only the name is supplied', () => {
206
+ const state = makeState();
207
+ const ctx = makeCtx(state);
208
+
209
+ const out = operReducer(state, oper('alice'), ctx);
210
+
211
+ expect(out.state.userModes.oper).toBe(false);
212
+ expect(out.effects).toEqual<EffectType[]>([
213
+ Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
214
+ ]);
215
+ });
216
+
217
+ it('uses "*" as the nick in 461 when the connection has no nick yet', () => {
218
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
219
+ const ctx = makeCtx(state);
220
+
221
+ const out = operReducer(state, oper(), ctx);
222
+
223
+ expect(out.effects).toEqual<EffectType[]>([
224
+ Effect.send('c1', [L(':irc.example.com 461 * OPER :Not enough parameters')]),
225
+ ]);
226
+ });
227
+ });
228
+
229
+ describe('matchOperCred', () => {
230
+ it('returns true when a credential pair matches exactly', () => {
231
+ const creds = [
232
+ { user: 'alice', password: 'secret' },
233
+ { user: 'bob', password: 'hunter2' },
234
+ ];
235
+ expect(matchOperCred(creds, 'bob', 'hunter2')).toBe(true);
236
+ });
237
+
238
+ it('returns false when the password does not match', () => {
239
+ const creds = [{ user: 'alice', password: 'secret' }];
240
+ expect(matchOperCred(creds, 'alice', 'nope')).toBe(false);
241
+ });
242
+
243
+ it('returns false when no user matches', () => {
244
+ const creds = [{ user: 'alice', password: 'secret' }];
245
+ expect(matchOperCred(creds, 'mallory', 'secret')).toBe(false);
246
+ });
247
+
248
+ it('returns false for an empty credential list', () => {
249
+ expect(matchOperCred([], 'alice', 'secret')).toBe(false);
250
+ });
251
+
252
+ it('does a case-sensitive comparison of both fields', () => {
253
+ const creds = [{ user: 'alice', password: 'secret' }];
254
+ expect(matchOperCred(creds, 'Alice', 'secret')).toBe(false);
255
+ expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);
256
+ });
257
+ });