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,599 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateIsupport } from '../../src/commands/isupport';
3
+ import {
4
+ buildWelcomeLines,
5
+ emitWelcomeIfReady,
6
+ isValidNick,
7
+ nickReducer,
8
+ passReducer,
9
+ userReducer,
10
+ } from '../../src/commands/registration';
11
+ import { Effect } from '../../src/effects';
12
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
13
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
14
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
15
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
16
+
17
+ const serverConfig: ServerConfig = {
18
+ serverName: 'irc.example.com',
19
+ networkName: 'ExampleNet',
20
+ maxChannelsPerUser: 30,
21
+ maxTargetsPerCommand: 10,
22
+ maxListEntries: 50,
23
+ nickLen: 30,
24
+ channelLen: 50,
25
+ topicLen: 390,
26
+ quitMessage: 'Client Quit',
27
+ };
28
+
29
+ function makeCtx(state: ConnectionState, clock = new FakeClock(1_000)): Ctx {
30
+ return buildCtx({
31
+ serverConfig,
32
+ clock,
33
+ ids: new SequentialIdFactory(),
34
+ motd: EmptyMotdProvider,
35
+ connection: state,
36
+ });
37
+ }
38
+
39
+ function makeState(): ConnectionState {
40
+ return createConnection({ id: 'c1', connectedSince: 0 });
41
+ }
42
+
43
+ /** Convenience: a fully-registered-ready state with nick + user already set. */
44
+ function registeredState(): ConnectionState {
45
+ const s = makeState();
46
+ s.nick = 'alice';
47
+ s.user = 'alice';
48
+ s.host = 'example.com';
49
+ s.realname = 'Alice';
50
+ s.registration = 'registered';
51
+ return s;
52
+ }
53
+
54
+ const L = (text: string): RawLine => ({ text });
55
+
56
+ /** Builds the expected welcome block for the given nick/user/host. */
57
+ function expectedWelcome(nick: string, user: string, host: string | undefined): RawLine[] {
58
+ const sv = 'irc.example.com';
59
+ const hostmask = host !== undefined ? `${nick}!${user}@${host}` : `${nick}!${user}`;
60
+ const prefix = `:${sv}`;
61
+ const isupport = generateIsupport(
62
+ {
63
+ serverName: sv,
64
+ networkName: 'ExampleNet',
65
+ maxChannelsPerUser: 30,
66
+ maxTargetsPerCommand: 10,
67
+ maxListEntries: 50,
68
+ nickLen: 30,
69
+ channelLen: 50,
70
+ topicLen: 390,
71
+ quitMessage: 'Client Quit',
72
+ },
73
+ new Set<string>(),
74
+ nick,
75
+ );
76
+ return [
77
+ L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
78
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.1.0`),
79
+ L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
80
+ L(`${prefix} 004 ${nick} ${sv} 0.1.0 iosw biklmnstp`),
81
+ ...isupport,
82
+ L(`${prefix} 375 ${nick} :- ${sv} Message of the day -`),
83
+ L(`${prefix} 372 ${nick} :- MOTD placeholder`),
84
+ L(`${prefix} 376 ${nick} :End of MOTD command`),
85
+ ];
86
+ }
87
+
88
+ // ============================================================================
89
+ // isValidNick
90
+ // ============================================================================
91
+
92
+ describe('isValidNick', () => {
93
+ it('accepts a simple alpha nick', () => {
94
+ expect(isValidNick('alice', 30)).toBe(true);
95
+ });
96
+
97
+ it('accepts a single-letter nick', () => {
98
+ expect(isValidNick('a', 30)).toBe(true);
99
+ });
100
+
101
+ it('accepts digits after the first char', () => {
102
+ expect(isValidNick('a1b2', 30)).toBe(true);
103
+ });
104
+
105
+ it('accepts underscores', () => {
106
+ expect(isValidNick('a_b', 30)).toBe(true);
107
+ });
108
+
109
+ it('accepts hyphens', () => {
110
+ expect(isValidNick('a-b', 30)).toBe(true);
111
+ });
112
+
113
+ it('accepts square brackets', () => {
114
+ expect(isValidNick('a[b]c', 30)).toBe(true);
115
+ });
116
+
117
+ it('accepts backslash', () => {
118
+ expect(isValidNick('a\\b', 30)).toBe(true);
119
+ });
120
+
121
+ it('accepts backtick', () => {
122
+ expect(isValidNick('a`b', 30)).toBe(true);
123
+ });
124
+
125
+ it('accepts caret', () => {
126
+ expect(isValidNick('a^b', 30)).toBe(true);
127
+ });
128
+
129
+ it('accepts curly braces', () => {
130
+ expect(isValidNick('a{b}c', 30)).toBe(true);
131
+ });
132
+
133
+ it('accepts pipe', () => {
134
+ expect(isValidNick('a|b', 30)).toBe(true);
135
+ });
136
+
137
+ it('accepts uppercase letters', () => {
138
+ expect(isValidNick('Alice', 30)).toBe(true);
139
+ });
140
+
141
+ it('rejects a digit-first nick', () => {
142
+ expect(isValidNick('1alice', 30)).toBe(false);
143
+ });
144
+
145
+ it('rejects a hyphen-first nick', () => {
146
+ expect(isValidNick('-alice', 30)).toBe(false);
147
+ });
148
+
149
+ it('rejects an underscore-first nick', () => {
150
+ expect(isValidNick('_alice', 30)).toBe(false);
151
+ });
152
+
153
+ it('rejects an empty string', () => {
154
+ expect(isValidNick('', 30)).toBe(false);
155
+ });
156
+
157
+ it('rejects spaces', () => {
158
+ expect(isValidNick('a b', 30)).toBe(false);
159
+ });
160
+
161
+ it('rejects exclamation marks', () => {
162
+ expect(isValidNick('a!b', 30)).toBe(false);
163
+ });
164
+
165
+ it('rejects dots', () => {
166
+ expect(isValidNick('a.b', 30)).toBe(false);
167
+ });
168
+
169
+ it('rejects a nick exceeding the length cap', () => {
170
+ expect(isValidNick('a'.repeat(31), 30)).toBe(false);
171
+ });
172
+
173
+ it('accepts a nick at exactly the length cap', () => {
174
+ expect(isValidNick(`a${'b'.repeat(29)}`, 30)).toBe(true);
175
+ });
176
+ });
177
+
178
+ // ============================================================================
179
+ // nickReducer
180
+ // ============================================================================
181
+
182
+ describe('nickReducer', () => {
183
+ it('reserves the nick and transitions to registering when user is not set', () => {
184
+ const state = makeState();
185
+ const ctx = makeCtx(state);
186
+ const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);
187
+ expect(out.state.nick).toBe('alice');
188
+ expect(out.state.registration).toBe('registering');
189
+ expect(out.effects).toEqual<EffectType[]>([Effect.reserveNick('alice', 'c1')]);
190
+ });
191
+
192
+ it('completes registration when NICK arrives after USER', () => {
193
+ const state = makeState();
194
+ state.user = 'alice';
195
+ state.realname = 'Alice';
196
+ state.registration = 'registering';
197
+ const ctx = makeCtx(state);
198
+ const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);
199
+ expect(out.state.nick).toBe('alice');
200
+ expect(out.state.registration).toBe('registered');
201
+ expect(out.effects).toEqual<EffectType[]>([
202
+ Effect.reserveNick('alice', 'c1'),
203
+ Effect.send('c1', expectedWelcome('alice', 'alice', undefined)),
204
+ ]);
205
+ });
206
+
207
+ it('completes registration with full hostmask when host is set', () => {
208
+ const state = makeState();
209
+ state.user = 'alice';
210
+ state.realname = 'Alice';
211
+ state.host = 'example.com';
212
+ state.registration = 'registering';
213
+ const ctx = makeCtx(state);
214
+ const out = nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);
215
+ expect(out.effects).toContainEqual(
216
+ Effect.send('c1', expectedWelcome('alice', 'alice', 'example.com')),
217
+ );
218
+ });
219
+
220
+ it('emits 431 when no nickname is supplied', () => {
221
+ const state = makeState();
222
+ const ctx = makeCtx(state);
223
+ const out = nickReducer(state, { command: 'NICK', params: [], tags: {} }, ctx);
224
+ expect(out.effects).toEqual<EffectType[]>([
225
+ Effect.send('c1', [L(':irc.example.com 431 * :No nickname given')]),
226
+ ]);
227
+ expect(out.state.nick).toBeUndefined();
228
+ expect(out.state.registration).toBe('pre-registration');
229
+ });
230
+
231
+ it('emits 432 for a digit-first nick', () => {
232
+ const state = makeState();
233
+ const ctx = makeCtx(state);
234
+ const out = nickReducer(state, { command: 'NICK', params: ['1alice'], tags: {} }, ctx);
235
+ expect(out.effects).toEqual<EffectType[]>([
236
+ Effect.send('c1', [L(':irc.example.com 432 * 1alice :Erroneous nickname')]),
237
+ ]);
238
+ expect(out.state.nick).toBeUndefined();
239
+ });
240
+
241
+ it('emits 432 for a nick containing an invalid char', () => {
242
+ const state = makeState();
243
+ const ctx = makeCtx(state);
244
+ const out = nickReducer(state, { command: 'NICK', params: ['al!ce'], tags: {} }, ctx);
245
+ expect(out.effects).toEqual<EffectType[]>([
246
+ Effect.send('c1', [L(':irc.example.com 432 * al!ce :Erroneous nickname')]),
247
+ ]);
248
+ });
249
+
250
+ it('emits 432 for an overlong nick', () => {
251
+ const state = makeState();
252
+ const ctx = makeCtx(state);
253
+ const longNick = `a${'b'.repeat(30)}`;
254
+ const out = nickReducer(state, { command: 'NICK', params: [longNick], tags: {} }, ctx);
255
+ expect(out.effects).toEqual<EffectType[]>([
256
+ Effect.send('c1', [L(`:irc.example.com 432 * ${longNick} :Erroneous nickname`)]),
257
+ ]);
258
+ });
259
+
260
+ it('uses the current nick (not *) in the 432 reply when partially registered', () => {
261
+ const state = makeState();
262
+ state.nick = 'alice';
263
+ state.registration = 'registering';
264
+ const ctx = makeCtx(state);
265
+ const out = nickReducer(state, { command: 'NICK', params: ['1bad'], tags: {} }, ctx);
266
+ expect(out.effects).toEqual<EffectType[]>([
267
+ Effect.send('c1', [L(':irc.example.com 432 alice 1bad :Erroneous nickname')]),
268
+ ]);
269
+ });
270
+
271
+ it('emits ChangeNick and echoes back on post-registration nick change', () => {
272
+ const state = registeredState();
273
+ const ctx = makeCtx(state);
274
+ const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
275
+ expect(out.state.nick).toBe('bob');
276
+ expect(out.effects).toEqual<EffectType[]>([
277
+ Effect.changeNick('c1', 'alice', 'bob'),
278
+ Effect.send('c1', [L(':alice!alice@example.com NICK bob')]),
279
+ ]);
280
+ expect(out.state.registration).toBe('registered');
281
+ });
282
+
283
+ it('echoes back with partial hostmask when host is absent on nick change', () => {
284
+ const state = makeState();
285
+ state.nick = 'alice';
286
+ state.user = 'alice';
287
+ state.realname = 'Alice';
288
+ state.registration = 'registered';
289
+ const ctx = makeCtx(state);
290
+ const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
291
+ expect(out.effects).toContainEqual(Effect.send('c1', [L(':alice!alice NICK bob')]));
292
+ });
293
+
294
+ it('still validates grammar on post-registration nick change', () => {
295
+ const state = registeredState();
296
+ const ctx = makeCtx(state);
297
+ const out = nickReducer(state, { command: 'NICK', params: ['1bad'], tags: {} }, ctx);
298
+ expect(out.effects).toEqual<EffectType[]>([
299
+ Effect.send('c1', [L(':irc.example.com 432 alice 1bad :Erroneous nickname')]),
300
+ ]);
301
+ expect(out.state.nick).toBe('alice');
302
+ });
303
+
304
+ it('still emits 431 on post-registration nick change with no param', () => {
305
+ const state = registeredState();
306
+ const ctx = makeCtx(state);
307
+ const out = nickReducer(state, { command: 'NICK', params: [], tags: {} }, ctx);
308
+ expect(out.effects).toEqual<EffectType[]>([
309
+ Effect.send('c1', [L(':irc.example.com 431 alice :No nickname given')]),
310
+ ]);
311
+ });
312
+
313
+ it('returns state unchanged when registered state has no nick (invariant violation)', () => {
314
+ const state = makeState();
315
+ state.registration = 'registered';
316
+ const ctx = makeCtx(state);
317
+ const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
318
+ expect(out.effects).toEqual([]);
319
+ expect(out.state.nick).toBeUndefined();
320
+ });
321
+ });
322
+
323
+ // ============================================================================
324
+ // userReducer
325
+ // ============================================================================
326
+
327
+ describe('userReducer', () => {
328
+ it('stores user and realname and transitions to registering when nick is not set', () => {
329
+ const state = makeState();
330
+ const ctx = makeCtx(state);
331
+ const out = userReducer(
332
+ state,
333
+ { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },
334
+ ctx,
335
+ );
336
+ expect(out.state.user).toBe('alice');
337
+ expect(out.state.realname).toBe('Alice');
338
+ expect(out.state.registration).toBe('registering');
339
+ expect(out.effects).toEqual([]);
340
+ });
341
+
342
+ it('completes registration when USER arrives after NICK', () => {
343
+ const state = makeState();
344
+ state.nick = 'alice';
345
+ state.registration = 'registering';
346
+ const ctx = makeCtx(state);
347
+ const out = userReducer(
348
+ state,
349
+ { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },
350
+ ctx,
351
+ );
352
+ expect(out.state.user).toBe('alice');
353
+ expect(out.state.realname).toBe('Alice');
354
+ expect(out.state.registration).toBe('registered');
355
+ expect(out.effects).toEqual<EffectType[]>([
356
+ Effect.send('c1', expectedWelcome('alice', 'alice', undefined)),
357
+ ]);
358
+ });
359
+
360
+ it('includes the host in the welcome hostmask when set', () => {
361
+ const state = makeState();
362
+ state.nick = 'alice';
363
+ state.host = 'example.com';
364
+ state.registration = 'registering';
365
+ const ctx = makeCtx(state);
366
+ const out = userReducer(
367
+ state,
368
+ { command: 'USER', params: ['alice', '0', '*', 'Alice'], tags: {} },
369
+ ctx,
370
+ );
371
+ expect(out.effects).toContainEqual(
372
+ Effect.send('c1', expectedWelcome('alice', 'alice', 'example.com')),
373
+ );
374
+ });
375
+
376
+ it('emits 461 when fewer than four params are supplied', () => {
377
+ const state = makeState();
378
+ const ctx = makeCtx(state);
379
+ const out = userReducer(state, { command: 'USER', params: ['alice', '0'], tags: {} }, ctx);
380
+ expect(out.effects).toEqual<EffectType[]>([
381
+ Effect.send('c1', [L(':irc.example.com 461 * USER :Not enough parameters')]),
382
+ ]);
383
+ expect(out.state.user).toBeUndefined();
384
+ expect(out.state.registration).toBe('pre-registration');
385
+ });
386
+
387
+ it('emits 461 when no params are supplied', () => {
388
+ const state = makeState();
389
+ const ctx = makeCtx(state);
390
+ const out = userReducer(state, { command: 'USER', params: [], tags: {} }, ctx);
391
+ expect(out.effects).toEqual<EffectType[]>([
392
+ Effect.send('c1', [L(':irc.example.com 461 * USER :Not enough parameters')]),
393
+ ]);
394
+ });
395
+
396
+ it('emits 462 when already registered', () => {
397
+ const state = registeredState();
398
+ const ctx = makeCtx(state);
399
+ const out = userReducer(
400
+ state,
401
+ { command: 'USER', params: ['bob', '0', '*', 'Bob'], tags: {} },
402
+ ctx,
403
+ );
404
+ expect(out.effects).toEqual<EffectType[]>([
405
+ Effect.send('c1', [L(':irc.example.com 462 alice :You may not reregister')]),
406
+ ]);
407
+ expect(out.state.user).toBe('alice');
408
+ });
409
+
410
+ it('uses the current nick in the 461 reply when partially registered', () => {
411
+ const state = makeState();
412
+ state.nick = 'alice';
413
+ state.registration = 'registering';
414
+ const ctx = makeCtx(state);
415
+ const out = userReducer(state, { command: 'USER', params: ['a'], tags: {} }, ctx);
416
+ expect(out.effects).toEqual<EffectType[]>([
417
+ Effect.send('c1', [L(':irc.example.com 461 alice USER :Not enough parameters')]),
418
+ ]);
419
+ });
420
+
421
+ it('stores a realname containing spaces from the trailing param', () => {
422
+ const state = makeState();
423
+ state.nick = 'alice';
424
+ state.registration = 'registering';
425
+ const ctx = makeCtx(state);
426
+ const out = userReducer(
427
+ state,
428
+ { command: 'USER', params: ['alice', '0', '*', 'Alice Wonderland'], tags: {} },
429
+ ctx,
430
+ );
431
+ expect(out.state.realname).toBe('Alice Wonderland');
432
+ });
433
+ });
434
+
435
+ // ============================================================================
436
+ // passReducer
437
+ // ============================================================================
438
+
439
+ describe('passReducer', () => {
440
+ it('stashes the password attempt with no effects when pre-registration', () => {
441
+ const state = makeState();
442
+ const ctx = makeCtx(state);
443
+ const out = passReducer(state, { command: 'PASS', params: ['secret'], tags: {} }, ctx);
444
+ expect(out.state.passAttempt).toBe('secret');
445
+ expect(out.effects).toEqual([]);
446
+ expect(out.state.registration).toBe('pre-registration');
447
+ });
448
+
449
+ it('does not validate the password (enforcement lives in the actor layer)', () => {
450
+ const state = makeState();
451
+ const ctx = makeCtx(state);
452
+ const out = passReducer(state, { command: 'PASS', params: ['wrong-password'], tags: {} }, ctx);
453
+ expect(out.state.passAttempt).toBe('wrong-password');
454
+ expect(out.effects).toEqual([]);
455
+ });
456
+
457
+ it('overwrites a previous passAttempt on repeated PASS', () => {
458
+ const state = makeState();
459
+ state.passAttempt = 'first';
460
+ const ctx = makeCtx(state);
461
+ const out = passReducer(state, { command: 'PASS', params: ['second'], tags: {} }, ctx);
462
+ expect(out.state.passAttempt).toBe('second');
463
+ });
464
+
465
+ it('emits 461 when no password is supplied', () => {
466
+ const state = makeState();
467
+ const ctx = makeCtx(state);
468
+ const out = passReducer(state, { command: 'PASS', params: [], tags: {} }, ctx);
469
+ expect(out.effects).toEqual<EffectType[]>([
470
+ Effect.send('c1', [L(':irc.example.com 461 * PASS :Not enough parameters')]),
471
+ ]);
472
+ expect(out.state.passAttempt).toBeUndefined();
473
+ });
474
+
475
+ it('emits 462 when already registered', () => {
476
+ const state = registeredState();
477
+ const ctx = makeCtx(state);
478
+ const out = passReducer(state, { command: 'PASS', params: ['secret'], tags: {} }, ctx);
479
+ expect(out.effects).toEqual<EffectType[]>([
480
+ Effect.send('c1', [L(':irc.example.com 462 alice :You may not reregister')]),
481
+ ]);
482
+ expect(out.state.passAttempt).toBeUndefined();
483
+ });
484
+ });
485
+
486
+ // ============================================================================
487
+ // buildWelcomeLines
488
+ // ============================================================================
489
+
490
+ describe('buildWelcomeLines', () => {
491
+ it('produces the welcome block in the correct numeric order', () => {
492
+ const state = makeState();
493
+ state.nick = 'alice';
494
+ state.user = 'alice';
495
+ state.host = 'example.com';
496
+ const lines = buildWelcomeLines(state, makeCtx(state));
497
+ // 8 base lines (001-004, one or more 005, 375, 372, 376).
498
+ expect(lines.length).toBeGreaterThanOrEqual(8);
499
+ const codes = lines.map((l) => l.text.split(' ')[1]);
500
+ // First four numerics in fixed order; 005 (>=1); final three in order.
501
+ expect(codes.slice(0, 4)).toEqual(['001', '002', '003', '004']);
502
+ expect(codes[4]).toBe('005');
503
+ expect(codes.at(-3)).toBe('375');
504
+ expect(codes.at(-2)).toBe('372');
505
+ expect(codes.at(-1)).toBe('376');
506
+ });
507
+
508
+ it('prefixes every line with the server source', () => {
509
+ const state = makeState();
510
+ state.nick = 'alice';
511
+ state.user = 'alice';
512
+ const lines = buildWelcomeLines(state, makeCtx(state));
513
+ for (const line of lines) {
514
+ expect(line.text.startsWith(':irc.example.com ')).toBe(true);
515
+ }
516
+ });
517
+
518
+ it('builds the 001 hostmask from nick!user@host', () => {
519
+ const state = makeState();
520
+ state.nick = 'alice';
521
+ state.user = 'alice';
522
+ state.host = 'example.com';
523
+ const lines = buildWelcomeLines(state, makeCtx(state));
524
+ const welcome = lines[0];
525
+ expect(welcome).toEqual(
526
+ L(
527
+ ':irc.example.com 001 alice :Welcome to the ExampleNet IRC Network, alice!alice@example.com',
528
+ ),
529
+ );
530
+ });
531
+
532
+ it('builds the 001 hostmask without @host when host is absent', () => {
533
+ const state = makeState();
534
+ state.nick = 'alice';
535
+ state.user = 'alice';
536
+ const lines = buildWelcomeLines(state, makeCtx(state));
537
+ const welcome = lines[0];
538
+ expect(welcome).toEqual(
539
+ L(':irc.example.com 001 alice :Welcome to the ExampleNet IRC Network, alice!alice'),
540
+ );
541
+ });
542
+
543
+ it('matches the full expected welcome block for a complete connection', () => {
544
+ const state = makeState();
545
+ state.nick = 'alice';
546
+ state.user = 'alice';
547
+ state.host = 'example.com';
548
+ const lines = buildWelcomeLines(state, makeCtx(state));
549
+ expect(lines).toEqual(expectedWelcome('alice', 'alice', 'example.com'));
550
+ });
551
+
552
+ it('returns an empty array when nick is absent (defensive)', () => {
553
+ const state = makeState();
554
+ const lines = buildWelcomeLines(state, makeCtx(state));
555
+ expect(lines).toEqual([]);
556
+ });
557
+ });
558
+
559
+ // ============================================================================
560
+ // emitWelcomeIfReady
561
+ // ============================================================================
562
+
563
+ describe('emitWelcomeIfReady', () => {
564
+ it('returns a Send effect and transitions to registered when nick and user are set', () => {
565
+ const state = makeState();
566
+ state.nick = 'alice';
567
+ state.user = 'alice';
568
+ const ctx = makeCtx(state);
569
+ const effect = emitWelcomeIfReady(state, ctx);
570
+ expect(effect).toEqual(Effect.send('c1', expectedWelcome('alice', 'alice', undefined)));
571
+ expect(state.registration).toBe('registered');
572
+ });
573
+
574
+ it('returns null and does not transition when nick is missing', () => {
575
+ const state = makeState();
576
+ state.user = 'alice';
577
+ const ctx = makeCtx(state);
578
+ const effect = emitWelcomeIfReady(state, ctx);
579
+ expect(effect).toBeNull();
580
+ expect(state.registration).toBe('pre-registration');
581
+ });
582
+
583
+ it('returns null and does not transition when user is missing', () => {
584
+ const state = makeState();
585
+ state.nick = 'alice';
586
+ const ctx = makeCtx(state);
587
+ const effect = emitWelcomeIfReady(state, ctx);
588
+ expect(effect).toBeNull();
589
+ expect(state.registration).toBe('pre-registration');
590
+ });
591
+
592
+ it('returns null when already registered', () => {
593
+ const state = registeredState();
594
+ const ctx = makeCtx(state);
595
+ const effect = emitWelcomeIfReady(state, ctx);
596
+ expect(effect).toBeNull();
597
+ expect(state.registration).toBe('registered');
598
+ });
599
+ });