serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -139,6 +139,77 @@ describe('hostservReducer — dispatch', () => {
139
139
  expect(sentTexts(out.effects).some((t) => t.includes('Unknown command'))).toBe(true);
140
140
  });
141
141
 
142
+ it('hides oper-only commands from a non-oper on empty body', () => {
143
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
144
+ const conn = identifiedConn('bob');
145
+ const ctx = makeCtx(conn, services);
146
+
147
+ const out = hostservReducer(conn, privmsg(''), ctx);
148
+
149
+ const lines = sentTexts(out.effects);
150
+ expect(lines).toContain(
151
+ ':HostServ!HostServ@services NOTICE bob :Available commands: ON, OFF, REQUEST',
152
+ );
153
+ const helpLine = lines.find((t) => t.includes('Available commands'));
154
+ for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
155
+ expect(helpLine).not.toContain(cmd);
156
+ }
157
+ });
158
+
159
+ it('lists the full command set to an oper on empty body', () => {
160
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
161
+ const conn = operConn('admin');
162
+ const ctx = makeCtx(conn, services);
163
+
164
+ const out = hostservReducer(conn, privmsg(''), ctx);
165
+
166
+ expect(sentTexts(out.effects)).toContain(
167
+ ':HostServ!HostServ@services NOTICE admin :Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
168
+ );
169
+ });
170
+
171
+ it('hides oper-only commands from a non-oper on an unknown subcommand', () => {
172
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
173
+ const conn = identifiedConn('bob');
174
+ const ctx = makeCtx(conn, services);
175
+
176
+ const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
177
+
178
+ const lines = sentTexts(out.effects);
179
+ expect(lines).toContain(
180
+ ':HostServ!HostServ@services NOTICE bob :Unknown command. Available: ON, OFF, REQUEST',
181
+ );
182
+ const unknownLine = lines.find((t) => t.includes('Unknown command'));
183
+ for (const cmd of ['SET', 'APPROVE', 'REJECT', 'LIST']) {
184
+ expect(unknownLine).not.toContain(cmd);
185
+ }
186
+ });
187
+
188
+ it('lists the full command set to an oper on an unknown subcommand', () => {
189
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
190
+ const conn = operConn('admin');
191
+ const ctx = makeCtx(conn, services);
192
+
193
+ const out = hostservReducer(conn, privmsg('FOOBAR'), ctx);
194
+
195
+ expect(sentTexts(out.effects)).toContain(
196
+ ':HostServ!HostServ@services NOTICE admin :Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST',
197
+ );
198
+ });
199
+
200
+ it('still rejects a non-oper who guesses an oper-only command (LIST)', () => {
201
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
202
+ services.requestVhost('alice', 'cool.example.net');
203
+ const conn = identifiedConn('bob');
204
+ const ctx = makeCtx(conn, services);
205
+
206
+ const out = hostservReducer(conn, privmsg('LIST'), ctx);
207
+
208
+ expect(sentTexts(out.effects)).toContain(
209
+ ':HostServ!HostServ@services NOTICE bob :You must be an oper to use LIST.',
210
+ );
211
+ });
212
+
142
213
  it('updates lastSeen to ctx.clock.now()', () => {
143
214
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
144
215
  const conn = identifiedConn('alice');
@@ -1064,6 +1064,83 @@ describe('joinReducer — KEEPTOPIC restore from ServicesStore', () => {
1064
1064
  });
1065
1065
  });
1066
1066
 
