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,309 @@
1
+ /**
2
+ * Pure reducer for the IRC `JOIN` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: JOIN runs in the Channel entity. The
5
+ * reducer's `state` argument is the channel's authoritative state; the
6
+ * joiner's identity is read from `ctx.connection`. Mutations to both
7
+ * `state.members` (the roster) and `ctx.connection.joinedChannels` (the
8
+ * connection's joined list) happen in place — the actor layer persists both.
9
+ *
10
+ * Multi-channel JOIN (`JOIN #a,#b k1,k2`) is split by the actor layer;
11
+ * this reducer handles one channel per invocation.
12
+ *
13
+ * `JOIN 0` (legacy "leave all channels") is routed by the actor layer to
14
+ * {@link handleJoinZero}, which emits PART-like effects for every joined
15
+ * channel.
16
+ */
17
+
18
+ import { Effect } from '../effects.js';
19
+ import type { Effect as EffectType, RawLine } from '../effects.js';
20
+ import { wrapBatch } from '../protocol/batch.js';
21
+ import { Numerics } from '../protocol/numerics.js';
22
+ import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
23
+ import { hostmaskOf } from '../state/connection.js';
24
+ import type { ConnectionState } from '../state/connection.js';
25
+ import type { Ctx, Reducer } from '../types.js';
26
+
27
+ /**
28
+ * RFC 1459/2812 channel-name grammar. The first character must be a channel
29
+ * prefix (`#` or `&`); the rest may be any printable char except space, comma,
30
+ * or colon (which all carry protocol meaning).
31
+ */
32
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
33
+
34
+ /**
35
+ * Returns true iff `name` matches the IRC channel-name grammar and is within
36
+ * the configured length cap.
37
+ */
38
+ export function isValidChannelName(name: string, maxLen: number): boolean {
39
+ if (name.length === 0 || name.length > maxLen) return false;
40
+ return CHANNEL_NAME_RE.test(name);
41
+ }
42
+
43
+ /**
44
+ * Returns the 353 NAMES sigil for a channel given its modes:
45
+ * `@` for `+p` (private), `*` for `+s` (secret), `=` otherwise (public).
46
+ *
47
+ * `+p` wins over `+s` when both are set (per RFC 2812 §3.2.4 — private is the
48
+ * stronger visibility hint for NAMES display).
49
+ */
50
+ function namesSigil(state: ChannelState): string {
51
+ if (state.modes.private) return '@';
52
+ if (state.modes.secret) return '*';
53
+ return '=';
54
+ }
55
+
56
+ /** Builds the space-separated NAMES list with mode prefixes for a channel. */
57
+ function buildNamesList(state: ChannelState): string {
58
+ const names: string[] = [];
59
+ for (const entry of state.members.values()) {
60
+ names.push(prefixOf(entry) + entry.nick);
61
+ }
62
+ return names.join(' ');
63
+ }
64
+
65
+ /**
66
+ * Formats a single numeric error line addressed to the connection's current
67
+ * nick (or `*` when unregistered). Optional `middle` is emitted before the
68
+ * trailing `:`-prefixed text.
69
+ */
70
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
71
+ const nick = ctx.connection.nick ?? '*';
72
+ const codeStr = code.toString().padStart(3, '0');
73
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
74
+ if (middle !== undefined) parts.push(middle);
75
+ parts.push(`:${trailing}`);
76
+ return { text: parts.join(' ') };
77
+ }
78
+
79
+ /**
80
+ * Returns true iff `hostmask` matches the IRC ban mask `mask`. Masks use
81
+ * `*` (zero or more chars) and `?` (one char) wildcards; comparison is
82
+ * case-insensitive.
83
+ */
84
+ function matchesBanMask(mask: string, hostmask: string): boolean {
85
+ let pattern = '^';
86
+ for (const ch of mask) {
87
+ if (ch === '*') {
88
+ pattern += '.*';
89
+ } else if (ch === '?') {
90
+ pattern += '.';
91
+ } else if (/[A-Za-z0-9]/.test(ch)) {
92
+ pattern += ch;
93
+ } else {
94
+ pattern += `\\${ch}`;
95
+ }
96
+ }
97
+ pattern += '$';
98
+ return new RegExp(pattern, 'i').test(hostmask);
99
+ }
100
+
101
+ /**
102
+ * Returns the hostmask string for the connection, falling back to the bare
103
+ * nick (or `?` when the connection has no nick yet). Used as the JOIN/PART
104
+ * message source.
105
+ */
106
+ function joinerHostmask(conn: ConnectionState): string {
107
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
108
+ }
109
+
110
+ /**
111
+ * Handles `JOIN <chan> [<key>]` for a single channel.
112
+ *
113
+ * Pre-condition: `state` is the authoritative ChannelState for the channel
114
+ * named in `msg.params[0]`. The actor layer creates an empty channel (via
115
+ * `createChannel`) if the channel does not yet exist, then invokes this
116
+ * reducer.
117
+ *
118
+ * Validation order (deliberate): missing-param → invalid-name →
119
+ * already-on-channel (silent no-op) → max-channels → ban → invite-only →
120
+ * limit → key.
121
+ */
122
+ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
123
+ ctx.connection.lastSeen = ctx.clock.now();
124
+
125
+ const effects: EffectType[] = [];
126
+
127
+ const rawName = msg.params[0];
128
+ if (rawName === undefined) {
129
+ effects.push(
130
+ Effect.send(ctx.connId, [
131
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'JOIN'),
132
+ ]),
133
+ );
134
+ return { state, effects };
135
+ }
136
+
137
+ if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
138
+ effects.push(
139
+ Effect.send(ctx.connId, [
140
+ numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
141
+ ]),
142
+ );
143
+ return { state, effects };
144
+ }
145
+
146
+ // Already on the channel: silent no-op (RFC: re-joining is a no-op).
147
+ if (state.members.has(ctx.connId)) {
148
+ return { state, effects };
149
+ }
150
+
151
+ // Max-channels limit. Counts every channel the connection currently holds;
152
+ // the already-on-channel check above ensures we don't double-count the
153
+ // target when the actor layer hasn't pre-checked membership.
154
+ if (ctx.connection.joinedChannels.size >= ctx.serverConfig.maxChannelsPerUser) {
155
+ effects.push(
156
+ Effect.send(ctx.connId, [
157
+ numericErr(ctx, Numerics.ERR_TOOMANYCHANNELS, 'You have joined too many channels', rawName),
158
+ ]),
159
+ );
160
+ return { state, effects };
161
+ }
162
+
163
+ const hostmask = joinerHostmask(ctx.connection);
164
+
165
+ // Ban check.
166
+ if (state.banMasks.size > 0) {
167
+ for (const mask of state.banMasks) {
168
+ if (matchesBanMask(mask, hostmask)) {
169
+ effects.push(
170
+ Effect.send(ctx.connId, [
171
+ numericErr(ctx, Numerics.ERR_BANNEDFROMCHAN, 'Cannot join channel (+b)', rawName),
172
+ ]),
173
+ );
174
+ return { state, effects };
175
+ }
176
+ }
177
+ }
178
+
179
+ // Invite-only check. pendingInvites stores lowercased nicks.
180
+ const myNick = ctx.connection.nick;
181
+ const invited = myNick !== undefined && state.pendingInvites.has(myNick.toLowerCase());
182
+ if (state.modes.inviteOnly && !invited) {
183
+ effects.push(
184
+ Effect.send(ctx.connId, [
185
+ numericErr(ctx, Numerics.ERR_INVITEONLYCHAN, 'Cannot join channel (+i)', rawName),
186
+ ]),
187
+ );
188
+ return { state, effects };
189
+ }
190
+
191
+ // Limit check.
192
+ if (state.modes.limit !== undefined && state.members.size >= state.modes.limit) {
193
+ effects.push(
194
+ Effect.send(ctx.connId, [
195
+ numericErr(ctx, Numerics.ERR_CHANNELISFULL, 'Cannot join channel (+l)', rawName),
196
+ ]),
197
+ );
198
+ return { state, effects };
199
+ }
200
+
201
+ // Key check.
202
+ if (state.modes.key !== undefined) {
203
+ const supplied = msg.params[1];
204
+ if (supplied !== state.modes.key) {
205
+ effects.push(
206
+ Effect.send(ctx.connId, [
207
+ numericErr(ctx, Numerics.ERR_BADCHANNELKEY, 'Cannot join channel (+k)', rawName),
208
+ ]),
209
+ );
210
+ return { state, effects };
211
+ }
212
+ }
213
+
214
+ // All checks passed: add to roster. First joiner gets op per RFC 2812.
215
+ const isFirst = state.members.size === 0;
216
+ state.members.set(ctx.connId, {
217
+ conn: ctx.connId,
218
+ nick: ctx.connection.nick ?? '?',
219
+ op: isFirst,
220
+ voice: false,
221
+ });
222
+
223
+ // Pending invite is consumed on a successful join.
224
+ if (myNick !== undefined) state.pendingInvites.delete(myNick.toLowerCase());
225
+
226
+ // Track the channel on the connection's joined set (cross-authority).
227
+ ctx.connection.joinedChannels.add(state.nameLower);
228
+
229
+ // Mirror the roster addition to the runtime's channel authority. In
230
+ // the in-memory runtime the actor's `state` IS the authoritative
231
+ // state, so this is a redundant idempotent update; in distributed
232
+ // runtimes (CF ChannelDO) this is the only path that mutates the
233
+ // authoritative roster, so subsequent broadcasts reach the new member.
234
+ effects.push(
235
+ Effect.applyChannelDelta(state.name, {
236
+ memberships: [
237
+ {
238
+ type: 'add',
239
+ conn: ctx.connId,
240
+ nick: ctx.connection.nick ?? '?',
241
+ op: isFirst,
242
+ },
243
+ ],
244
+ }),
245
+ );
246
+
247
+ const legacyJoin: RawLine = { text: `:${hostmask} JOIN ${state.name}` };
248
+ const realname = ctx.connection.realname;
249
+ if (realname !== undefined) {
250
+ const account = ctx.connection.account ?? '_';
251
+ const extendedJoin: RawLine = {
252
+ text: `:${hostmask} JOIN ${state.name} ${account} :${realname}`,
253
+ };
254
+ effects.push(
255
+ Effect.broadcast(state.name, [legacyJoin], undefined, 'extended-join', [extendedJoin]),
256
+ );
257
+ } else {
258
+ effects.push(Effect.broadcast(state.name, [legacyJoin]));
259
+ }
260
+
261
+ // NAMES list + end-of-names to the joiner only.
262
+ const nick = ctx.connection.nick ?? '*';
263
+ const sigil = namesSigil(state);
264
+ const names = buildNamesList(state);
265
+ const chanName = state.name;
266
+ const namesLines: RawLine[] = [
267
+ { text: `:${ctx.serverName} 353 ${nick} ${sigil} ${chanName} :${names}` },
268
+ { text: `:${ctx.serverName} 366 ${nick} ${chanName} :End of /NAMES list.` },
269
+ ];
270
+
271
+ // IRCv3 batch: wrap the joiner's NAMES reply when the joiner negotiated it.
272
+ const lines = ctx.connection.caps.has('batch')
273
+ ? wrapBatch(namesLines, ctx.ids.batchId(), 'join')
274
+ : namesLines;
275
+
276
+ effects.push(Effect.send(ctx.connId, lines));
277
+
278
+ return { state, effects };
279
+ };
280
+
281
+ /**
282
+ * Handles the legacy `JOIN 0` form, which parts the connection from every
283
+ * channel they currently occupy. The actor layer routes `JOIN 0` to this
284
+ * function instead of {@link joinReducer}.
285
+ *
286
+ * Emits, for each joined channel:
287
+ * - `Broadcast(PART)` to the channel (including the parting connection,
288
+ * matching PART semantics — clients expect the echo).
289
+ * - `ApplyChannelDelta` removing the connection from the roster.
290
+ *
291
+ * Also clears `ctx.connection.joinedChannels`.
292
+ */
293
+ export function handleJoinZero(ctx: Ctx): EffectType[] {
294
+ const effects: EffectType[] = [];
295
+ const hostmask = joinerHostmask(ctx.connection);
296
+
297
+ for (const chan of ctx.connection.joinedChannels) {
298
+ const chanName = chan as ChanName;
299
+ effects.push(Effect.broadcast(chanName, [{ text: `:${hostmask} PART ${chanName}` }]));
300
+ effects.push(
301
+ Effect.applyChannelDelta(chanName, {
302
+ memberships: [{ type: 'remove', conn: ctx.connId }],
303
+ }),
304
+ );
305
+ }
306
+
307
+ ctx.connection.joinedChannels.clear();
308
+ return effects;
309
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Pure reducer for the IRC `KICK` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: KICK runs in the Channel entity. The
5
+ * reducer's `state` argument is the channel's authoritative state; the
6
+ * kicker's identity is read from `ctx.connection`. Mutations to the roster
7
+ * are expressed as a `ApplyChannelDelta` effect (the actor layer additionally
8
+ * removes the channel from the kicked user's `joinedChannels` set — that
9
+ * cross-authority mutation is the actor's job, not the reducer's, since the
10
+ * reducer has no handle on the target's `ConnectionState`).
11
+ *
12
+ * Multi-channel KICK is not part of the RFC grammar; this reducer handles
13
+ * one channel per invocation.
14
+ */
15
+
16
+ import { Effect } from '../effects.js';
17
+ import type { Effect as EffectType, RawLine } from '../effects.js';
18
+ import { Numerics } from '../protocol/numerics.js';
19
+ import type { ChannelState } from '../state/channel.js';
20
+ import { hostmaskOf } from '../state/connection.js';
21
+ import type { ConnectionState } from '../state/connection.js';
22
+ import type { Ctx, Reducer } from '../types.js';
23
+
24
+ /** Channel-name validation mirrors JOIN: same grammar, same length cap. */
25
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
26
+
27
+ function isValidChannelName(name: string, maxLen: number): boolean {
28
+ if (name.length === 0 || name.length > maxLen) return false;
29
+ return CHANNEL_NAME_RE.test(name);
30
+ }
31
+
32
+ /**
33
+ * Formats a single numeric error line addressed to the connection's current
34
+ * nick (or `*` when unregistered). Optional `middle` is emitted before the
35
+ * trailing `:`-prefixed text.
36
+ */
37
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
38
+ const nick = ctx.connection.nick ?? '*';
39
+ const codeStr = code.toString().padStart(3, '0');
40
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
41
+ if (middle !== undefined) parts.push(middle);
42
+ parts.push(`:${trailing}`);
43
+ return { text: parts.join(' ') };
44
+ }
45
+
46
+ /**
47
+ * Returns the hostmask string for the connection, falling back to the bare
48
+ * nick (or `?` when the connection has no nick yet). Used as the KICK
49
+ * message source.
50
+ */
51
+ function kickerHostmask(conn: ConnectionState): string {
52
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
53
+ }
54
+
55
+ /**
56
+ * Lowercases a nick using ASCII case folding (good enough for v1; rfc1459
57
+ * case mapping lands with the isupport `CASEMAPPING` token).
58
+ */
59
+ function lowerNick(nick: string): string {
60
+ return nick.toLowerCase();
61
+ }
62
+
63
+ /**
64
+ * Handles `KICK <chan> <user> [:reason]`.
65
+ *
66
+ * Pre-condition: `state` is the authoritative ChannelState for the channel
67
+ * named in `msg.params[0]`.
68
+ *
69
+ * Validation order (deliberate): missing-channel → missing-target →
70
+ * invalid-name → kicker-not-on-channel → kicker-not-op →
71
+ * target-not-on-channel → success.
72
+ */
73
+ export const kickReducer: Reducer<ChannelState> = (state, msg, ctx) => {
74
+ ctx.connection.lastSeen = ctx.clock.now();
75
+
76
+ const effects: EffectType[] = [];
77
+
78
+ const rawName = msg.params[0];
79
+ const targetNick = msg.params[1];
80
+
81
+ if (rawName === undefined || targetNick === undefined) {
82
+ effects.push(
83
+ Effect.send(ctx.connId, [
84
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'KICK'),
85
+ ]),
86
+ );
87
+ return { state, effects };
88
+ }
89
+
90
+ if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) {
91
+ effects.push(
92
+ Effect.send(ctx.connId, [
93
+ numericErr(ctx, Numerics.ERR_NOSUCHCHANNEL, 'No such channel', rawName),
94
+ ]),
95
+ );
96
+ return { state, effects };
97
+ }
98
+
99
+ // Kicker must be on the channel to kick anyone.
100
+ const kickerEntry = state.members.get(ctx.connId);
101
+ if (kickerEntry === undefined) {
102
+ effects.push(
103
+ Effect.send(ctx.connId, [
104
+ numericErr(ctx, Numerics.ERR_NOTONCHANNEL, "You're not on that channel", rawName),
105
+ ]),
106
+ );
107
+ return { state, effects };
108
+ }
109
+
110
+ // Kicker must be an op.
111
+ if (!kickerEntry.op) {
112
+ effects.push(
113
+ Effect.send(ctx.connId, [
114
+ numericErr(ctx, Numerics.ERR_CHANOPRIVSNEEDED, "You're not channel operator", rawName),
115
+ ]),
116
+ );
117
+ return { state, effects };
118
+ }
119
+
120
+ // Find the target by nick (case-insensitive). IRC nicks fold by ASCII.
121
+ const targetLower = lowerNick(targetNick);
122
+ let targetConnId: string | undefined;
123
+ for (const entry of state.members.values()) {
124
+ if (lowerNick(entry.nick) === targetLower) {
125
+ targetConnId = entry.conn;
126
+ break;
127
+ }
128
+ }
129
+ if (targetConnId === undefined) {
130
+ effects.push(
131
+ Effect.send(ctx.connId, [
132
+ numericErr(
133
+ ctx,
134
+ Numerics.ERR_USERNOTINCHANNEL,
135
+ 'They are not on that channel',
136
+ `${targetNick} ${rawName}`,
137
+ ),
138
+ ]),
139
+ );
140
+ return { state, effects };
141
+ }
142
+
143
+ // All checks passed: build the KICK line and remove the target.
144
+ const hostmask = kickerHostmask(ctx.connection);
145
+ const reason = msg.params[2];
146
+ const line: RawLine =
147
+ reason && reason.length > 0
148
+ ? { text: `:${hostmask} KICK ${state.name} ${targetNick} :${reason}` }
149
+ : { text: `:${hostmask} KICK ${state.name} ${targetNick}` };
150
+ effects.push(Effect.broadcast(state.name, [line]));
151
+
152
+ // Drop the target from the roster (the actor layer persists the delta and
153
+ // also removes the channel from the target's joinedChannels set).
154
+ state.members.delete(targetConnId);
155
+ effects.push(
156
+ Effect.applyChannelDelta(state.name, {
157
+ memberships: [{ type: 'remove', conn: targetConnId }],
158
+ }),
159
+ );
160
+
161
+ return { state, effects };
162
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pure reducer for the IRC `LIST` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: LIST reads channel state across the
5
+ * deployment but does not mutate it, so the reducer's `state` argument is
6
+ * the actor-supplied list of {@link ChanSnapshot} entries; the requester
7
+ * is `ctx.connection`.
8
+ *
9
+ * Wire format (RFC 2812 §3.2.7):
10
+ * - `321 RPL_LISTSTART` — `:<server> 321 <nick> Channel :Users Name`
11
+ * - `322 RPL_LIST` — `:<server> 322 <nick> <chan> <#users> :<topic>`
12
+ * - `323 RPL_LISTEND` — `:<server> 323 <nick> :End of /LIST`
13
+ *
14
+ * Visibility rules (ticket spec):
15
+ * - Secret (`+s`) channels are hidden from non-members.
16
+ * - Membership is checked against `chan.members` using `ctx.connId`.
17
+ *
18
+ * Thundering-herd mitigation (PLAN §10 risk): the number of `322` entries
19
+ * is capped at `serverConfig.maxListEntries`. The cap applies after
20
+ * visibility filtering, so a member listing their own +s channels still
21
+ * sees them up to the cap.
22
+ *
23
+ * The actor layer is responsible for assembling the channel list to
24
+ * enumerate; this reducer performs no I/O. `LIST <chan>{,<chan>}` narrows
25
+ * the result to the requested channels (still subject to visibility and
26
+ * the cap).
27
+ */
28
+
29
+ import { Effect } from '../effects.js';
30
+ import type { Effect as EffectType, RawLine } from '../effects.js';
31
+ import { Numerics } from '../protocol/numerics.js';
32
+ import type { ChanSnapshot } from '../state/channel.js';
33
+ import type { Ctx, Reducer } from '../types.js';
34
+
35
+ /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
36
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
37
+
38
+ function isValidChannelName(name: string, maxLen: number): boolean {
39
+ if (name.length === 0 || name.length > maxLen) return false;
40
+ return CHANNEL_NAME_RE.test(name);
41
+ }
42
+
43
+ /**
44
+ * Returns true iff the requester is permitted to see `chan` in LIST output.
45
+ * Secret channels require membership; everything else is public.
46
+ */
47
+ function isVisible(chan: ChanSnapshot, ctx: Ctx): boolean {
48
+ if (!chan.modes.secret) return true;
49
+ return chan.members.has(ctx.connId);
50
+ }
51
+
52
+ /** Formats a single `322 RPL_LIST` line for `chan`. */
53
+ function listLine(chan: ChanSnapshot, ctx: Ctx, nick: string): RawLine {
54
+ const codeStr = Numerics.RPL_LIST.toString().padStart(3, '0');
55
+ const topic = chan.topic?.text ?? '';
56
+ return {
57
+ text: `:${ctx.serverName} ${codeStr} ${nick} ${chan.name} ${chan.members.size} :${topic}`,
58
+ };
59
+ }
60
+
61
+ /** Splits a comma-separated channel-list param into trimmed tokens. */
62
+ function splitChannelParam(raw: string): string[] {
63
+ return raw
64
+ .split(',')
65
+ .map((s) => s.trim())
66
+ .filter((s) => s.length > 0);
67
+ }
68
+
69
+ /**
70
+ * Handles `LIST [<chan>{,<chan>}]`.
71
+ *
72
+ * Validation order (deliberate): visibility filter → cap → format.
73
+ * Unknown / invalid / non-existent channels in an explicit list are
74
+ * silently skipped (RFC 2812 §3.2.7).
75
+ */
76
+ export const listReducer: Reducer<ChanSnapshot[]> = (state, msg, ctx) => {
77
+ ctx.connection.lastSeen = ctx.clock.now();
78
+
79
+ const effects: EffectType[] = [];
80
+ const nick = ctx.connection.nick ?? '*';
81
+
82
+ const codeStr321 = Numerics.RPL_LISTSTART.toString().padStart(3, '0');
83
+ const codeStr323 = Numerics.RPL_LISTEND.toString().padStart(3, '0');
84
+
85
+ effects.push(
86
+ Effect.send(ctx.connId, [
87
+ { text: `:${ctx.serverName} ${codeStr321} ${nick} Channel :Users Name` },
88
+ ]),
89
+ );
90
+
91
+ const max = ctx.serverConfig.maxListEntries;
92
+ const lines: RawLine[] = [];
93
+
94
+ const rawParam = msg.params[0];
95
+ if (rawParam === undefined || rawParam.trim().length === 0) {
96
+ for (const chan of state) {
97
+ if (lines.length >= max) break;
98
+ if (!isVisible(chan, ctx)) continue;
99
+ lines.push(listLine(chan, ctx, nick));
100
+ }
101
+ } else {
102
+ const requested = splitChannelParam(rawParam);
103
+ const seen = new Set<string>();
104
+ for (const rawName of requested) {
105
+ if (lines.length >= max) break;
106
+ if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) continue;
107
+ const lower = rawName.toLowerCase();
108
+ if (seen.has(lower)) continue;
109
+ seen.add(lower);
110
+ const chan = state.find((c) => c.nameLower === lower);
111
+ if (chan === undefined) continue;
112
+ if (!isVisible(chan, ctx)) continue;
113
+ lines.push(listLine(chan, ctx, nick));
114
+ }
115
+ }
116
+
117
+ if (lines.length > 0) {
118
+ effects.push(Effect.send(ctx.connId, lines));
119
+ }
120
+
121
+ effects.push(
122
+ Effect.send(ctx.connId, [{ text: `:${ctx.serverName} ${codeStr323} ${nick} :End of /LIST` }]),
123
+ );
124
+
125
+ return { state, effects };
126
+ };