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
@@ -6,8 +6,10 @@ import {
6
6
  EmptyMotdProvider,
7
7
  FakeClock,
8
8
  InMemoryMessageStore,
9
+ InMemoryServicesStore,
9
10
  type MessageStore,
10
11
  SequentialIdFactory,
12
+ type ServicesStore,
11
13
  type StoredMessage,
12
14
  } from '../../src/ports';
13
15
  import { type ChannelState, createChannel } from '../../src/state/channel';
@@ -30,6 +32,7 @@ function makeCtx(
30
32
  conn: ConnectionState,
31
33
  clock = new FakeClock(1_000),
32
34
  messages?: MessageStore,
35
+ services?: ServicesStore,
33
36
  ): Ctx {
34
37
  return buildCtx({
35
38
  serverConfig,
@@ -38,6 +41,7 @@ function makeCtx(
38
41
  motd: EmptyMotdProvider,
39
42
  connection: conn,
40
43
  ...(messages !== undefined ? { messages } : {}),
44
+ ...(services !== undefined ? { services } : {}),
41
45
  });
42
46
  }
43
47
 
@@ -126,7 +130,7 @@ describe('joinReducer — success', () => {
126
130
 
127
131
  expect(out.effects).toEqual<EffectType[]>([
128
132
  Effect.applyChannelDelta('#foo', {
129
- memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true }],
133
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice', op: true, voice: false }],
130
134
  }),
131
135
  Effect.broadcast(
132
136
  '#foo',
@@ -929,3 +933,391 @@ describe('joinReducer — chathistory auto-playback', () => {
929
933
  expect(conn.lastReadMarkers?.get('#foo')).toBe('m5');
930
934
  });
931
935
  });
936
+
937
+ // ============================================================================
938
+ // joinReducer — ChanServ +R (block unidentified join)
939
+ // ============================================================================
940
+
941
+ describe('joinReducer — +R (block unidentified join)', () => {
942
+ it('rejects an unidentified joiner with 519 ERR_ALLMUSTREGISTER', () => {
943
+ const chan = makeChan('#foo');
944
+ chan.modes.blockUnidentified = true;
945
+ const conn = makeConn('c1', 'guest');
946
+ // guest has no account set
947
+ const ctx = makeCtx(conn);
948
+
949
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
950
+
951
+ expect(out.state.members.has('c1')).toBe(false);
952
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
953
+ expect(texts).toContain(
954
+ ':irc.example.com 519 guest #foo :You must register with NickServ to join this channel',
955
+ );
956
+ });
957
+
958
+ it('admits an identified joiner (account set)', () => {
959
+ const chan = makeChan('#foo');
960
+ chan.modes.blockUnidentified = true;
961
+ const conn = makeConn('c1', 'alice');
962
+ conn.account = 'alice';
963
+ const ctx = makeCtx(conn);
964
+
965
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
966
+
967
+ expect(out.state.members.has('c1')).toBe(true);
968
+ });
969
+ });
970
+
971
+ // ============================================================================
972
+ // joinReducer — ChanServ KEEPTOPIC restore
973
+ // ============================================================================
974
+
975
+ describe('joinReducer — KEEPTOPIC restore from ServicesStore', () => {
976
+ it('restores the persisted topic on the first join after empty-recreate', () => {
977
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
978
+ services.registerNick('alice', 'pw', 'a@e');
979
+ services.registerChannel('#foo', 'alice');
980
+ services.setChannelKeepTopic('#foo', true);
981
+ services.setChannelTopic('#foo', {
982
+ text: 'remembered topic',
983
+ setter: 'bob!bob@example.org',
984
+ setAt: 1_000,
985
+ });
986
+ const chan = makeChan('#foo');
987
+ // First joiner: members is empty.
988
+ expect(chan.members.size).toBe(0);
989
+ const conn = makeConn('c1', 'alice');
990
+ conn.account = 'alice';
991
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
992
+
993
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
994
+
995
+ expect(out.state.topic?.text).toBe('remembered topic');
996
+ expect(out.state.topic?.setter).toBe('bob!bob@example.org');
997
+ expect(out.state.topic?.setAt).toBe(1_000);
998
+ // The 332/333 numerics are emitted to the joiner.
999
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1000
+ expect(texts).toContain(':irc.example.com 332 alice #foo :remembered topic');
1001
+ expect(
1002
+ texts.some((t) => t.startsWith(':irc.example.com 333 alice #foo bob!bob@example.org 1000')),
1003
+ ).toBe(true);
1004
+ });
1005
+
1006
+ it('does not restore a topic when the channel has existing members', () => {
1007
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1008
+ services.registerNick('alice', 'pw', 'a@e');
1009
+ services.registerChannel('#foo', 'alice');
1010
+ services.setChannelKeepTopic('#foo', true);
1011
+ services.setChannelTopic('#foo', { text: 'stale', setter: 's', setAt: 0 });
1012
+ const chan = makeChan('#foo');
1013
+ // Pre-existing member.
1014
+ chan.members.set('c0', { conn: 'c0', nick: 'bob', op: true, voice: false });
1015
+ const conn = makeConn('c1', 'alice');
1016
+ conn.account = 'alice';
1017
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1018
+
1019
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1020
+
1021
+ expect(out.state.topic).toBeUndefined();
1022
+ });
1023
+
1024
+ it('does not restore a topic when no services store is bound', () => {
1025
+ const chan = makeChan('#foo');
1026
+ const conn = makeConn('c1', 'alice');
1027
+ const ctx = makeCtx(conn);
1028
+
1029
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1030
+
1031
+ expect(out.state.topic).toBeUndefined();
1032
+ });
1033
+
1034
+ it('does not restore a topic when KEEPTOPIC is off', () => {
1035
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1036
+ services.registerNick('alice', 'pw', 'a@e');
1037
+ services.registerChannel('#foo', 'alice');
1038
+ // keepTopic defaults to false; do not enable it.
1039
+ services.setChannelTopic('#foo', { text: 'nope', setter: 's', setAt: 0 });
1040
+ const chan = makeChan('#foo');
1041
+ const conn = makeConn('c1', 'alice');
1042
+ conn.account = 'alice';
1043
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1044
+
1045
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1046
+
1047
+ expect(out.state.topic).toBeUndefined();
1048
+ });
1049
+
1050
+ it('does not restore when KEEPTOPIC is on but no topic snapshot is stored', () => {
1051
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1052
+ services.registerNick('alice', 'pw', 'a@e');
1053
+ services.registerChannel('#foo', 'alice');
1054
+ services.setChannelKeepTopic('#foo', true);
1055
+ // No setChannelTopic call.
1056
+ const chan = makeChan('#foo');
1057
+ const conn = makeConn('c1', 'alice');
1058
+ conn.account = 'alice';
1059
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1060
+
1061
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1062
+
1063
+ expect(out.state.topic).toBeUndefined();
1064
+ });
1065
+ });
1066
+
1067
+ // ============================================================================
1068
+ // joinReducer — ChanServ ACCESS auto-op / auto-voice
1069
+ // ============================================================================
1070
+
1071
+ describe('joinReducer — ChanServ ACCESS auto-prefix', () => {
1072
+ it('AOP accounts are auto-opped on JOIN', () => {
1073
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1074
+ services.registerNick('alice', 'pw', 'a@e');
1075
+ services.registerNick('bob', 'pw', 'b@e');
1076
+ services.registerChannel('#foo', 'alice');
1077
+ // bob holds the AOP default level.
1078
+ services.setChannelAccess('#foo', 'bob', 5);
1079
+ const chan = makeChan('#foo');
1080
+ // Pre-existing op so the founder-first-op path does not interfere.
1081
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1082
+ const conn = makeConn('c1', 'bob');
1083
+ conn.account = 'bob';
1084
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1085
+
1086
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1087
+
1088
+ // The authoritative roster reflects the auto-op.
1089
+ const entry = out.state.members.get('c1');
1090
+ expect(entry?.op).toBe(true);
1091
+ // ChanServ broadcasts `:ChanServ MODE #foo +o bob` so other clients update.
1092
+ const broadcasts = out.effects.flatMap((e) =>
1093
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1094
+ );
1095
+ expect(broadcasts.some((t) => t.includes('MODE #foo +o bob'))).toBe(true);
1096
+ // The joiner's roster mirror also carries op.
1097
+ const applyDeltas = out.effects.filter((e) => e.tag === 'ApplyChannelDelta');
1098
+ expect(applyDeltas.length).toBeGreaterThan(0);
1099
+ for (const e of applyDeltas) {
1100
+ if (e.tag === 'ApplyChannelDelta') {
1101
+ const added = (e.delta.memberships ?? []).find((m) => m.conn === 'c1');
1102
+ expect(added?.op).toBe(true);
1103
+ }
1104
+ }
1105
+ });
1106
+
1107
+ it('VOP accounts are auto-voiced on JOIN', () => {
1108
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1109
+ services.registerNick('alice', 'pw', 'a@e');
1110
+ services.registerNick('bob', 'pw', 'b@e');
1111
+ services.registerChannel('#foo', 'alice');
1112
+ services.setChannelAccess('#foo', 'bob', 3);
1113
+ const chan = makeChan('#foo');
1114
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1115
+ const conn = makeConn('c1', 'bob');
1116
+ conn.account = 'bob';
1117
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1118
+
1119
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1120
+
1121
+ const entry = out.state.members.get('c1');
1122
+ expect(entry?.op).toBe(false);
1123
+ expect(entry?.voice).toBe(true);
1124
+ const broadcasts = out.effects.flatMap((e) =>
1125
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1126
+ );
1127
+ expect(broadcasts.some((t) => t.includes('MODE #foo +v bob'))).toBe(true);
1128
+ });
1129
+
1130
+ it('AOP-level accounts are also voiced (op supersedes voice but the +v is not emitted)', () => {
1131
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1132
+ services.registerNick('alice', 'pw', 'a@e');
1133
+ services.registerNick('bob', 'pw', 'b@e');
1134
+ services.registerChannel('#foo', 'alice');
1135
+ services.setChannelAccess('#foo', 'bob', 5);
1136
+ const chan = makeChan('#foo');
1137
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1138
+ const conn = makeConn('c1', 'bob');
1139
+ conn.account = 'bob';
1140
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1141
+
1142
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1143
+
1144
+ const broadcasts = out.effects.flatMap((e) =>
1145
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1146
+ );
1147
+ // +o emitted; +v is not (op already grants voice speak rights).
1148
+ expect(broadcasts.some((t) => t.includes('MODE #foo +o bob'))).toBe(true);
1149
+ expect(broadcasts.some((t) => t.includes('+v'))).toBe(false);
1150
+ });
1151
+
1152
+ it('accounts with no access entry get no auto prefix', () => {
1153
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1154
+ services.registerNick('alice', 'pw', 'a@e');
1155
+ services.registerNick('bob', 'pw', 'b@e');
1156
+ services.registerChannel('#foo', 'alice');
1157
+ const chan = makeChan('#foo');
1158
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1159
+ const conn = makeConn('c1', 'bob');
1160
+ conn.account = 'bob';
1161
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1162
+
1163
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1164
+
1165
+ const entry = out.state.members.get('c1');
1166
+ expect(entry?.op).toBe(false);
1167
+ expect(entry?.voice).toBe(false);
1168
+ const broadcasts = out.effects.flatMap((e) =>
1169
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1170
+ );
1171
+ expect(broadcasts.some((t) => t.includes('MODE #foo'))).toBe(false);
1172
+ });
1173
+
1174
+ it('unidentified joiners get no auto prefix even with an entry', () => {
1175
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1176
+ services.registerNick('alice', 'pw', 'a@e');
1177
+ services.registerNick('bob', 'pw', 'b@e');
1178
+ services.registerChannel('#foo', 'alice');
1179
+ services.setChannelAccess('#foo', 'bob', 5);
1180
+ const chan = makeChan('#foo');
1181
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1182
+ const conn = makeConn('c1', 'bob');
1183
+ // No conn.account set.
1184
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1185
+
1186
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1187
+
1188
+ const entry = out.state.members.get('c1');
1189
+ expect(entry?.op).toBe(false);
1190
+ expect(entry?.voice).toBe(false);
1191
+ });
1192
+
1193
+ it('LEVELS SET AUTOOP raises the auto-op threshold', () => {
1194
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1195
+ services.registerNick('alice', 'pw', 'a@e');
1196
+ services.registerNick('bob', 'pw', 'b@e');
1197
+ services.registerChannel('#foo', 'alice');
1198
+ // bob holds AOP (5) but the founder raised AUTOOP to 7.
1199
+ services.setChannelAccess('#foo', 'bob', 5);
1200
+ services.setChannelLevel('#foo', 'AUTOOP', 7);
1201
+ const chan = makeChan('#foo');
1202
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1203
+ const conn = makeConn('c1', 'bob');
1204
+ conn.account = 'bob';
1205
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1206
+
1207
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1208
+
1209
+ const entry = out.state.members.get('c1');
1210
+ expect(entry?.op).toBe(false);
1211
+ expect(entry?.voice).toBe(true);
1212
+ });
1213
+
1214
+ it('LEVELS SET AUTOVOICE raises the auto-voice threshold', () => {
1215
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1216
+ services.registerNick('alice', 'pw', 'a@e');
1217
+ services.registerNick('bob', 'pw', 'b@e');
1218
+ services.registerChannel('#foo', 'alice');
1219
+ // bob holds level 3 but the founder raised AUTOVOICE to 5.
1220
+ services.setChannelAccess('#foo', 'bob', 3);
1221
+ services.setChannelLevel('#foo', 'AUTOVOICE', 5);
1222
+ const chan = makeChan('#foo');
1223
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1224
+ const conn = makeConn('c1', 'bob');
1225
+ conn.account = 'bob';
1226
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1227
+
1228
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1229
+
1230
+ const entry = out.state.members.get('c1');
1231
+ expect(entry?.op).toBe(false);
1232
+ expect(entry?.voice).toBe(false);
1233
+ });
1234
+
1235
+ it('the founder is auto-opped regardless of the access list', () => {
1236
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1237
+ services.registerNick('alice', 'pw', 'a@e');
1238
+ services.registerChannel('#foo', 'alice');
1239
+ const chan = makeChan('#foo');
1240
+ chan.members.set('c0', { conn: 'c0', nick: 'someone', op: true, voice: false });
1241
+ const conn = makeConn('c1', 'alice');
1242
+ conn.account = 'alice';
1243
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1244
+
1245
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1246
+
1247
+ const entry = out.state.members.get('c1');
1248
+ expect(entry?.op).toBe(true);
1249
+ });
1250
+
1251
+ it('the first joiner (channel empty) keeps the RFC 2812 founder-op and skips ChanServ MODE', () => {
1252
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1253
+ services.registerNick('alice', 'pw', 'a@e');
1254
+ services.registerChannel('#foo', 'alice');
1255
+ const chan = makeChan('#foo');
1256
+ expect(chan.members.size).toBe(0);
1257
+ const conn = makeConn('c1', 'alice');
1258
+ conn.account = 'alice';
1259
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1260
+
1261
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1262
+
1263
+ const entry = out.state.members.get('c1');
1264
+ expect(entry?.op).toBe(true);
1265
+ // The first-op path emits no ChanServ MODE broadcast.
1266
+ const broadcasts = out.effects.flatMap((e) =>
1267
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1268
+ );
1269
+ expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
1270
+ });
1271
+
1272
+ it('does nothing when no services store is bound', () => {
1273
+ const chan = makeChan('#foo');
1274
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1275
+ const conn = makeConn('c1', 'bob');
1276
+ conn.account = 'bob';
1277
+ const ctx = makeCtx(conn);
1278
+
1279
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1280
+
1281
+ const entry = out.state.members.get('c1');
1282
+ expect(entry?.op).toBe(false);
1283
+ expect(entry?.voice).toBe(false);
1284
+ });
1285
+
1286
+ it('does nothing when the channel is not registered', () => {
1287
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1288
+ services.registerNick('bob', 'pw', 'b@e');
1289
+ const chan = makeChan('#foo');
1290
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1291
+ const conn = makeConn('c1', 'bob');
1292
+ conn.account = 'bob';
1293
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1294
+
1295
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1296
+
1297
+ const entry = out.state.members.get('c1');
1298
+ expect(entry?.op).toBe(false);
1299
+ expect(entry?.voice).toBe(false);
1300
+ });
1301
+
1302
+ it('auto-op broadcasts use the ChanServ pseudo-client source', () => {
1303
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1304
+ services.registerNick('alice', 'pw', 'a@e');
1305
+ services.registerNick('bob', 'pw', 'b@e');
1306
+ services.registerChannel('#foo', 'alice');
1307
+ services.setChannelAccess('#foo', 'bob', 5);
1308
+ const chan = makeChan('#foo');
1309
+ chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
1310
+ const conn = makeConn('c1', 'bob');
1311
+ conn.account = 'bob';
1312
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1313
+
1314
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1315
+
1316
+ const broadcasts = out.effects.flatMap((e) =>
1317
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1318
+ );
1319
+ expect(
1320
+ broadcasts.some((t) => t.startsWith(':ChanServ!ChanServ@services MODE #foo +o bob')),
1321
+ ).toBe(true);
1322
+ });
1323
+ });