1067
+ // ============================================================================
1068
+ // joinReducer — TOPIC numerics (332/333) on JOIN
1069
+ // ============================================================================
1070
+ //
1071
+ // RFC 2812 §3.2.1: after a successful JOIN the server sends 332 RPL_TOPIC
1072
+ // and 333 RPL_TOPICWHOTIME whenever a topic is set, regardless of how the
1073
+ // topic reached the authoritative ChannelState (KEEPTOPIC restore, a
1074
+ // deploy snapshot, or an earlier joiner). These tests pin the behaviour
1075
+ // that was previously missing for every non-KEEPTOPIC-restore code path.
1076
+ // ============================================================================
1077
+
1078
+ describe('joinReducer — TOPIC numerics on JOIN', () => {
1079
+ it('emits 332/333 to the joiner when state.topic is already set (deploy snapshot)', () => {
1080
+ const chan = makeChan('#foo');
1081
+ // Simulate a topic that survived a deploy in storage and was loaded
1082
+ // onto the authoritative ChannelState before this JOIN (empty roster,
1083
+ // no KEEPTOPIC restore path taken).
1084
+ chan.topic = { text: 'persisted topic', setter: 'alice!alice@example.com', setAt: 5_000 };
1085
+ const conn = makeConn('c1', 'bob');
1086
+ const ctx = makeCtx(conn);
1087
+
1088
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1089
+
1090
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1091
+ expect(texts).toContain(':irc.example.com 332 bob #foo :persisted topic');
1092
+ expect(
1093
+ texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 5000')),
1094
+ ).toBe(true);
1095
+ });
1096
+
1097
+ it('emits 332/333 to a second joiner when the topic was set by an earlier joiner', () => {
1098
+ const chan = makeChan('#foo');
1099
+ addMember(chan, 'c0', 'alice', true);
1100
+ // Topic set by an earlier joiner on a non-empty channel.
1101
+ chan.topic = { text: 'hello world', setter: 'alice!alice@example.com', setAt: 1_000 };
1102
+ const conn = makeConn('c1', 'bob');
1103
+ const ctx = makeCtx(conn);
1104
+
1105
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1106
+
1107
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1108
+ expect(texts).toContain(':irc.example.com 332 bob #foo :hello world');
1109
+ expect(
1110
+ texts.some((t) => t.startsWith(':irc.example.com 333 bob #foo alice!alice@example.com 1000')),
1111
+ ).toBe(true);
1112
+ });
1113
+
1114
+ it('emits 332/333 after the 353/366 NAMES list (existing emission order)', () => {
1115
+ const chan = makeChan('#foo');
1116
+ chan.topic = { text: 't', setter: 's', setAt: 1 };
1117
+ const conn = makeConn('c1', 'bob');
1118
+ const ctx = makeCtx(conn);
1119
+
1120
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1121
+
1122
+ const send = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1123
+ const idx353 = send.findIndex((t) => t.includes(' 353 '));
1124
+ const idx366 = send.findIndex((t) => t.includes(' 366 '));
1125
+ const idx332 = send.findIndex((t) => t.includes(' 332 '));
1126
+ expect(idx353).toBeGreaterThanOrEqual(0);
1127
+ expect(idx366).toBeGreaterThan(idx353);
1128
+ expect(idx332).toBeGreaterThan(idx366);
1129
+ });
1130
+
1131
+ it('does not emit 332/333 when no topic is set', () => {
1132
+ const chan = makeChan('#foo');
1133
+ const conn = makeConn();
1134
+ const ctx = makeCtx(conn);
1135
+
1136
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1137
+
1138
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
1139
+ expect(texts.some((t) => t.includes(' 332 '))).toBe(false);
1140
+ expect(texts.some((t) => t.includes(' 333 '))).toBe(false);
1141
+ });
1142
+ });
1143
+
1067
1144
  // ============================================================================
1068
1145
  // joinReducer — ChanServ ACCESS auto-op / auto-voice
1069
1146
  // ============================================================================
@@ -1269,6 +1346,108 @@ describe('joinReducer — ChanServ ACCESS auto-prefix', () => {
1269
1346
  expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
1270
1347
  });
1271
1348
 
