serverless-ircd 0.5.0 → 0.6.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 (167) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -140,6 +140,12 @@ describe('generateIsupport — token values', () => {
140
140
  const allNoCap = linesNoCap.flatMap((l) => l.text.split(' '));
141
141
  expect(allNoCap).toContain('SAFELIST');
142
142
  });
143
+
144
+ it('advertises MONITOR=<n> with the default monitor cap ceiling', () => {
145
+ const lines = generateIsupport(makeConfig(), new Set<string>());
146
+ const all = lines.flatMap((l) => l.text.split(' '));
147
+ expect(all).toContain('MONITOR=30');
148
+ });
143
149
  });
144
150
 
145
151
  // ============================================================================
@@ -207,3 +213,77 @@ describe('generateIsupport — capability-aware tokens', () => {
207
213
  expect(lines.length).toBeGreaterThan(0);
208
214
  });
209
215
  });
216
+
217
+ // ============================================================================
218
+ // generateIsupport — cap/mode-derived ISUPPORT tokens
219
+ // ============================================================================
220
+
221
+ describe('generateIsupport — MONITOR token (config-derived)', () => {
222
+ it('derives MONITOR=<n> from serverConfig.monitorLimit when set', () => {
223
+ const lines = generateIsupport(makeConfig({ monitorLimit: 50 }), new Set<string>());
224
+ const all = lines.flatMap((l) => l.text.split(' '));
225
+ expect(all).toContain('MONITOR=50');
226
+ });
227
+
228
+ it('falls back to the default monitor ceiling when monitorLimit is unset', () => {
229
+ const lines = generateIsupport(makeConfig(), new Set<string>());
230
+ const all = lines.flatMap((l) => l.text.split(' '));
231
+ expect(all).toContain('MONITOR=30');
232
+ });
233
+ });
234
+
235
+ describe('generateIsupport — STATUSMSG token', () => {
236
+ it('advertises STATUSMSG=@+ (status-msg prefixes mirror PREFIX)', () => {
237
+ const lines = generateIsupport(makeConfig(), new Set<string>());
238
+ const all = lines.flatMap((l) => l.text.split(' '));
239
+ expect(all).toContain('STATUSMSG=@+');
240
+ });
241
+ });
242
+
243
+ describe('generateIsupport — EXTBAN token (config-derived prefix)', () => {
244
+ it('advertises EXTBAN=<prefix>,q with the default extban prefix', () => {
245
+ const lines = generateIsupport(makeConfig(), new Set<string>());
246
+ const all = lines.flatMap((l) => l.text.split(' '));
247
+ expect(all).toContain('EXTBAN=~,q');
248
+ });
249
+
250
+ it('derives the EXTBAN prefix from serverConfig.extbanPrefix', () => {
251
+ const lines = generateIsupport(makeConfig({ extbanPrefix: '$' }), new Set<string>());
252
+ const all = lines.flatMap((l) => l.text.split(' '));
253
+ expect(all).toContain('EXTBAN=$,q');
254
+ });
255
+ });
256
+
257
+ describe('generateIsupport — ACCOUNTEXTBAN token (services-gated)', () => {
258
+ it('omits ACCOUNTEXTBAN when services are not enabled', () => {
259
+ const lines = generateIsupport(makeConfig(), new Set<string>());
260
+ const all = lines.flatMap((l) => l.text.split(' '));
261
+ expect(all.some((t) => t.startsWith('ACCOUNTEXTBAN'))).toBe(false);
262
+ });
263
+
264
+ it('advertises ACCOUNTEXTBAN=a when services are enabled', () => {
265
+ const lines = generateIsupport(makeConfig({ servicesEnabled: true }), new Set<string>());
266
+ const all = lines.flatMap((l) => l.text.split(' '));
267
+ expect(all).toContain('ACCOUNTEXTBAN=a');
268
+ });
269
+ });
270
+
271
+ describe('generateIsupport — draft cap advertisement tokens', () => {
272
+ it('advertises the TYPING token (draft/typing is supported)', () => {
273
+ const lines = generateIsupport(makeConfig(), new Set<string>());
274
+ const all = lines.flatMap((l) => l.text.split(' '));
275
+ expect(all).toContain('TYPING');
276
+ });
277
+
278
+ it('derives MULTILINE=<n> from serverConfig.multilineMaxBytes (draft/multiline)', () => {
279
+ const lines = generateIsupport(makeConfig({ multilineMaxBytes: 8192 }), new Set<string>());
280
+ const all = lines.flatMap((l) => l.text.split(' '));
281
+ expect(all).toContain('MULTILINE=8192');
282
+ });
283
+
284
+ it('falls back to the default multiline byte budget when multilineMaxBytes is unset', () => {
285
+ const lines = generateIsupport(makeConfig(), new Set<string>());
286
+ const all = lines.flatMap((l) => l.text.split(' '));
287
+ expect(all).toContain('MULTILINE=4096');
288
+ });
289
+ });
@@ -1103,3 +1103,75 @@ describe('userModeReducer — write', () => {
1103
1103
  expect(conn.lastSeen).toBe(11_000);
1104
1104
  });
