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,256 @@
1
+ /**
2
+ * Pure reducers for the IRC `PRIVMSG` and `NOTICE` commands.
3
+ *
4
+ * PLAN §2.1 location-of-authority splits these by target type:
5
+ *
6
+ * - **Channel target** (`PRIVMSG #foo :text`): the channel is authoritative
7
+ * for `+n`/`+m`/`+b`, so the reducer's `state` is the channel's
8
+ * {@link ChannelState}. Sender identity is `ctx.connection`. Emits a
9
+ * single `Broadcast` effect (excluding the sender; `echo-message` echo
10
+ * handled elsewhere).
11
+ * - **User target** (`PRIVMSG bob :text`): the connection is authoritative
12
+ * for the sender's identity, so the reducer's `state` is the sender's
13
+ * {@link ConnectionState}. Emits a `SendToNick` effect that the dispatch
14
+ * layer resolves via `lookupNick`; if the recipient is offline the
15
+ * optional `notFoundLines` (PRIVMSG only) carries the `401` back to the
16
+ * sender.
17
+ *
18
+ * Multi-target commands (`PRIVMSG #a,#b` / `PRIVMSG a,b`) are split by the
19
+ * actor layer, which also enforces `maxTargetsPerCommand`;
20
+ * each reducer handles exactly one target per invocation.
21
+ *
22
+ * RFC-critical: **`NOTICE` MUST NEVER produce a numeric error reply.** All
23
+ * rejection paths short-circuit with an empty effect list for NOTICE.
24
+ */
25
+
26
+ import { Effect } from '../effects.js';
27
+ import type { Effect as EffectType, RawLine } from '../effects.js';
28
+ import { Numerics } from '../protocol/numerics.js';
29
+ import type { ChannelState } from '../state/channel.js';
30
+ import { hostmaskOf } from '../state/connection.js';
31
+ import type { ConnectionState } from '../state/connection.js';
32
+ import type { Ctx, Reducer } from '../types.js';
33
+
34
+ /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
35
+ const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
36
+
37
+ /** Returns true iff `name` looks like a channel target (vs. a nick target). */
38
+ function isChannelTarget(name: string): boolean {
39
+ return CHANNEL_NAME_RE.test(name);
40
+ }
41
+
42
+ /**
43
+ * Returns true iff `hostmask` matches the IRC ban mask `mask`. Identical to
44
+ * the implementation in `join.ts`; duplicated to keep the channel-message
45
+ * path self-contained (ban-matching consolidation is planned).
46
+ */
47
+ function matchesBanMask(mask: string, hostmask: string): boolean {
48
+ let pattern = '^';
49
+ for (const ch of mask) {
50
+ if (ch === '*') {
51
+ pattern += '.*';
52
+ } else if (ch === '?') {
53
+ pattern += '.';
54
+ } else if (/[A-Za-z0-9]/.test(ch)) {
55
+ pattern += ch;
56
+ } else {
57
+ pattern += `\\${ch}`;
58
+ }
59
+ }
60
+ pattern += '$';
61
+ return new RegExp(pattern, 'i').test(hostmask);
62
+ }
63
+
64
+ /**
65
+ * Formats a numeric error line addressed to the connection's current nick
66
+ * (or `*` when unregistered).
67
+ */
68
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
69
+ const nick = ctx.connection.nick ?? '*';
70
+ const codeStr = code.toString().padStart(3, '0');
71
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
72
+ if (middle !== undefined) parts.push(middle);
73
+ parts.push(`:${trailing}`);
74
+ return { text: parts.join(' ') };
75
+ }
76
+
77
+ /** Resolves the sender's hostmask, falling back to nick then `?`. */
78
+ function senderHostmask(conn: ConnectionState): string {
79
+ return hostmaskOf(conn) ?? conn.nick ?? '?';
80
+ }
81
+
82
+ /**
83
+ * Returns true iff `entry` may speak on a moderated channel: ops and voiced
84
+ * members pass; everyone else is blocked.
85
+ */
86
+ function maySpeakOnModerated(entry: { op: boolean; voice: boolean } | undefined): boolean {
87
+ if (entry === undefined) return false;
88
+ return entry.op || entry.voice;
89
+ }
90
+
91
+ /** Command flavor: PRIVMSG surfaces errors; NOTICE is silent. */
92
+ type MessageCommand = 'PRIVMSG' | 'NOTICE';
93
+
94
+ /**
95
+ * Builds a channel-target reducer for one of PRIVMSG / NOTICE. The shared
96
+ * body validates params, checks `+n` / `+m` / `+b`, and emits `Broadcast`.
97
+ */
98
+ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
99
+ return (state, msg, ctx) => {
100
+ ctx.connection.lastSeen = ctx.clock.now();
101
+
102
+ const effects: EffectType[] = [];
103
+ const silent = command === 'NOTICE';
104
+
105
+ const target = msg.params[0];
106
+ const text = msg.params[1];
107
+
108
+ if (target === undefined) {
109
+ if (!silent) {
110
+ effects.push(
111
+ Effect.send(ctx.connId, [
112
+ numericErr(ctx, Numerics.ERR_NORECIPIENT, `No recipient given (${command})`),
113
+ ]),
114
+ );
115
+ }
116
+ return { state, effects };
117
+ }
118
+
119
+ if (text === undefined || text.length === 0) {
120
+ if (!silent) {
121
+ effects.push(
122
+ Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_NOTEXTTOSEND, 'No text to send')]),
123
+ );
124
+ }
125
+ return { state, effects };
126
+ }
127
+
128
+ const senderEntry = state.members.get(ctx.connId);
129
+ const hostmask = senderHostmask(ctx.connection);
130
+
131
+ // +n (no external messages): sender must be a member.
132
+ if (state.modes.noExternal && senderEntry === undefined) {
133
+ if (!silent) {
134
+ effects.push(
135
+ Effect.send(ctx.connId, [
136
+ numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
137
+ ]),
138
+ );
139
+ }
140
+ return { state, effects };
141
+ }
142
+
143
+ // +m (moderated): sender must be op or voiced.
144
+ if (state.modes.moderated && !maySpeakOnModerated(senderEntry)) {
145
+ if (!silent) {
146
+ effects.push(
147
+ Effect.send(ctx.connId, [
148
+ numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
149
+ ]),
150
+ );
151
+ }
152
+ return { state, effects };
153
+ }
154
+
155
+ // +b (ban): sender's hostmask must not match any ban mask.
156
+ if (state.banMasks.size > 0) {
157
+ for (const mask of state.banMasks) {
158
+ if (matchesBanMask(mask, hostmask)) {
159
+ if (!silent) {
160
+ effects.push(
161
+ Effect.send(ctx.connId, [
162
+ numericErr(
163
+ ctx,
164
+ Numerics.ERR_CANNOTSENDTOCHAN,
165
+ 'Cannot send to channel',
166
+ state.name,
167
+ ),
168
+ ]),
169
+ );
170
+ }
171
+ return { state, effects };
172
+ }
173
+ }
174
+ }
175
+
176
+ // All checks passed: broadcast to the channel, excluding the sender.
177
+ const line: RawLine = { text: `:${hostmask} ${command} ${state.name} :${text}` };
178
+ effects.push(Effect.broadcast(state.name, [line], ctx.connId));
179
+
180
+ // IRCv3 echo-message: echo the same line back to the sender, after
181
+ // the broadcast effect so clients observe their own message last.
182
+ if (ctx.connection.caps.has('echo-message')) {
183
+ effects.push(Effect.send(ctx.connId, [line]));
184
+ }
185
+
186
+ return { state, effects };
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Builds a user-target reducer for one of PRIVMSG / NOTICE. The shared body
192
+ * validates params and emits a `SendToNick` effect. PRIVMSG attaches a
193
+ * `401` fallback for offline recipients; NOTICE omits the fallback entirely.
194
+ */
195
+ function buildUserReducer(command: MessageCommand): Reducer<ConnectionState> {
196
+ return (state, msg, ctx) => {
197
+ state.lastSeen = ctx.clock.now();
198
+
199
+ const effects: EffectType[] = [];
200
+ const silent = command === 'NOTICE';
201
+
202
+ const target = msg.params[0];
203
+ const text = msg.params[1];
204
+
205
+ if (target === undefined) {
206
+ if (!silent) {
207
+ effects.push(
208
+ Effect.send(ctx.connId, [
209
+ numericErr(ctx, Numerics.ERR_NORECIPIENT, `No recipient given (${command})`),
210
+ ]),
211
+ );
212
+ }
213
+ return { state, effects };
214
+ }
215
+
216
+ if (text === undefined || text.length === 0) {
217
+ if (!silent) {
218
+ effects.push(
219
+ Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_NOTEXTTOSEND, 'No text to send')]),
220
+ );
221
+ }
222
+ return { state, effects };
223
+ }
224
+
225
+ const hostmask = senderHostmask(state);
226
+ const line: RawLine = { text: `:${hostmask} ${command} ${target} :${text}` };
227
+ const notFoundLines = silent
228
+ ? undefined
229
+ : [numericErr(ctx, Numerics.ERR_NOSUCHNICK, 'No such nick/channel', target)];
230
+
231
+ effects.push(Effect.sendToNick(target, ctx.connId, [line], notFoundLines));
232
+
233
+ // IRCv3 echo-message: echo the same line back to the sender, after
234
+ // the SendToNick effect so clients observe their own message last.
235
+ if (ctx.connection.caps.has('echo-message')) {
236
+ effects.push(Effect.send(ctx.connId, [line]));
237
+ }
238
+
239
+ return { state, effects };
240
+ };
241
+ }
242
+
243
+ /** Reducer for `PRIVMSG <channel> :<text>`. Authority: ChannelState. */
244
+ export const privmsgChannelReducer: Reducer<ChannelState> = buildChannelReducer('PRIVMSG');
245
+
246
+ /** Reducer for `NOTICE <channel> :<text>`. Authority: ChannelState. */
247
+ export const noticeChannelReducer: Reducer<ChannelState> = buildChannelReducer('NOTICE');
248
+
249
+ /** Reducer for `PRIVMSG <nick> :<text>`. Authority: sender's ConnectionState. */
250
+ export const privmsgUserReducer: Reducer<ConnectionState> = buildUserReducer('PRIVMSG');
251
+
252
+ /** Reducer for `NOTICE <nick> :<text>`. Authority: sender's ConnectionState. */
253
+ export const noticeUserReducer: Reducer<ConnectionState> = buildUserReducer('NOTICE');
254
+
255
+ /** Exported for the actor layer so it knows which reducer a target string routes to. */
256
+ export { isChannelTarget };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure reducer for the IRC `QUIT` command.
3
+ *
4
+ * Per PLAN §2.1 this runs in the Connection entity. It does not own the
5
+ * channel rosters or the nick registry, so every mutation is expressed as
6
+ * an effect the runtime interprets:
7
+ *
8
+ * - `Broadcast(QUIT)` to each joined channel (excluding the quitter,
9
+ * who is being disconnected anyway).
10
+ * - `ApplyChannelDelta` removing the connection from each channel roster.
11
+ * - `ReleaseNick` for the reserved nick (only if registered).
12
+ * - `Disconnect` to close the transport.
13
+ *
14
+ * The reducer is intentionally idempotent for never-registered connections:
15
+ * with no nick and no joined channels there is nothing to broadcast or
16
+ * release, so the only effect is `Disconnect`.
17
+ */
18
+
19
+ import { Effect } from '../effects.js';
20
+ import type { RawLine } from '../effects.js';
21
+ import type { ChanName } from '../state/channel.js';
22
+ import { hostmaskOf } from '../state/connection.js';
23
+ import type { ConnectionState } from '../state/connection.js';
24
+ import type { Reducer } from '../types.js';
25
+
26
+ /**
27
+ * Handles `QUIT [:reason]`.
28
+ *
29
+ * Reason resolution: a non-empty `params[0]` is used verbatim; anything
30
+ * else (no params, or an explicit empty trailing) falls back to
31
+ * `ctx.serverConfig.quitMessage`.
32
+ */
33
+ export const quitReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
34
+ state.lastSeen = ctx.clock.now();
35
+
36
+ const effects = [];
37
+
38
+ const nick = state.nick;
39
+ if (nick !== undefined) {
40
+ const hostmask = hostmaskOf(state);
41
+ const reason = pickReason(msg.params, ctx.serverConfig.quitMessage);
42
+ const line: RawLine = { text: `:${hostmask} QUIT :${reason}` };
43
+
44
+ for (const chan of state.joinedChannels) {
45
+ effects.push(Effect.broadcast(chan as ChanName, [line], ctx.connId));
46
+ }
47
+ for (const chan of state.joinedChannels) {
48
+ effects.push(
49
+ Effect.applyChannelDelta(chan as ChanName, {
50
+ memberships: [{ type: 'remove', conn: ctx.connId }],
51
+ }),
52
+ );
53
+ }
54
+ effects.push(Effect.releaseNick(nick));
55
+ }
56
+
57
+ effects.push(Effect.disconnect(ctx.connId));
58
+
59
+ return { state, effects };
60
+ };
61
+
62
+ /**
63
+ * Returns the provided reason if it is a non-empty string, otherwise the
64
+ * configured default. Treats `QUIT` and `QUIT :` identically (per modern
65
+ * client behavior).
66
+ */
67
+ function pickReason(params: string[], fallback: string): string {
68
+ const supplied = params[0];
69
+ return supplied && supplied.length > 0 ? supplied : fallback;
70
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Pure reducers for the IRC registration handshake: `NICK`, `USER`, `PASS`.
3
+ *
4
+ * PLAN §2.1 location-of-authority: registration runs in the Connection
5
+ * entity. These reducers handle the pre-`CAP` handshake (CAP negotiation
6
+ * itself arrives in the CAP layer and calls back into {@link emitWelcomeIfReady}
7
+ * on `CAP END`).
8
+ *
9
+ * Nick collision (`433 ERR_NICKNAMEINUSE`) is detected by the Registry
10
+ * entity when it interprets the `ReserveNick` / `ChangeNick` effect; the
11
+ * reducer cannot observe that purely and so emits the reservation
12
+ * optimistically. The actor layer surfaces the collision
13
+ * numeric on `{ ok: false }`.
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 { hostmaskOf } from '../state/connection.js';
20
+ import type { ConnectionState } from '../state/connection.js';
21
+ import type { Ctx, Reducer } from '../types.js';
22
+ import { generateIsupport } from './isupport.js';
23
+
24
+ /**
25
+ * Server version reported in `002` / `004`. Placeholder until a config-driven
26
+ * version lands.
27
+ */
28
+ const SERVER_VERSION = '0.1.0';
29
+
30
+ /** "Created" text for `003 RPL_CREATED`. Placeholder. */
31
+ const CREATED_TEXT = 'Phase 1';
32
+
33
+ /** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
34
+ const USER_MODES_004 = 'iosw';
35
+
36
+ /** Channel mode letters for `004 RPL_MYINFO` (matches PLAN §3 channel mode set). */
37
+ const CHANNEL_MODES_004 = 'biklmnstp';
38
+
39
+ /**
40
+ * RFC 1459/2812 nickname grammar. First char must be a letter; subsequent
41
+ * chars may be letters, digits, underscore, hyphen, or the "special" set
42
+ * `[]\`^{|}`.
43
+ */
44
+ const NICK_GRAMMAR_RE = /^[A-Za-z][\w\-\[\]\\`^{|}]*$/u;
45
+
46
+ /**
47
+ * Returns true iff `nick` matches the IRC nick grammar and is within the
48
+ * configured length cap.
49
+ */
50
+ export function isValidNick(nick: string, maxLen: number): boolean {
51
+ if (nick.length === 0 || nick.length > maxLen) return false;
52
+ return NICK_GRAMMAR_RE.test(nick);
53
+ }
54
+
55
+ /**
56
+ * Builds the welcome numeric block emitted once registration completes:
57
+ * `001`–`005` (with `005` as a placeholder) followed by
58
+ * the `375`/`372`/`376` MOTD placeholders (real MOTD content via the
59
+ * `MotdProvider` port).
60
+ *
61
+ * Caller guarantees `state.nick` and `state.user` are set.
62
+ */
63
+ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
64
+ const prefix = `:${ctx.serverName}`;
65
+ const nick = state.nick;
66
+ if (nick === undefined) return [];
67
+ // hostmaskOf returns string|undefined, but after the guard above nick is
68
+ // always defined, so hostmaskOf always returns at least `nick`. The cast
69
+ // avoids a dead `??` branch that v8 coverage cannot satisfy.
70
+ const hostmask = hostmaskOf(state) as string;
71
+
72
+ return [
73
+ { text: `${prefix} 001 ${nick} :Welcome to the ${ctx.networkName} IRC Network, ${hostmask}` },
74
+ {
75
+ text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${SERVER_VERSION}`,
76
+ },
77
+ { text: `${prefix} 003 ${nick} :This server was created in ${CREATED_TEXT}` },
78
+ {
79
+ text: `${prefix} 004 ${nick} ${ctx.serverName} ${SERVER_VERSION} ${USER_MODES_004} ${CHANNEL_MODES_004}`,
80
+ },
81
+ // 005 RPL_ISUPPORT — real tokens generated from serverConfig.
82
+ ...generateIsupport(ctx.serverConfig, state.caps, nick),
83
+ // MOTD placeholders — filled via MotdProvider elsewhere.
84
+ { text: `${prefix} 375 ${nick} :- ${ctx.serverName} Message of the day -` },
85
+ { text: `${prefix} 372 ${nick} :- MOTD placeholder` },
86
+ { text: `${prefix} 376 ${nick} :End of MOTD command` },
87
+ ];
88
+ }
89
+
90
+ /**
91
+ * Attempts to finalize registration: if both `nick` and `user` are present
92
+ * and the connection is not yet registered, transitions `state.registration`
93
+ * to `'registered'` and returns a `Send` effect carrying the welcome block.
94
+ * Otherwise returns `null` and leaves state untouched.
95
+ *
96
+ * Reused by the CAP `END` handler.
97
+ */
98
+ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType | null {
99
+ if (state.registration === 'registered') return null;
100
+ if (state.nick === undefined || state.user === undefined) return null;
101
+ if (state.capNegotiating) return null;
102
+ state.registration = 'registered';
103
+ return Effect.send(ctx.connId, buildWelcomeLines(state, ctx));
104
+ }
105
+
106
+ /**
107
+ * Formats a single numeric error line addressed to the connection's current
108
+ * nick (or `*` when unregistered). Optional `middle` param is emitted before
109
+ * the trailing `:`-prefixed text — used for `432 <badnick>` and
110
+ * `461 <command>`.
111
+ */
112
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
113
+ const nick = ctx.connection.nick ?? '*';
114
+ const codeStr = code.toString().padStart(3, '0');
115
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
116
+ if (middle !== undefined) parts.push(middle);
117
+ parts.push(`:${trailing}`);
118
+ return { text: parts.join(' ') };
119
+ }
120
+
121
+ /**
122
+ * Reducer for `NICK <nickname>`.
123
+ *
124
+ * Pre-registration: validates grammar, stashes the nick on the connection,
125
+ * emits `ReserveNick`, and attempts to complete registration.
126
+ *
127
+ * Post-registration: validates grammar, emits `ChangeNick` plus the
128
+ * `:oldhostmask NICK newnick` echo back to the client.
129
+ */
130
+ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
131
+ const effects: EffectType[] = [];
132
+ const nick = msg.params[0];
133
+
134
+ if (nick === undefined) {
135
+ effects.push(
136
+ Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_NONICKNAMEGIVEN, 'No nickname given')]),
137
+ );
138
+ return { state, effects };
139
+ }
140
+
141
+ if (!isValidNick(nick, ctx.serverConfig.nickLen)) {
142
+ effects.push(
143
+ Effect.send(ctx.connId, [
144
+ numericErr(ctx, Numerics.ERR_ERRONEUSNICKNAME, 'Erroneous nickname', nick),
145
+ ]),
146
+ );
147
+ return { state, effects };
148
+ }
149
+
150
+ if (state.registration === 'registered') {
151
+ const oldNick = state.nick;
152
+ if (oldNick === undefined) {
153
+ return { state, effects };
154
+ }
155
+ // hostmaskOf is guaranteed non-undefined here because oldNick is defined.
156
+ const source = hostmaskOf(state) as string;
157
+ state.nick = nick;
158
+ effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
159
+ effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));
160
+ return { state, effects };
161
+ }
162
+
163
+ state.nick = nick;
164
+ if (state.registration === 'pre-registration') {
165
+ state.registration = 'registering';
166
+ }
167
+ effects.push(Effect.reserveNick(nick, ctx.connId));
168
+
169
+ const welcome = emitWelcomeIfReady(state, ctx);
170
+ if (welcome !== null) effects.push(welcome);
171
+
172
+ return { state, effects };
173
+ };
174
+
175
+ /**
176
+ * Reducer for `USER <user> <mode> <unused> :<realname>` (RFC 2812 form).
177
+ *
178
+ * Stores username (params[0]) and realname (params[3]); the historical
179
+ * mode/hostname/servername slots (params[1], params[2]) are ignored. If
180
+ * `NICK` has already been received, completes registration and emits the
181
+ * welcome block.
182
+ */
183
+ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
184
+ const effects: EffectType[] = [];
185
+
186
+ if (state.registration === 'registered') {
187
+ effects.push(
188
+ Effect.send(ctx.connId, [
189
+ numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
190
+ ]),
191
+ );
192
+ return { state, effects };
193
+ }
194
+
195
+ const user = msg.params[0];
196
+ const realname = msg.params[3];
197
+ if (user === undefined || realname === undefined) {
198
+ effects.push(
199
+ Effect.send(ctx.connId, [
200
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'USER'),
201
+ ]),
202
+ );
203
+ return { state, effects };
204
+ }
205
+
206
+ state.user = user;
207
+ state.realname = realname;
208
+ if (state.registration === 'pre-registration') {
209
+ state.registration = 'registering';
210
+ }
211
+
212
+ const welcome = emitWelcomeIfReady(state, ctx);
213
+ if (welcome !== null) effects.push(welcome);
214
+
215
+ return { state, effects };
216
+ };
217
+
218
+ /**
219
+ * Reducer for `PASS <password>`.
220
+ *
221
+ * Only stashes the attempt as `state.passAttempt`. Actual server-password
222
+ * enforcement lives in the actor layer / security hardening
223
+ * since it requires comparing against the deployment's configured
224
+ * password, which the pure reducer does not see.
225
+ */
226
+ export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
227
+ const effects: EffectType[] = [];
228
+
229
+ if (state.registration === 'registered') {
230
+ effects.push(
231
+ Effect.send(ctx.connId, [
232
+ numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
233
+ ]),
234
+ );
235
+ return { state, effects };
236
+ }
237
+
238
+ const password = msg.params[0];
239
+ if (password === undefined) {
240
+ effects.push(
241
+ Effect.send(ctx.connId, [
242
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
243
+ ]),
244
+ );
245
+ return { state, effects };
246
+ }
247
+
248
+ state.passAttempt = password;
249
+
250
+ return { state, effects };
251
+ };