1349
+ it('does not auto-op the first joiner of an empty registered channel when off the access list', () => {
1350
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1351
+ services.registerNick('alice', 'pw', 'a@e');
1352
+ services.registerNick('bob', 'pw', 'b@e');
1353
+ services.registerChannel('#foo', 'alice');
1354
+ const chan = makeChan('#foo');
1355
+ expect(chan.members.size).toBe(0);
1356
+ const conn = makeConn('c1', 'bob');
1357
+ conn.account = 'bob';
1358
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1359
+
1360
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1361
+
1362
+ const entry = out.state.members.get('c1');
1363
+ expect(entry?.op).toBe(false);
1364
+ expect(entry?.voice).toBe(false);
1365
+ const broadcasts = out.effects.flatMap((e) =>
1366
+ e.tag === 'Broadcast' && e.chan === '#foo' ? e.lines.map((l) => l.text) : [],
1367
+ );
1368
+ expect(broadcasts.some((t) => t.includes('ChanServ MODE'))).toBe(false);
1369
+ expect(broadcasts.some((t) => t.includes('+o bob'))).toBe(false);
1370
+ });
1371
+
1372
+ it('auto-ops the first joiner of an empty registered channel when they hold AUTOOP-level access', () => {
1373
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1374
+ services.registerNick('alice', 'pw', 'a@e');
1375
+ services.registerNick('bob', 'pw', 'b@e');
1376
+ services.registerChannel('#foo', 'alice');
1377
+ services.setChannelAccess('#foo', 'bob', 5);
1378
+ const chan = makeChan('#foo');
1379
+ expect(chan.members.size).toBe(0);
1380
+ const conn = makeConn('c1', 'bob');
1381
+ conn.account = 'bob';
1382
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1383
+
1384
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1385
+
1386
+ const entry = out.state.members.get('c1');
1387
+ expect(entry?.op).toBe(true);
1388
+ });
1389
+
1390
+ it('does not auto-op an unidentified first joiner of an empty registered channel', () => {
1391
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1392
+ services.registerNick('alice', 'pw', 'a@e');
1393
+ services.registerChannel('#foo', 'alice');
1394
+ const chan = makeChan('#foo');
1395
+ expect(chan.members.size).toBe(0);
1396
+ const conn = makeConn('c1', 'bob');
1397
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1398
+
1399
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1400
+
1401
+ const entry = out.state.members.get('c1');
1402
+ expect(entry?.op).toBe(false);
1403
+ });
1404
+
1405
+ it('still auto-ops the founder first-joining an empty registered channel', () => {
1406
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1407
+ services.registerNick('alice', 'pw', 'a@e');
1408
+ services.registerChannel('#foo', 'alice');
1409
+ const chan = makeChan('#foo');
1410
+ expect(chan.members.size).toBe(0);
1411
+ const conn = makeConn('c1', 'alice');
1412
+ conn.account = 'alice';
1413
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1414
+
1415
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1416
+
1417
+ const entry = out.state.members.get('c1');
1418
+ expect(entry?.op).toBe(true);
1419
+ });
1420
+
1421
+ it('auto-ops the first joiner of an empty channel when services are bound but the channel is unregistered', () => {
1422
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1423
+ services.registerNick('bob', 'pw', 'b@e');
1424
+ const chan = makeChan('#foo');
1425
+ expect(chan.members.size).toBe(0);
1426
+ const conn = makeConn('c1', 'bob');
1427
+ conn.account = 'bob';
1428
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, services);
1429
+
1430
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1431
+
1432
+ const entry = out.state.members.get('c1');
1433
+ expect(entry?.op).toBe(true);
1434
+ });
1435
+
1436
+ it('auto-ops the first joiner of an empty channel when no services store is bound', () => {
1437
+ const services = new InMemoryServicesStore({ clock: new FakeClock(2_000) });
1438
+ services.registerNick('bob', 'pw', 'b@e');
1439
+ const chan = makeChan('#foo');
1440
+ expect(chan.members.size).toBe(0);
1441
+ const conn = makeConn('c1', 'bob');
1442
+ conn.account = 'bob';
1443
+ const ctx = makeCtx(conn, new FakeClock(2_000), undefined, undefined);
1444
+
1445
+ const out = joinReducer(chan, { command: 'JOIN', params: ['#foo'], tags: {} }, ctx);
1446
+
1447
+ const entry = out.state.members.get('c1');
1448
+ expect(entry?.op).toBe(true);
1449
+ });
1450
+
1272
1451
  it('does nothing when no services store is bound', () => {
1273
1452
  const chan = makeChan('#foo');
1274
1453
  chan.members.set('c0', { conn: 'c0', nick: 'alice', op: true, voice: false });
@@ -115,6 +115,24 @@ describe('markreadChannelReducer — cap gating', () => {
115
115
  Effect.send('c1', [L(':irc.example.com 421 alice MARKREAD :Unknown command')]),
116
116
  ]);
117
117
  });