1105
1105
  });
1106
+
1107
+ // ============================================================================
1108
+ // userModeReducer — read-only mode `S` (TLS connected)
1109
+ // ============================================================================
1110
+
1111
+ describe('userModeReducer — read-only mode `S` (TLS connected)', () => {
1112
+ it('emits 221 with +S when the connection has the tls user mode set', () => {
1113
+ const conn = makeConn();
1114
+ conn.userModes.tls = true;
1115
+ const ctx = makeCtx(conn);
1116
+
1117
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);
1118
+
1119
+ expect(out.effects).toEqual<EffectType[]>([
1120
+ Effect.send('c1', [L(':irc.example.com 221 alice +S')]),
1121
+ ]);
1122
+ });
1123
+
1124
+ it('emits 221 with +iS when invisible and tls are both set', () => {
1125
+ const conn = makeConn();
1126
+ conn.userModes.invisible = true;
1127
+ conn.userModes.tls = true;
1128
+ const ctx = makeCtx(conn);
1129
+
1130
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice'], tags: {} }, ctx);
1131
+
1132
+ expect(out.effects).toEqual<EffectType[]>([
1133
+ Effect.send('c1', [L(':irc.example.com 221 alice +iS')]),
1134
+ ]);
1135
+ });
1136
+
1137
+ it('rejects MODE nick +S with 501 ERR_UMODEUNKNOWNFLAG (read-only)', () => {
1138
+ const conn = makeConn();
1139
+ const ctx = makeCtx(conn);
1140
+
1141
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+S'], tags: {} }, ctx);
1142
+
1143
+ expect(conn.userModes.tls).toBe(false);
1144
+ expect(out.effects).toEqual<EffectType[]>([
1145
+ Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),
1146
+ ]);
1147
+ });
1148
+
1149
+ it('rejects MODE nick -S with 501 ERR_UMODEUNKNOWNFLAG even when tls is set', () => {
1150
+ const conn = makeConn();
1151
+ conn.userModes.tls = true;
1152
+ const ctx = makeCtx(conn);
1153
+
1154
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '-S'], tags: {} }, ctx);
1155
+
1156
+ // The mode stays set: a client cannot clear a transport-owned mode.
1157
+ expect(conn.userModes.tls).toBe(true);
1158
+ expect(out.effects).toEqual<EffectType[]>([
1159
+ Effect.send('c1', [L(':irc.example.com 501 alice :Unknown MODE flag')]),
1160
+ ]);
1161
+ });
1162
+
1163
+ it('does not echo a MODE line when only +S was supplied (rejected, no-op)', () => {
1164
+ const conn = makeConn();
1165
+ const ctx = makeCtx(conn);
1166
+
1167
+ const out = userModeReducer(conn, { command: 'MODE', params: ['alice', '+iS'], tags: {} }, ctx);
1168
+
1169
+ // `i` is applied (and echoed), `S` is rejected with 501. The echo only
1170
+ // carries the applied `+i`; the 501 is emitted alongside it.
1171
+ expect(conn.userModes.invisible).toBe(true);
1172
+ expect(conn.userModes.tls).toBe(false);
1173
+ const texts = out.effects.flatMap((e) => (e.tag === 'Send' ? e.lines : []));
1174
+ expect(texts).toContainEqual(L(':alice!alice@example.com MODE alice +i'));
1175
+ expect(texts).toContainEqual(L(':irc.example.com 501 alice :Unknown MODE flag'));
1176
+ });
1177
+ });
@@ -0,0 +1,411 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { monitorReducer } from '../../src/commands/monitor';
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
+ /** Monitor cap ceiling used in tests; mirrors the production default. */
22
+ const MAX_MONITOR = 30;
23
+
24
+ function makeCtx(state: ConnectionState): Ctx {
25
+ return buildCtx({
26
+ serverConfig: baseServerConfig,
27
+ clock: new FakeClock(5_000),
28
+ ids: new SequentialIdFactory(),
29
+ motd: EmptyMotdProvider,
30
+ connection: state,
31
+ });
32
+ }
33
+
34
+ function makeState(): ConnectionState {
35
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
36
+ s.nick = 'alice';
37
+ s.user = 'alice';
38
+ s.host = 'example.com';
39
+ s.realname = 'Alice';
40
+ s.registration = 'registered';
41
+ return s;
42
+ }
43
+
44
+ const L = (text: string): RawLine => ({ text });
45
+
46
+ const monitor = (sub: string, target?: string) =>
47
+ ({
48
+ command: 'MONITOR',
49
+ params: target === undefined ? [sub] : [sub, target],
50
+ tags: {},
51
+ }) as const;
52
+
53
+ /** Builds an online map from display nicks (auto-folds each key). */
54
+ const onlineMap = (...nicks: string[]): ReadonlyMap<string, string> =>
55
+ new Map(nicks.map((n) => [n.toLowerCase(), n]));
56
+
57
+ describe('monitorReducer — MONITOR + (add)', () => {
58
+ it('emits 731 RPL_MONOFFLINE for an offline nick', () => {
59
+ const state = makeState();
60
+ const ctx = makeCtx(state);
61
+
62
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);
63
+
64
+ expect(out.effects).toEqual<EffectType[]>([
65
+ Effect.send('c1', [L(':irc.example.com 731 alice :bob')]),
66
+ ]);
67
+ expect(state.monitorList?.has('bob')).toBe(true);
68
+ });
69
+
70
+ it('emits 730 RPL_MONONLINE for an online nick', () => {
71
+ const state = makeState();
72
+ const ctx = makeCtx(state);
73
+
74
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);
75
+
76
+ expect(out.effects).toEqual<EffectType[]>([
77
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob')]),
78
+ ]);
79
+ expect(state.monitorList?.has('bob')).toBe(true);
80
+ });
81
+
82
+ it('handles a comma-separated add list, splitting online/offline into 730/731', () => {
83
+ const state = makeState();
84
+ const ctx = makeCtx(state);
85
+
86
+ const out = monitorReducer(
87
+ onlineMap('bob', 'carol'),
88
+ MAX_MONITOR,
89
+ monitor('+', 'bob,carol,dave'),
90
+ ctx,
91
+ );
92
+
93
+ // Two separate effects: 730 with online nicks, then 731 with offline.
94
+ expect(out.effects).toEqual<EffectType[]>([
95
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),
96
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave')]),
97
+ ]);
98
+ expect(Array.from(state.monitorList ?? []).sort()).toEqual(['bob', 'carol', 'dave']);
99
+ });
100
+
101
+ it('omits the 730 line when no added nick is online', () => {
102
+ const state = makeState();
103
+ const ctx = makeCtx(state);
104
+
105
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'dave,eve'), ctx);
106
+
107
+ expect(out.effects).toEqual<EffectType[]>([
108
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave,eve')]),
109
+ ]);
110
+ });
111
+
112
+ it('omits the 731 line when every added nick is online', () => {
113
+ const state = makeState();
114
+ const ctx = makeCtx(state);
115
+
116
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);
117
+
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob')]),
120
+ ]);
121
+ });
122
+
123
+ it('skips nicks already in the watchlist without re-emitting their status', () => {
124
+ const state = makeState();
125
+ state.monitorList = new Set(['bob']);
126
+ const ctx = makeCtx(state);
127
+
128
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('+', 'bob'), ctx);
129
+
130
+ // Already-monitored nick produces no output (per ircv3 MONITOR spec).
131
+ expect(out.effects).toEqual<EffectType[]>([]);
132
+ });
133
+
134
+ it('reports the registered display spelling, not the requester spelling', () => {
135
+ const state = makeState();
136
+ const ctx = makeCtx(state);
137
+
138
+ const out = monitorReducer(onlineMap('Bob'), MAX_MONITOR, monitor('+', 'BOB'), ctx);
139
+
140
+ expect(out.effects).toEqual<EffectType[]>([
141
+ Effect.send('c1', [L(':irc.example.com 730 alice :Bob')]),
142
+ ]);
143
+ // Stored in the folded form for case-insensitive membership tests.
144
+ expect(state.monitorList?.has('bob')).toBe(true);
145
+ });
146
+
147
+ it('emits 734 ERR_MONLISTFULL when adding would exceed the cap, naming the rejected nicks', () => {
148
+ const state = makeState();
149
+ // Pre-fill the list to the cap.
150
+ state.monitorList = new Set(Array.from({ length: MAX_MONITOR }, (_, i) => `nick${i}`));
151
+ const ctx = makeCtx(state);
152
+
153
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob,carol'), ctx);
154
+
155
+ expect(out.effects).toEqual<EffectType[]>([
156
+ Effect.send('c1', [L(`:irc.example.com 734 alice ${MAX_MONITOR} :bob,carol`)]),
157
+ ]);
158
+ // Cap unchanged.
159
+ expect(state.monitorList?.size).toBe(MAX_MONITOR);
160
+ });
161
+
162
+ it('partially admits an oversized add list up to the cap and 734s the rest', () => {
163
+ const state = makeState();
164
+ state.monitorList = new Set(Array.from({ length: MAX_MONITOR - 1 }, (_, i) => `nick${i}`));
165
+ const ctx = makeCtx(state);
166
+
167
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob,carol'), ctx);
168
+
169
+ // First nick admitted (offline) → 731; second rejected → 734.
170
+ expect(out.effects).toEqual<EffectType[]>([
171
+ Effect.send('c1', [L(':irc.example.com 731 alice :bob')]),
172
+ Effect.send('c1', [L(`:irc.example.com 734 alice ${MAX_MONITOR} :carol`)]),
173
+ ]);
174
+ expect(state.monitorList?.has('bob')).toBe(true);
175
+ expect(state.monitorList?.has('carol')).toBe(false);
176
+ });
177
+ });
178
+
179
+ describe('monitorReducer — MONITOR - (remove)', () => {
180
+ it('removes nicks from the watchlist silently (no numeric output)', () => {
181
+ const state = makeState();
182
+ state.monitorList = new Set(['bob', 'carol']);
183
+ const ctx = makeCtx(state);
184
+
185
+ const out = monitorReducer(onlineMap('bob'), MAX_MONITOR, monitor('-', 'bob'), ctx);
186
+
187
+ expect(out.effects).toEqual<EffectType[]>([]);
188
+ expect(state.monitorList?.has('bob')).toBe(false);
189
+ expect(state.monitorList?.has('carol')).toBe(true);
190
+ });
191
+
192
+ it('removes multiple comma-separated nicks', () => {
193
+ const state = makeState();
194
+ state.monitorList = new Set(['bob', 'carol', 'dave']);
195
+ const ctx = makeCtx(state);
196
+
197
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'bob,dave'), ctx);
198
+
199
+ expect(out.effects).toEqual<EffectType[]>([]);
200
+ expect(state.monitorList?.has('bob')).toBe(false);
201
+ expect(state.monitorList?.has('carol')).toBe(true);
202
+ expect(state.monitorList?.has('dave')).toBe(false);
203
+ });
204
+
205
+ it('is a no-op for a nick that was not being monitored', () => {
206
+ const state = makeState();
207
+ state.monitorList = new Set(['bob']);
208
+ const ctx = makeCtx(state);
209
+
210
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'ghost'), ctx);
211
+
212
+ expect(out.effects).toEqual<EffectType[]>([]);
213
+ expect(state.monitorList?.has('bob')).toBe(true);
214
+ });
215
+
216
+ it('is a no-op when no watchlist has been allocated yet', () => {
217
+ const state = makeState();
218
+ const ctx = makeCtx(state);
219
+
220
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-', 'bob'), ctx);
221
+
222
+ expect(out.effects).toEqual<EffectType[]>([]);
223
+ expect(state.monitorList).toBeUndefined();
224
+ });
225
+
226
+ it('is a no-op when the target list is empty (`MONITOR -` with no args)', () => {
227
+ const state = makeState();
228
+ state.monitorList = new Set(['bob']);
229
+ const ctx = makeCtx(state);
230
+
231
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('-'), ctx);
232
+
233
+ expect(out.effects).toEqual<EffectType[]>([]);
234
+ expect(state.monitorList?.has('bob')).toBe(true);
235
+ });
236
+ });
237
+
238
+ describe('monitorReducer — MONITOR C (clear)', () => {
239
+ it('empties the entire watchlist silently', () => {
240
+ const state = makeState();
241
+ state.monitorList = new Set(['bob', 'carol', 'dave']);
242
+ const ctx = makeCtx(state);
243
+
244
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('C'), ctx);
245
+
246
+ expect(out.effects).toEqual<EffectType[]>([]);
247
+ expect(state.monitorList?.size).toBe(0);
248
+ });
249
+
250
+ it('is a no-op when the watchlist is already empty', () => {
251
+ const state = makeState();
252
+ const ctx = makeCtx(state);
253
+
254
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('C'), ctx);
255
+
256
+ expect(out.effects).toEqual<EffectType[]>([]);
257
+ expect(state.monitorList?.size).toBe(0);
258
+ });
259
+ });
260
+
261
+ describe('monitorReducer — MONITOR L (list)', () => {
262
+ it('emits 732 RPL_MONLIST per line then 733 RPL_ENDOFMONLIST', () => {
263
+ const state = makeState();
264
+ state.monitorList = new Set(['bob', 'carol']);
265
+ const ctx = makeCtx(state);
266
+
267
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);
268
+
269
+ // Nicks are listed in their stored display form (which is folded, so
270
+ // lowercase here). Order is the Set's insertion order.
271
+ const sends = out.effects.map((e) => (e as { lines: RawLine[] }).lines[0]?.text);
272
+ expect(sends).toContain(':irc.example.com 732 alice :bob');
273
+ expect(sends).toContain(':irc.example.com 732 alice :carol');
274
+ expect(sends[sends.length - 1]).toBe(':irc.example.com 733 alice :End of MONITOR list');
275
+ });
276
+
277
+ it('emits only 733 when the watchlist is empty', () => {
278
+ const state = makeState();
279
+ const ctx = makeCtx(state);
280
+
281
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);
282
+
283
+ expect(out.effects).toEqual<EffectType[]>([
284
+ Effect.send('c1', [L(':irc.example.com 733 alice :End of MONITOR list')]),
285
+ ]);
286
+ });
287
+ });
288
+
289
+ describe('monitorReducer — MONITOR S (status)', () => {
290
+ it('emits 730 for online monitored nicks and 731 for offline ones', () => {
291
+ const state = makeState();
292
+ state.monitorList = new Set(['bob', 'carol', 'dave']);
293
+ const ctx = makeCtx(state);
294
+
295
+ const out = monitorReducer(onlineMap('bob', 'carol'), MAX_MONITOR, monitor('S'), ctx);
296
+
297
+ expect(out.effects).toEqual<EffectType[]>([
298
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),
299
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave')]),
300
+ ]);
301
+ });
302
+
303
+ it('emits only 730 when every monitored nick is online', () => {
304
+ const state = makeState();
305
+ state.monitorList = new Set(['bob', 'carol']);
306
+ const ctx = makeCtx(state);
307
+
308
+ const out = monitorReducer(onlineMap('bob', 'carol'), MAX_MONITOR, monitor('S'), ctx);
309
+
310
+ expect(out.effects).toEqual<EffectType[]>([
311
+ Effect.send('c1', [L(':irc.example.com 730 alice :bob,carol')]),
312
+ ]);
313
+ });
314
+
315
+ it('emits only 731 when every monitored nick is offline', () => {
316
+ const state = makeState();
317
+ state.monitorList = new Set(['dave', 'eve']);
318
+ const ctx = makeCtx(state);
319
+
320
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('S'), ctx);
321
+
322
+ expect(out.effects).toEqual<EffectType[]>([
323
+ Effect.send('c1', [L(':irc.example.com 731 alice :dave,eve')]),
324
+ ]);
325
+ });
326
+
327
+ it('emits nothing when the watchlist is empty', () => {
328
+ const state = makeState();
329
+ const ctx = makeCtx(state);
330
+
331
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('S'), ctx);
332
+
333
+ expect(out.effects).toEqual<EffectType[]>([]);
334
+ });
335
+ });
336
+
337
+ describe('monitorReducer — validation & state', () => {
338
+ it('uses * as nick placeholder for unregistered connections', () => {
339
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
340
+ const ctx = makeCtx(state);
341
+
342
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);
343
+
344
+ expect(out.effects).toEqual<EffectType[]>([
345
+ Effect.send('c1', [L(':irc.example.com 731 * :bob')]),
346
+ ]);
347
+ });
348
+
349
+ it('updates lastSeen to ctx.clock.now()', () => {
350
+ const state = makeState();
351
+ const ctx = makeCtx(state);
352
+
353
+ monitorReducer(new Map(), MAX_MONITOR, monitor('+', 'bob'), ctx);
354
+
355
+ expect(state.lastSeen).toBe(5_000);
356
+ });
357
+
358
+ it('returns the same state reference', () => {
359
+ const state = makeState();
360
+ const ctx = makeCtx(state);
361
+
362
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('L'), ctx);
363
+
364
+ expect(out.state).toBe(state);
365
+ });
366
+
367
+ it('rejects an unknown subcommand with 461 ERR_NEEDMOREPARAMS', () => {
368
+ const state = makeState();
369
+ const ctx = makeCtx(state);
370
+
371
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('Z'), ctx);
372
+
373
+ expect(out.effects).toEqual<EffectType[]>([
374
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Unknown command')]),
375
+ ]);
376
+ });
377
+
378
+ it('rejects MONITOR + with no nick argument via 461 ERR_NEEDMOREPARAMS', () => {
379
+ const state = makeState();
380
+ const ctx = makeCtx(state);
381
+
382
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor('+'), ctx);
383
+
384
+ expect(out.effects).toEqual<EffectType[]>([
385
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),
386
+ ]);
387
+ });
388
+
389
+ it('treats MONITOR (no sub) as 461 ERR_NEEDMOREPARAMS', () => {
390
+ const state = makeState();
391
+ const ctx = makeCtx(state);
392
+
393
+ const out = monitorReducer(new Map(), MAX_MONITOR, monitor(''), ctx);
394
+
395
+ expect(out.effects).toEqual<EffectType[]>([
396
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),
397
+ ]);
398
+ });
399
+
400
+ it('treats MONITOR with no params array at all as 461 ERR_NEEDMOREPARAMS', () => {
401
+ const state = makeState();
402
+ const ctx = makeCtx(state);
403
+
404
+ // Mirrors the parser shape for a bare `MONITOR` with no subcommand.
405
+ const out = monitorReducer(new Map(), MAX_MONITOR, { params: [] }, ctx);
406
+
407
+ expect(out.effects).toEqual<EffectType[]>([
408
+ Effect.send('c1', [L(':irc.example.com 461 alice MONITOR :Not enough parameters')]),
409
+ ]);
410
+ });
411
+ });