serverless-ircd 0.1.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 (165) hide show
  1. package/.github/workflows/ci.yml +47 -0
  2. package/.github/workflows/deploy-cf.yml +97 -0
  3. package/LICENSE +29 -0
  4. package/README.md +323 -0
  5. package/apps/cf-worker/package.json +37 -0
  6. package/apps/cf-worker/scripts/smoke.mjs +175 -0
  7. package/apps/cf-worker/src/worker.ts +59 -0
  8. package/apps/cf-worker/tests/smoke.test.ts +150 -0
  9. package/apps/cf-worker/tsconfig.build.json +17 -0
  10. package/apps/cf-worker/tsconfig.test.json +18 -0
  11. package/apps/cf-worker/vitest.config.ts +31 -0
  12. package/apps/cf-worker/wrangler.test.toml +33 -0
  13. package/apps/cf-worker/wrangler.toml +98 -0
  14. package/apps/local-cli/package.json +35 -0
  15. package/apps/local-cli/src/line-scanner.ts +60 -0
  16. package/apps/local-cli/src/main.ts +145 -0
  17. package/apps/local-cli/src/motd-file.ts +45 -0
  18. package/apps/local-cli/src/server.ts +409 -0
  19. package/apps/local-cli/tests/e2e.test.ts +346 -0
  20. package/apps/local-cli/tests/id-factory.test.ts +25 -0
  21. package/apps/local-cli/tests/line-scanner.test.ts +81 -0
  22. package/apps/local-cli/tests/motd-file.test.ts +71 -0
  23. package/apps/local-cli/tests/tcp.test.ts +358 -0
  24. package/apps/local-cli/tsconfig.build.json +17 -0
  25. package/apps/local-cli/tsconfig.test.json +15 -0
  26. package/apps/local-cli/vitest.config.ts +24 -0
  27. package/biome.json +52 -0
  28. package/package.json +35 -0
  29. package/packages/cf-adapter/package.json +38 -0
  30. package/packages/cf-adapter/src/cf-runtime.ts +308 -0
  31. package/packages/cf-adapter/src/channel-do.ts +374 -0
  32. package/packages/cf-adapter/src/connection-do.ts +542 -0
  33. package/packages/cf-adapter/src/env.ts +67 -0
  34. package/packages/cf-adapter/src/index.ts +38 -0
  35. package/packages/cf-adapter/src/registry-do.ts +130 -0
  36. package/packages/cf-adapter/src/serialize.ts +142 -0
  37. package/packages/cf-adapter/src/sharding.ts +101 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +264 -0
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +73 -0
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +527 -0
  41. package/packages/cf-adapter/tests/channel-do.test.ts +480 -0
  42. package/packages/cf-adapter/tests/connection-do.test.ts +329 -0
  43. package/packages/cf-adapter/tests/integration/harness.test.ts +229 -0
  44. package/packages/cf-adapter/tests/integration/harness.ts +102 -0
  45. package/packages/cf-adapter/tests/integration/in-memory-harness.ts +242 -0
  46. package/packages/cf-adapter/tests/integration/index.ts +17 -0
  47. package/packages/cf-adapter/tests/integration/scenarios.test.ts +20 -0
  48. package/packages/cf-adapter/tests/integration/scenarios.ts +495 -0
  49. package/packages/cf-adapter/tests/registry-do.test.ts +335 -0
  50. package/packages/cf-adapter/tests/sharding.test.ts +100 -0
  51. package/packages/cf-adapter/tests/worker/main.ts +33 -0
  52. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +50 -0
  53. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +57 -0
  54. package/packages/cf-adapter/tsconfig.build.json +16 -0
  55. package/packages/cf-adapter/tsconfig.test.json +18 -0
  56. package/packages/cf-adapter/vitest.config.ts +32 -0
  57. package/packages/cf-adapter/wrangler.test.toml +50 -0
  58. package/packages/in-memory-runtime/package.json +29 -0
  59. package/packages/in-memory-runtime/src/in-memory-runtime.ts +245 -0
  60. package/packages/in-memory-runtime/src/index.ts +9 -0
  61. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +502 -0
  62. package/packages/in-memory-runtime/tsconfig.build.json +16 -0
  63. package/packages/in-memory-runtime/tsconfig.test.json +14 -0
  64. package/packages/in-memory-runtime/vitest.config.ts +21 -0
  65. package/packages/irc-core/package.json +29 -0
  66. package/packages/irc-core/src/caps/capabilities.ts +96 -0
  67. package/packages/irc-core/src/caps/index.ts +1 -0
  68. package/packages/irc-core/src/commands/away.ts +82 -0
  69. package/packages/irc-core/src/commands/cap.ts +257 -0
  70. package/packages/irc-core/src/commands/chghost.ts +30 -0
  71. package/packages/irc-core/src/commands/index.ts +40 -0
  72. package/packages/irc-core/src/commands/invite.ts +156 -0
  73. package/packages/irc-core/src/commands/isupport.ts +133 -0
  74. package/packages/irc-core/src/commands/join.ts +309 -0
  75. package/packages/irc-core/src/commands/kick.ts +162 -0
  76. package/packages/irc-core/src/commands/list.ts +126 -0
  77. package/packages/irc-core/src/commands/mode.ts +655 -0
  78. package/packages/irc-core/src/commands/motd.ts +146 -0
  79. package/packages/irc-core/src/commands/names.ts +164 -0
  80. package/packages/irc-core/src/commands/part.ts +118 -0
  81. package/packages/irc-core/src/commands/ping.ts +47 -0
  82. package/packages/irc-core/src/commands/privmsg.ts +256 -0
  83. package/packages/irc-core/src/commands/quit.ts +70 -0
  84. package/packages/irc-core/src/commands/registration.ts +251 -0
  85. package/packages/irc-core/src/commands/topic.ts +171 -0
  86. package/packages/irc-core/src/commands/who.ts +169 -0
  87. package/packages/irc-core/src/commands/whois.ts +165 -0
  88. package/packages/irc-core/src/effects.ts +184 -0
  89. package/packages/irc-core/src/index.ts +20 -0
  90. package/packages/irc-core/src/ports.ts +153 -0
  91. package/packages/irc-core/src/protocol/batch.ts +85 -0
  92. package/packages/irc-core/src/protocol/index.ts +18 -0
  93. package/packages/irc-core/src/protocol/messages.ts +36 -0
  94. package/packages/irc-core/src/protocol/numerics.ts +155 -0
  95. package/packages/irc-core/src/protocol/outbound.ts +145 -0
  96. package/packages/irc-core/src/protocol/parser.ts +175 -0
  97. package/packages/irc-core/src/protocol/serializer.ts +71 -0
  98. package/packages/irc-core/src/state/channel.ts +293 -0
  99. package/packages/irc-core/src/state/connection.ts +153 -0
  100. package/packages/irc-core/src/state/index.ts +25 -0
  101. package/packages/irc-core/src/state/registry.ts +22 -0
  102. package/packages/irc-core/src/types.ts +83 -0
  103. package/packages/irc-core/tests/batch.test.ts +79 -0
  104. package/packages/irc-core/tests/caps/capabilities.test.ts +148 -0
  105. package/packages/irc-core/tests/commands/away.test.ts +233 -0
  106. package/packages/irc-core/tests/commands/batch.test.ts +178 -0
  107. package/packages/irc-core/tests/commands/cap.test.ts +499 -0
  108. package/packages/irc-core/tests/commands/chghost.test.ts +186 -0
  109. package/packages/irc-core/tests/commands/echo-message.test.ts +212 -0
  110. package/packages/irc-core/tests/commands/extended-join.test.ts +147 -0
  111. package/packages/irc-core/tests/commands/invite-notify.test.ts +160 -0
  112. package/packages/irc-core/tests/commands/invite.test.ts +321 -0
  113. package/packages/irc-core/tests/commands/isupport.test.ts +209 -0
  114. package/packages/irc-core/tests/commands/join.test.ts +687 -0
  115. package/packages/irc-core/tests/commands/kick.test.ts +316 -0
  116. package/packages/irc-core/tests/commands/list.test.ts +452 -0
  117. package/packages/irc-core/tests/commands/mode.test.ts +1048 -0
  118. package/packages/irc-core/tests/commands/motd.test.ts +322 -0
  119. package/packages/irc-core/tests/commands/names.test.ts +342 -0
  120. package/packages/irc-core/tests/commands/part.test.ts +265 -0
  121. package/packages/irc-core/tests/commands/ping.test.ts +144 -0
  122. package/packages/irc-core/tests/commands/privmsg.test.ts +665 -0
  123. package/packages/irc-core/tests/commands/quit.test.ts +220 -0
  124. package/packages/irc-core/tests/commands/registration.test.ts +599 -0
  125. package/packages/irc-core/tests/commands/topic.test.ts +337 -0
  126. package/packages/irc-core/tests/commands/who.test.ts +441 -0
  127. package/packages/irc-core/tests/commands/whois.test.ts +422 -0
  128. package/packages/irc-core/tests/effects.test.ts +147 -0
  129. package/packages/irc-core/tests/message-tags.test.ts +182 -0
  130. package/packages/irc-core/tests/numerics.test.ts +98 -0
  131. package/packages/irc-core/tests/outbound.test.ts +232 -0
  132. package/packages/irc-core/tests/parser.test.ts +162 -0
  133. package/packages/irc-core/tests/ports.test.ts +101 -0
  134. package/packages/irc-core/tests/serializer.test.ts +164 -0
  135. package/packages/irc-core/tests/state/channel.test.ts +351 -0
  136. package/packages/irc-core/tests/state/connection.test.ts +122 -0
  137. package/packages/irc-core/tests/state/registry.test.ts +20 -0
  138. package/packages/irc-core/tests/types.test.ts +127 -0
  139. package/packages/irc-core/tsconfig.build.json +12 -0
  140. package/packages/irc-core/tsconfig.test.json +10 -0
  141. package/packages/irc-core/vitest.config.ts +21 -0
  142. package/packages/irc-server/package.json +28 -0
  143. package/packages/irc-server/src/actor.ts +449 -0
  144. package/packages/irc-server/src/dispatch.ts +120 -0
  145. package/packages/irc-server/src/index.ts +11 -0
  146. package/packages/irc-server/src/runtime.ts +61 -0
  147. package/packages/irc-server/tests/actor.test.ts +816 -0
  148. package/packages/irc-server/tests/dispatch.test.ts +512 -0
  149. package/packages/irc-server/tests/runtime.test.ts +52 -0
  150. package/packages/irc-server/tsconfig.build.json +13 -0
  151. package/packages/irc-server/tsconfig.test.json +11 -0
  152. package/packages/irc-server/vitest.config.ts +21 -0
  153. package/pnpm-workspace.yaml +4 -0
  154. package/tools/tcp-ws-forwarder/package.json +32 -0
  155. package/tools/tcp-ws-forwarder/src/forwarder.ts +244 -0
  156. package/tools/tcp-ws-forwarder/src/line-scanner.ts +55 -0
  157. package/tools/tcp-ws-forwarder/src/main.ts +114 -0
  158. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +618 -0
  159. package/tools/tcp-ws-forwarder/tests/framing.test.ts +51 -0
  160. package/tools/tcp-ws-forwarder/tests/line-scanner.test.ts +75 -0
  161. package/tools/tcp-ws-forwarder/tsconfig.build.json +12 -0
  162. package/tools/tcp-ws-forwarder/tsconfig.test.json +10 -0
  163. package/tools/tcp-ws-forwarder/vitest.config.ts +27 -0
  164. package/tsconfig.base.json +25 -0
  165. package/turbo.json +29 -0
@@ -0,0 +1,441 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { whoReducer } from '../../src/commands/who';
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 ChannelState, createChannel } from '../../src/state/channel';
7
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
8
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
9
+
10
+ const serverConfig: ServerConfig = {
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ maxChannelsPerUser: 30,
14
+ maxTargetsPerCommand: 10,
15
+ maxListEntries: 50,
16
+ nickLen: 30,
17
+ channelLen: 50,
18
+ topicLen: 390,
19
+ quitMessage: 'Client Quit',
20
+ };
21
+
22
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(1_000)): Ctx {
23
+ return buildCtx({
24
+ serverConfig,
25
+ clock,
26
+ ids: new SequentialIdFactory(),
27
+ motd: EmptyMotdProvider,
28
+ connection: conn,
29
+ });
30
+ }
31
+
32
+ function makeConn(id = 'c1', nick = 'alice', caps: ReadonlyArray<string> = []): ConnectionState {
33
+ const s = createConnection({ id, connectedSince: 0 });
34
+ s.nick = nick;
35
+ s.user = nick;
36
+ s.host = 'example.com';
37
+ s.realname = nick.charAt(0).toUpperCase() + nick.slice(1);
38
+ s.registration = 'registered';
39
+ for (const c of caps) s.caps.add(c);
40
+ return s;
41
+ }
42
+
43
+ function makeChan(name = '#foo'): ChannelState {
44
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
45
+ }
46
+
47
+ function addMember(
48
+ chan: ChannelState,
49
+ connId: string,
50
+ nick: string,
51
+ op = false,
52
+ voice = false,
53
+ ): void {
54
+ chan.members.set(connId, { conn: connId, nick, op, voice });
55
+ }
56
+
57
+ function makeConnState(
58
+ id: string,
59
+ nick: string,
60
+ opts: {
61
+ op?: boolean;
62
+ away?: string;
63
+ invisible?: boolean;
64
+ oper?: boolean;
65
+ realname?: string;
66
+ } = {},
67
+ ): ConnectionState {
68
+ const s = createConnection({ id, connectedSince: 1_000 });
69
+ s.nick = nick;
70
+ s.user = nick;
71
+ s.host = 'example.com';
72
+ s.realname = opts.realname ?? nick;
73
+ s.registration = 'registered';
74
+ if (opts.away !== undefined) s.away = opts.away;
75
+ if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
76
+ if (opts.oper !== undefined) s.userModes.oper = opts.oper;
77
+ return s;
78
+ }
79
+
80
+ const L = (text: string): RawLine => ({ text });
81
+
82
+ // ============================================================================
83
+ // whoReducer — channel target
84
+ // ============================================================================
85
+
86
+ describe('whoReducer — channel target', () => {
87
+ it('emits one 352 per channel member then 315 RPL_ENDOFWHO', () => {
88
+ const chan = makeChan('#foo');
89
+ addMember(chan, 'c1', 'alice');
90
+ addMember(chan, 'c2', 'bob', true);
91
+ const conn = makeConn();
92
+ const ctx = makeCtx(conn);
93
+ const connStates = new Map<string, ConnectionState>([
94
+ ['c1', makeConnState('c1', 'alice')],
95
+ ['c2', makeConnState('c2', 'bob', { op: true })],
96
+ ]);
97
+
98
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
99
+
100
+ expect(out.effects).toEqual<EffectType[]>([
101
+ Effect.send('c1', [
102
+ L(':irc.example.com 352 alice #foo alice example.com irc.example.com alice H :0 alice'),
103
+ L(':irc.example.com 352 alice #foo bob example.com irc.example.com bob H@ :0 bob'),
104
+ ]),
105
+ Effect.send('c1', [L(':irc.example.com 315 alice #foo :End of /WHO list')]),
106
+ ]);
107
+ });
108
+
109
+ it('marks the away flag as G (gone) for away members', () => {
110
+ const chan = makeChan('#foo');
111
+ addMember(chan, 'c1', 'alice');
112
+ const conn = makeConn();
113
+ const ctx = makeCtx(conn);
114
+ const connStates = new Map<string, ConnectionState>([
115
+ ['c1', makeConnState('c1', 'alice', { away: 'brb' })],
116
+ ]);
117
+
118
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
119
+
120
+ const send = out.effects[0];
121
+ expect(send?.tag).toBe('Send');
122
+ if (send?.tag === 'Send') {
123
+ expect(send.lines[0]?.text).toContain(' G ');
124
+ }
125
+ });
126
+
127
+ it('marks the oper flag with * for IRC operators', () => {
128
+ const chan = makeChan('#foo');
129
+ addMember(chan, 'c1', 'alice');
130
+ const conn = makeConn();
131
+ const ctx = makeCtx(conn);
132
+ const connStates = new Map<string, ConnectionState>([
133
+ ['c1', makeConnState('c1', 'alice', { oper: true })],
134
+ ]);
135
+
136
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
137
+
138
+ const send = out.effects[0];
139
+ if (send?.tag === 'Send') {
140
+ // Oper flag appears between hops and the away flag: `<hops> <awayFlag>[*]<prefixes>`
141
+ // The 352 layout: `<chan> <user> <host> <server> <nick> <H|G>[*][~|&|@|%|+] :<hops> <real>`
142
+ expect(send.lines[0]?.text).toContain(' H* ');
143
+ }
144
+ });
145
+
146
+ it('includes the @ prefix for ops and + prefix for voice', () => {
147
+ const chan = makeChan('#foo');
148
+ addMember(chan, 'c1', 'alice');
149
+ addMember(chan, 'c2', 'bob', true, true);
150
+ addMember(chan, 'c3', 'carol', false, true);
151
+ const conn = makeConn();
152
+ const ctx = makeCtx(conn);
153
+ const connStates = new Map<string, ConnectionState>([
154
+ ['c1', makeConnState('c1', 'alice')],
155
+ ['c2', makeConnState('c2', 'bob')],
156
+ ['c3', makeConnState('c3', 'carol')],
157
+ ]);
158
+
159
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
160
+
161
+ const send = out.effects[0];
162
+ if (send?.tag === 'Send') {
163
+ const bob = send.lines[1]?.text ?? '';
164
+ const carol = send.lines[2]?.text ?? '';
165
+ expect(bob).toContain('H@');
166
+ expect(carol).toContain('H+');
167
+ }
168
+ });
169
+
170
+ it('shows only the highest-priority prefix in 352 when multi-prefix is NOT negotiated', () => {
171
+ // Legacy client: op+voice user appears as `H@` only, NOT `H@+`.
172
+ const chan = makeChan('#foo');
173
+ addMember(chan, 'c1', 'alice');
174
+ addMember(chan, 'c2', 'bob', true, true); // op AND voice
175
+ const conn = makeConn('c1', 'alice'); // no multi-prefix cap
176
+ const ctx = makeCtx(conn);
177
+ const connStates = new Map<string, ConnectionState>([
178
+ ['c1', makeConnState('c1', 'alice')],
179
+ ['c2', makeConnState('c2', 'bob')],
180
+ ]);
181
+
182
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
183
+
184
+ const send = out.effects[0];
185
+ expect(send?.tag).toBe('Send');
186
+ if (send?.tag === 'Send') {
187
+ const bob = send.lines[1]?.text ?? '';
188
+ // Flag field is the token before the trailing ` :0 real` segment.
189
+ // We expect exactly `H@` (with the space after), not `H@+`.
190
+ expect(bob).toContain(' H@ ');
191
+ expect(bob).not.toContain('H@+');
192
+ }
193
+ });
194
+
195
+ it('shows every applicable prefix in 352 when multi-prefix IS negotiated', () => {
196
+ // Cap-enabled client: op+voice user appears as `H@+`.
197
+ const chan = makeChan('#foo');
198
+ addMember(chan, 'c1', 'alice');
199
+ addMember(chan, 'c2', 'bob', true, true); // op AND voice
200
+ const conn = makeConn('c1', 'alice', ['multi-prefix']);
201
+ const ctx = makeCtx(conn);
202
+ const connStates = new Map<string, ConnectionState>([
203
+ ['c1', makeConnState('c1', 'alice')],
204
+ ['c2', makeConnState('c2', 'bob')],
205
+ ]);
206
+
207
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
208
+
209
+ const send = out.effects[0];
210
+ expect(send?.tag).toBe('Send');
211
+ if (send?.tag === 'Send') {
212
+ const bob = send.lines[1]?.text ?? '';
213
+ expect(bob).toContain('H@+');
214
+ }
215
+ });
216
+
217
+ it('uses * for the server name (single-server v1)', () => {
218
+ const chan = makeChan('#foo');
219
+ addMember(chan, 'c1', 'alice');
220
+ const conn = makeConn();
221
+ const ctx = makeCtx(conn);
222
+ const connStates = new Map<string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);
223
+
224
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
225
+
226
+ // The 352 server field is the configured server name; single-server v1
227
+ // reports the local server for every member.
228
+ const send = out.effects[0];
229
+ if (send?.tag === 'Send') {
230
+ expect(send.lines[0]?.text).toContain(' irc.example.com alice ');
231
+ }
232
+ });
233
+
234
+ it('emits only 315 when the channel has no members', () => {
235
+ const chan = makeChan('#foo');
236
+ const conn = makeConn();
237
+ const ctx = makeCtx(conn);
238
+ const connStates = new Map<string, ConnectionState>();
239
+
240
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
241
+
242
+ expect(out.effects).toEqual<EffectType[]>([
243
+ Effect.send('c1', [L(':irc.example.com 315 alice #foo :End of /WHO list')]),
244
+ ]);
245
+ });
246
+
247
+ it('hides +s channel members from a non-member requester', () => {
248
+ const chan = makeChan('#secret');
249
+ chan.modes.secret = true;
250
+ addMember(chan, 'c2', 'bob');
251
+ const conn = makeConn();
252
+ const ctx = makeCtx(conn);
253
+ const connStates = new Map<string, ConnectionState>([['c2', makeConnState('c2', 'bob')]]);
254
+
255
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#secret'] }, ctx);
256
+
257
+ // Non-member of a +s channel gets empty list + end.
258
+ expect(out.effects).toEqual<EffectType[]>([
259
+ Effect.send('c1', [L(':irc.example.com 315 alice #secret :End of /WHO list')]),
260
+ ]);
261
+ });
262
+
263
+ it('shows +s channel members to a member requester', () => {
264
+ const chan = makeChan('#secret');
265
+ chan.modes.secret = true;
266
+ addMember(chan, 'c1', 'alice');
267
+ addMember(chan, 'c2', 'bob');
268
+ const conn = makeConn();
269
+ const ctx = makeCtx(conn);
270
+ const connStates = new Map<string, ConnectionState>([
271
+ ['c1', makeConnState('c1', 'alice')],
272
+ ['c2', makeConnState('c2', 'bob')],
273
+ ]);
274
+
275
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#secret'] }, ctx);
276
+
277
+ const send = out.effects[0];
278
+ expect(send?.tag).toBe('Send');
279
+ if (send?.tag === 'Send') {
280
+ expect(send.lines.length).toBe(2);
281
+ }
282
+ });
283
+
284
+ it('skips +i (invisible) user-mode members unless the requester shares a channel', () => {
285
+ // In v1, the requester IS on the channel (they sent WHO #foo), so invisible
286
+ // members of #foo are still listed: they're visible by virtue of shared
287
+ // channel membership. This test confirms that behavior.
288
+ const chan = makeChan('#foo');
289
+ addMember(chan, 'c1', 'alice');
290
+ addMember(chan, 'c2', 'bob');
291
+ const conn = makeConn();
292
+ const ctx = makeCtx(conn);
293
+ const connStates = new Map<string, ConnectionState>([
294
+ ['c1', makeConnState('c1', 'alice')],
295
+ ['c2', makeConnState('c2', 'bob', { invisible: true })],
296
+ ]);
297
+
298
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
299
+
300
+ const send = out.effects[0];
301
+ if (send?.tag === 'Send') {
302
+ // Both members are listed: requester shares the channel with the
303
+ // invisible user, so they're visible.
304
+ expect(send.lines.length).toBe(2);
305
+ }
306
+ });
307
+
308
+ it('hides members whose connection state is missing from the registry', () => {
309
+ const chan = makeChan('#foo');
310
+ addMember(chan, 'c1', 'alice');
311
+ addMember(chan, 'c2', 'ghost'); // no ConnectionState entry for c2
312
+ const conn = makeConn();
313
+ const ctx = makeCtx(conn);
314
+ const connStates = new Map<string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);
315
+
316
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
317
+
318
+ const send = out.effects[0];
319
+ if (send?.tag === 'Send') {
320
+ expect(send.lines.length).toBe(1);
321
+ }
322
+ });
323
+ });
324
+
325
+ // ============================================================================
326
+ // whoReducer — `o` filter (operators only)
327
+ // ============================================================================
328
+
329
+ describe('whoReducer — operators-only filter', () => {
330
+ it('with `o` flag lists only IRC operators (channel members)', () => {
331
+ const chan = makeChan('#foo');
332
+ addMember(chan, 'c1', 'alice');
333
+ addMember(chan, 'c2', 'bob');
334
+ const conn = makeConn();
335
+ const ctx = makeCtx(conn);
336
+ const connStates = new Map<string, ConnectionState>([
337
+ ['c1', makeConnState('c1', 'alice')],
338
+ ['c2', makeConnState('c2', 'bob', { oper: true })],
339
+ ]);
340
+
341
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo', 'o'] }, ctx);
342
+
343
+ const send = out.effects[0];
344
+ if (send?.tag === 'Send') {
345
+ expect(send.lines.length).toBe(1);
346
+ expect(send.lines[0]?.text).toContain(' bob ');
347
+ }
348
+ });
349
+ });
350
+
351
+ // ============================================================================
352
+ // whoReducer — errors
353
+ // ============================================================================
354
+
355
+ describe('whoReducer — errors', () => {
356
+ it('emits 403 ERR_NOSUCHCHANNEL for an invalid channel name', () => {
357
+ const chan = makeChan('foo');
358
+ const conn = makeConn();
359
+ const ctx = makeCtx(conn);
360
+ const connStates = new Map<string, ConnectionState>();
361
+
362
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['foo'] }, ctx);
363
+
364
+ expect(out.effects).toEqual<EffectType[]>([
365
+ Effect.send('c1', [L(':irc.example.com 403 alice foo :No such channel')]),
366
+ ]);
367
+ });
368
+
369
+ it('rejects an empty channel name with 403', () => {
370
+ const chan = makeChan('');
371
+ const conn = makeConn();
372
+ const ctx = makeCtx(conn);
373
+ const connStates = new Map<string, ConnectionState>();
374
+
375
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: [''] }, ctx);
376
+
377
+ const send = out.effects[0];
378
+ expect(send?.tag).toBe('Send');
379
+ if (send?.tag === 'Send') {
380
+ expect(send.lines[0]?.text).toContain(' 403 ');
381
+ }
382
+ });
383
+
384
+ it('rejects a too-long channel name with 403', () => {
385
+ const long = `#${'x'.repeat(serverConfig.channelLen)}`;
386
+ const chan = makeChan(long);
387
+ const conn = makeConn();
388
+ const ctx = makeCtx(conn);
389
+ const connStates = new Map<string, ConnectionState>();
390
+
391
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: [long] }, ctx);
392
+
393
+ const send = out.effects[0];
394
+ expect(send?.tag).toBe('Send');
395
+ if (send?.tag === 'Send') {
396
+ expect(send.lines[0]?.text).toContain(' 403 ');
397
+ }
398
+ });
399
+
400
+ it('emits only 315 with an empty mask when no params are supplied', () => {
401
+ const chan = makeChan('#foo');
402
+ addMember(chan, 'c1', 'alice');
403
+ const conn = makeConn();
404
+ const ctx = makeCtx(conn);
405
+ const connStates = new Map<string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);
406
+
407
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: [] }, ctx);
408
+
409
+ expect(out.effects).toEqual<EffectType[]>([
410
+ Effect.send('c1', [L(':irc.example.com 315 alice :End of /WHO list')]),
411
+ ]);
412
+ });
413
+
414
+ it('uses * in replies when the connection has no nick', () => {
415
+ const chan = makeChan('#foo');
416
+ addMember(chan, 'c1', 'alice');
417
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
418
+ const ctx = makeCtx(conn);
419
+ const connStates = new Map<string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);
420
+
421
+ const out = whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
422
+
423
+ const end = out.effects.at(-1);
424
+ if (end?.tag === 'Send') {
425
+ expect(end.lines[0]?.text).toBe(':irc.example.com 315 * #foo :End of /WHO list');
426
+ }
427
+ });
428
+
429
+ it('updates the connection lastSeen to ctx.clock.now()', () => {
430
+ const chan = makeChan('#foo');
431
+ addMember(chan, 'c1', 'alice');
432
+ const conn = makeConn();
433
+ const clock = new FakeClock(5_500);
434
+ const ctx = makeCtx(conn, clock);
435
+ const connStates = new Map<string, ConnectionState>([['c1', makeConnState('c1', 'alice')]]);
436
+
437
+ whoReducer(chan, connStates, { command: 'WHO', params: ['#foo'] }, ctx);
438
+
439
+ expect(conn.lastSeen).toBe(5_500);
440
+ });
441
+ });