118
+
119
+ it('substitutes * for the nick in the 421 numeric when the connection has no nick', () => {
120
+ const chan = makeChan();
121
+ // A bare connection: no nick set and no draft/read-marker cap negotiated.
122
+ // The 421 numeric's `ctx.connection.nick ?? '*'` falls back to `*`.
123
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
124
+ const ctx = makeCtx(conn);
125
+
126
+ const out = markreadChannelReducer(
127
+ chan,
128
+ { command: 'MARKREAD', params: ['#foo', ts(2_000)], tags: {} },
129
+ ctx,
130
+ );
131
+
132
+ expect(out.effects).toEqual<EffectType[]>([
133
+ Effect.send('c1', [L(':irc.example.com 421 * MARKREAD :Unknown command')]),
134
+ ]);
135
+ });
118
136
  });
119
137
 
120
138
  // ============================================================================
@@ -168,6 +186,42 @@ describe('markreadChannelReducer — parameter errors', () => {
168
186
  Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
169
187
  ]);
170
188
  });
189
+
190
+ it('FAILs with INVALID_PARAMS when the timestamp prefix is present but the value is empty', () => {
191
+ const chan = makeChan();
192
+ addMember(chan, 'c1', 'alice');
193
+ const store = new InMemoryMessageStore();
194
+ seed(store, '#foo', 3);
195
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
196
+
197
+ const out = markreadChannelReducer(
198
+ chan,
199
+ { command: 'MARKREAD', params: ['#foo', 'timestamp='], tags: {} },
200
+ ctx,
201
+ );
202
+
203
+ expect(out.effects).toEqual<EffectType[]>([
204
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
205
+ ]);
206
+ });
207
+
208
+ it('FAILs with INVALID_PARAMS when the timestamp value is not a parseable date', () => {
209
+ const chan = makeChan();
210
+ addMember(chan, 'c1', 'alice');
211
+ const store = new InMemoryMessageStore();
212
+ seed(store, '#foo', 3);
213
+ const ctx = makeCtx(makeConn(), new FakeClock(10_000), store);
214
+
215
+ const out = markreadChannelReducer(
216
+ chan,
217
+ { command: 'MARKREAD', params: ['#foo', 'timestamp=not-a-date'], tags: {} },
218
+ ctx,
219
+ );
220
+
221
+ expect(out.effects).toEqual<EffectType[]>([
222
+ Effect.send('c1', [READ_MARKER_FAIL('INVALID_PARAMS', 'Invalid parameters')]),
223
+ ]);
224
+ });
171
225
  });
172
226
 
173
227
  // ============================================================================
@@ -71,6 +71,25 @@ describe('memoservReducer — services availability', () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe('memoservReducer — empty body', () => {
75
+ it('replies with the help notice when the PRIVMSG carries no body parameter', () => {
76
+ const conn = makeConn();
77
+ const services = new InMemoryServicesStore();
78
+ const ctx = makeCtx(conn, services);
79
+ // params: [MEMOSERV_NICK] only — params[1] is undefined, exercising the
80
+ // `msg.params[1] ?? ''` fallback. The empty body resolves to an empty
81
+ // subcommand, which emits the help notice.
82
+ const out = memoservReducer(
83
+ conn,
84
+ { command: 'PRIVMSG', params: [MEMOSERV_NICK], tags: {} },
85
+ ctx,
86
+ );
87
+ expect(sentTexts(out.effects)).toContain(
88
+ ':MemoServ!MemoServ@services NOTICE alice :Available commands: SEND, LIST, READ, DEL',
89
+ );
90
+ });
91
+ });
92
+
74
93
  describe('memoservReducer — SEND', () => {
75
94
  it('requires the sender to be identified', () => {
76
95
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });