serverless-ircd 0.7.0 → 0.8.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 (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -0,0 +1,807 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { NICKSERV_NICK, enforceRegisteredNick, nickservReducer } from '../../src/commands/nickserv';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType } from '../../src/effects';
5
+ import type { ServicesStore } from '../../src/ports';
6
+ import {
7
+ EmptyMotdProvider,
8
+ FakeClock,
9
+ InMemoryServicesStore,
10
+ SequentialIdFactory,
11
+ } from '../../src/ports';
12
+ import type { IrcMessage } from '../../src/protocol/messages';
13
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
14
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
15
+
16
+ const NOW = 1_700_000_000_000;
17
+
18
+ const serverConfig: ServerConfig = {
19
+ serverName: 'irc.example.com',
20
+ networkName: 'ExampleNet',
21
+ maxChannelsPerUser: 30,
22
+ maxTargetsPerCommand: 10,
23
+ maxListEntries: 50,
24
+ nickLen: 30,
25
+ channelLen: 50,
26
+ topicLen: 390,
27
+ quitMessage: 'Client Quit',
28
+ };
29
+
30
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
31
+ const s = createConnection({ id, connectedSince: 0 });
32
+ s.nick = nick;
33
+ s.user = nick;
34
+ s.host = 'example.com';
35
+ s.realname = nick;
36
+ s.registration = 'registered';
37
+ return s;
38
+ }
39
+
40
+ function makeCtx(
41
+ state: ConnectionState,
42
+ services?: ServicesStore,
43
+ clock = new FakeClock(NOW),
44
+ ): Ctx {
45
+ return buildCtx({
46
+ serverConfig,
47
+ clock,
48
+ ids: new SequentialIdFactory(),
49
+ motd: EmptyMotdProvider,
50
+ connection: state,
51
+ ...(services !== undefined ? { services } : {}),
52
+ });
53
+ }
54
+
55
+ function privmsg(body: string): IrcMessage {
56
+ return { command: 'PRIVMSG', params: [NICKSERV_NICK, body], tags: {} };
57
+ }
58
+
59
+ /** Extracts the text of every Send line emitted by the reducer. */
60
+ function sentTexts(effects: EffectType[]): string[] {
61
+ return effects.flatMap((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text) : []));
62
+ }
63
+
64
+ describe('nickservReducer — REGISTER', () => {
65
+ it('registers a fresh nick and confirms via NickServ NOTICE', () => {
66
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
67
+ const conn = makeConn();
68
+ const ctx = makeCtx(conn, services);
69
+
70
+ const out = nickservReducer(conn, privmsg('REGISTER hunter2 alice@example.com'), ctx);
71
+
72
+ expect(services.isRegisteredNick('alice')).toBe(true);
73
+ const rec = services.getNick('alice');
74
+ expect(rec?.email).toBe('alice@example.com');
75
+ expect(sentTexts(out.effects)).toContain(
76
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice is now registered.',
77
+ );
78
+ });
79
+
80
+ it('does NOT auto-identify after REGISTER (no +r, no account)', () => {
81
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
82
+ const conn = makeConn();
83
+ const ctx = makeCtx(conn, services);
84
+
85
+ nickservReducer(conn, privmsg('REGISTER hunter2 alice@example.com'), ctx);
86
+
87
+ expect(conn.userModes.registered).toBe(false);
88
+ expect(conn.account).toBeUndefined();
89
+ });
90
+
91
+ it('rejects REGISTER on an already-registered nick with a NOTICE', () => {
92
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
93
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
94
+ const conn = makeConn();
95
+ const ctx = makeCtx(conn, services);
96
+
97
+ const out = nickservReducer(conn, privmsg('REGISTER otherpw other@example.com'), ctx);
98
+
99
+ expect(sentTexts(out.effects)).toContain(
100
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice is already registered.',
101
+ );
102
+ });
103
+
104
+ it('rejects REGISTER with missing parameters', () => {
105
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
106
+ const conn = makeConn();
107
+ const ctx = makeCtx(conn, services);
108
+
109
+ const out = nickservReducer(conn, privmsg('REGISTER hunter2'), ctx);
110
+
111
+ expect(services.isRegisteredNick('alice')).toBe(false);
112
+ expect(sentTexts(out.effects)).toContain(
113
+ ':NickServ!NickServ@services NOTICE alice :Syntax: REGISTER <password> <email>',
114
+ );
115
+ });
116
+
117
+ it('rejects REGISTER with no body at all', () => {
118
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
119
+ const conn = makeConn();
120
+ const ctx = makeCtx(conn, services);
121
+
122
+ const out = nickservReducer(conn, privmsg(''), ctx);
123
+
124
+ expect(sentTexts(out.effects).some((t) => t.includes('NOTICE alice'))).toBe(true);
125
+ });
126
+
127
+ it('returns a help NOTICE when PRIVMSG has no body parameter at all', () => {
128
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
129
+ const conn = makeConn();
130
+ const ctx = makeCtx(conn, services);
131
+
132
+ const out = nickservReducer(
133
+ conn,
134
+ { command: 'PRIVMSG', params: [NICKSERV_NICK], tags: {} },
135
+ ctx,
136
+ );
137
+
138
+ expect(sentTexts(out.effects)).toContain(
139
+ ':NickServ!NickServ@services NOTICE alice :Available commands: REGISTER, IDENTIFY, DROP, INFO, SET',
140
+ );
141
+ });
142
+
143
+ it('returns a NOTICE when no services store is bound', () => {
144
+ const conn = makeConn();
145
+ const ctx = makeCtx(conn);
146
+
147
+ const out = nickservReducer(conn, privmsg('REGISTER hunter2 alice@example.com'), ctx);
148
+
149
+ expect(sentTexts(out.effects)).toContain(
150
+ ':NickServ!NickServ@services NOTICE alice :Services are not available on this server.',
151
+ );
152
+ });
153
+ });
154
+
155
+ describe('nickservReducer — IDENTIFY', () => {
156
+ it('sets +r and account on correct password (IDENTIFY <password>)', () => {
157
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
158
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
159
+ const conn = makeConn();
160
+ const ctx = makeCtx(conn, services);
161
+
162
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
163
+
164
+ expect(conn.account).toBe('alice');
165
+ expect(conn.userModes.registered).toBe(true);
166
+ expect(sentTexts(out.effects)).toContain(
167
+ ':NickServ!NickServ@services NOTICE alice :You are now identified for nick alice.',
168
+ );
169
+ });
170
+
171
+ it('sets +r and account for IDENTIFY <nick> <password>', () => {
172
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
173
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
174
+ // Connection is using nick "bob" but identifies as "alice".
175
+ const conn = makeConn('c1', 'bob');
176
+ const ctx = makeCtx(conn, services);
177
+
178
+ const out = nickservReducer(conn, privmsg('IDENTIFY alice hunter2'), ctx);
179
+
180
+ expect(conn.account).toBe('alice');
181
+ expect(conn.userModes.registered).toBe(true);
182
+ expect(sentTexts(out.effects)).toContain(
183
+ ':NickServ!NickServ@services NOTICE bob :You are now identified for nick alice.',
184
+ );
185
+ });
186
+
187
+ it('emits ACCOUNT to account-notify peers on successful identify', () => {
188
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
189
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
190
+ const conn = makeConn();
191
+ conn.joinedChannels.add('#foo');
192
+ const ctx = makeCtx(conn, services);
193
+
194
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
195
+
196
+ const broadcasts = out.effects.filter((e) => e.tag === 'Broadcast');
197
+ expect(broadcasts).toHaveLength(1);
198
+ if (broadcasts.length === 0) return;
199
+ const bcast = broadcasts[0];
200
+ if (bcast?.tag !== 'Broadcast') return;
201
+ expect(bcast.chan).toBe('#foo');
202
+ expect(bcast.cap).toBe('account-notify');
203
+ expect(bcast.capLines?.[0]?.text).toBe(':alice!alice@example.com ACCOUNT alice');
204
+ });
205
+
206
+ it('rejects IDENTIFY with wrong password (no +r, NOTICE only)', () => {
207
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
208
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
209
+ const conn = makeConn();
210
+ const ctx = makeCtx(conn, services);
211
+
212
+ const out = nickservReducer(conn, privmsg('IDENTIFY wrongpass'), ctx);
213
+
214
+ expect(conn.account).toBeUndefined();
215
+ expect(conn.userModes.registered).toBe(false);
216
+ expect(sentTexts(out.effects)).toContain(
217
+ ':NickServ!NickServ@services NOTICE alice :Invalid password.',
218
+ );
219
+ expect(out.effects.filter((e) => e.tag === 'Broadcast')).toHaveLength(0);
220
+ });
221
+
222
+ it('rejects IDENTIFY for an unregistered nick', () => {
223
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
224
+ const conn = makeConn();
225
+ const ctx = makeCtx(conn, services);
226
+
227
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
228
+
229
+ expect(conn.account).toBeUndefined();
230
+ expect(sentTexts(out.effects)).toContain(
231
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is not registered.',
232
+ );
233
+ });
234
+
235
+ it('rejects IDENTIFY with missing parameters', () => {
236
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
237
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
238
+ const conn = makeConn();
239
+ const ctx = makeCtx(conn, services);
240
+
241
+ const out = nickservReducer(conn, privmsg('IDENTIFY'), ctx);
242
+
243
+ expect(conn.account).toBeUndefined();
244
+ expect(sentTexts(out.effects)).toContain(
245
+ ':NickServ!NickServ@services NOTICE alice :Syntax: IDENTIFY [nick] <password>',
246
+ );
247
+ });
248
+
249
+ it('informs an already-identified connection', () => {
250
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
251
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
252
+ const conn = makeConn();
253
+ conn.account = 'alice';
254
+ conn.userModes.registered = true;
255
+ const ctx = makeCtx(conn, services);
256
+
257
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
258
+
259
+ expect(sentTexts(out.effects)).toContain(
260
+ ':NickServ!NickServ@services NOTICE alice :You are already identified.',
261
+ );
262
+ });
263
+
264
+ it('delivers queued memos on successful identify', () => {
265
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
266
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
267
+ services.recordMemo({
268
+ from: 'bob',
269
+ to: 'alice',
270
+ body: 'Hi alice!',
271
+ sentAt: NOW,
272
+ });
273
+ const conn = makeConn();
274
+ const ctx = makeCtx(conn, services);
275
+
276
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), ctx);
277
+
278
+ expect(sentTexts(out.effects)).toContain(
279
+ ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] Hi alice!',
280
+ );
281
+ const memos = services.listMemos('alice');
282
+ expect(memos[0]?.read).toBe(true);
283
+ });
284
+ });
285
+
286
+ describe('nickservReducer — DROP', () => {
287
+ it('removes the registration when the connection is identified', () => {
288
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
289
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
290
+ const conn = makeConn();
291
+ conn.account = 'alice';
292
+ conn.userModes.registered = true;
293
+ const ctx = makeCtx(conn, services);
294
+
295
+ const out = nickservReducer(conn, privmsg('DROP'), ctx);
296
+
297
+ expect(services.isRegisteredNick('alice')).toBe(false);
298
+ expect(sentTexts(out.effects)).toContain(
299
+ ':NickServ!NickServ@services NOTICE alice :Nickname alice has been dropped.',
300
+ );
301
+ });
302
+
303
+ it('clears +r and the account after DROP', () => {
304
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
305
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
306
+ const conn = makeConn();
307
+ conn.account = 'alice';
308
+ conn.userModes.registered = true;
309
+ const ctx = makeCtx(conn, services);
310
+
311
+ nickservReducer(conn, privmsg('DROP'), ctx);
312
+
313
+ expect(conn.account).toBeUndefined();
314
+ expect(conn.userModes.registered).toBe(false);
315
+ });
316
+
317
+ it('emits ACCOUNT * (logout) to account-notify peers after DROP', () => {
318
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
319
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
320
+ const conn = makeConn();
321
+ conn.account = 'alice';
322
+ conn.userModes.registered = true;
323
+ conn.joinedChannels.add('#foo');
324
+ const ctx = makeCtx(conn, services);
325
+
326
+ const out = nickservReducer(conn, privmsg('DROP'), ctx);
327
+
328
+ const bcast = out.effects.find((e) => e.tag === 'Broadcast');
329
+ if (bcast?.tag === 'Broadcast') {
330
+ expect(bcast.capLines?.[0]?.text).toBe(':alice!alice@example.com ACCOUNT *');
331
+ } else {
332
+ expect.fail('expected an ACCOUNT * broadcast');
333
+ }
334
+ });
335
+
336
+ it('requires identification before DROP', () => {
337
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
338
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
339
+ const conn = makeConn();
340
+ const ctx = makeCtx(conn, services);
341
+
342
+ const out = nickservReducer(conn, privmsg('DROP'), ctx);
343
+
344
+ expect(services.isRegisteredNick('alice')).toBe(true);
345
+ expect(sentTexts(out.effects)).toContain(
346
+ ':NickServ!NickServ@services NOTICE alice :You must identify before dropping a nick.',
347
+ );
348
+ });
349
+
350
+ it('guards DROP when the connection has an account but no nick (defensive)', () => {
351
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
352
+ const conn = makeConn();
353
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
354
+ delete conn.nick;
355
+ conn.account = 'leftover';
356
+ const ctx = makeCtx(conn, services);
357
+
358
+ const out = nickservReducer(conn, privmsg('DROP'), ctx);
359
+
360
+ expect(sentTexts(out.effects)).toContain(
361
+ ':NickServ!NickServ@services NOTICE * :You must identify before dropping a nick.',
362
+ );
363
+ });
364
+ });
365
+
366
+ describe('nickservReducer — INFO', () => {
367
+ it('reports info for a registered nick', () => {
368
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
369
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
370
+ const conn = makeConn();
371
+ const ctx = makeCtx(conn, services);
372
+
373
+ const out = nickservReducer(conn, privmsg('INFO'), ctx);
374
+
375
+ const texts = sentTexts(out.effects);
376
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Nick: alice');
377
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Account: alice');
378
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Email: alice@example.com');
379
+ });
380
+
381
+ it('reports info for an explicitly named registered nick', () => {
382
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
383
+ services.registerNick('bob', 'pw', 'bob@example.com');
384
+ const conn = makeConn();
385
+ const ctx = makeCtx(conn, services);
386
+
387
+ const out = nickservReducer(conn, privmsg('INFO bob'), ctx);
388
+
389
+ const texts = sentTexts(out.effects);
390
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Nick: bob');
391
+ expect(texts).toContain(':NickServ!NickServ@services NOTICE alice :Email: bob@example.com');
392
+ });
393
+
394
+ it('reports not-registered for an unknown nick', () => {
395
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
396
+ const conn = makeConn();
397
+ const ctx = makeCtx(conn, services);
398
+
399
+ const out = nickservReducer(conn, privmsg('INFO ghostie'), ctx);
400
+
401
+ expect(sentTexts(out.effects)).toContain(
402
+ ':NickServ!NickServ@services NOTICE alice :Nick ghostie is not registered.',
403
+ );
404
+ });
405
+
406
+ it('reports not-registered for the current nick when unregistered', () => {
407
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
408
+ const conn = makeConn();
409
+ const ctx = makeCtx(conn, services);
410
+
411
+ const out = nickservReducer(conn, privmsg('INFO'), ctx);
412
+
413
+ expect(sentTexts(out.effects)).toContain(
414
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is not registered.',
415
+ );
416
+ });
417
+
418
+ it('emits a syntax notice when INFO has no target and the connection has no nick', () => {
419
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
420
+ const conn = makeConn();
421
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
422
+ delete conn.nick;
423
+ const ctx = makeCtx(conn, services);
424
+
425
+ const out = nickservReducer(conn, privmsg('INFO'), ctx);
426
+
427
+ expect(sentTexts(out.effects)).toContain(
428
+ ':NickServ!NickServ@services NOTICE * :Syntax: INFO [nick]',
429
+ );
430
+ });
431
+ });
432
+
433
+ describe('nickservReducer — misc', () => {
434
+ it('returns a help NOTICE for an unknown subcommand', () => {
435
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
436
+ const conn = makeConn();
437
+ const ctx = makeCtx(conn, services);
438
+
439
+ const out = nickservReducer(conn, privmsg('FROBNICATE foo bar'), ctx);
440
+
441
+ expect(sentTexts(out.effects)).toContain(
442
+ ':NickServ!NickServ@services NOTICE alice :Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET',
443
+ );
444
+ });
445
+
446
+ it('updates lastSeen to ctx.clock.now()', () => {
447
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
448
+ const conn = makeConn();
449
+ const ctx = makeCtx(conn, services, new FakeClock(NOW + 500));
450
+
451
+ nickservReducer(conn, privmsg('INFO'), ctx);
452
+
453
+ expect(conn.lastSeen).toBe(NOW + 500);
454
+ });
455
+
456
+ it('is case-insensitive on the subcommand', () => {
457
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
458
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
459
+ const conn = makeConn();
460
+ const ctx = makeCtx(conn, services);
461
+
462
+ nickservReducer(conn, privmsg('identify hunter2'), ctx);
463
+
464
+ expect(conn.account).toBe('alice');
465
+ expect(conn.userModes.registered).toBe(true);
466
+ });
467
+ });
468
+
469
+ describe('NICKSERV_NICK constant', () => {
470
+ it('is the canonical NickServ pseudo-client nick', () => {
471
+ expect(NICKSERV_NICK).toBe('NickServ');
472
+ });
473
+ });
474
+
475
+ // ============================================================================
476
+ // SET ENFORCE
477
+ // ============================================================================
478
+
479
+ describe('nickservReducer — SET ENFORCE', () => {
480
+ it('sets the enforce policy on the caller-identified nick (ghost)', () => {
481
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
482
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
483
+ const conn = makeConn();
484
+ conn.account = 'alice';
485
+ conn.userModes.registered = true;
486
+ const ctx = makeCtx(conn, services);
487
+
488
+ const out = nickservReducer(conn, privmsg('SET ENFORCE ghost'), ctx);
489
+
490
+ expect(services.getNickEnforce('alice')).toBe('ghost');
491
+ expect(sentTexts(out.effects)).toContain(
492
+ ':NickServ!NickServ@services NOTICE alice :Enforcement for nick alice is now set to ghost.',
493
+ );
494
+ });
495
+
496
+ it('sets the enforce policy to kill', () => {
497
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
498
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
499
+ const conn = makeConn();
500
+ conn.account = 'alice';
501
+ conn.userModes.registered = true;
502
+ const ctx = makeCtx(conn, services);
503
+
504
+ nickservReducer(conn, privmsg('SET ENFORCE kill'), ctx);
505
+
506
+ expect(services.getNickEnforce('alice')).toBe('kill');
507
+ });
508
+
509
+ it('sets the enforce policy to none (explicit)', () => {
510
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
511
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
512
+ services.setNickEnforce('alice', 'ghost');
513
+ const conn = makeConn();
514
+ conn.account = 'alice';
515
+ conn.userModes.registered = true;
516
+ const ctx = makeCtx(conn, services);
517
+
518
+ nickservReducer(conn, privmsg('SET ENFORCE none'), ctx);
519
+
520
+ expect(services.getNickEnforce('alice')).toBe('none');
521
+ });
522
+
523
+ it('requires identification before SET ENFORCE', () => {
524
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
525
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
526
+ const conn = makeConn();
527
+ const ctx = makeCtx(conn, services);
528
+
529
+ const out = nickservReducer(conn, privmsg('SET ENFORCE ghost'), ctx);
530
+
531
+ expect(services.getNickEnforce('alice')).toBe('none');
532
+ expect(sentTexts(out.effects)).toContain(
533
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing enforcement.',
534
+ );
535
+ });
536
+
537
+ it('rejects SET ENFORCE on a nick the caller does not own', () => {
538
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
539
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
540
+ // bob is identified as bob, but tries to set enforce on alice's nick.
541
+ services.registerNick('bob', 'pw', 'bob@example.com');
542
+ const conn = makeConn('c1', 'alice');
543
+ conn.account = 'bob';
544
+ conn.userModes.registered = true;
545
+ const ctx = makeCtx(conn, services);
546
+
547
+ const out = nickservReducer(conn, privmsg('SET ENFORCE ghost'), ctx);
548
+
549
+ expect(services.getNickEnforce('alice')).toBe('none');
550
+ expect(sentTexts(out.effects)).toContain(
551
+ ':NickServ!NickServ@services NOTICE alice :You must identify before changing enforcement.',
552
+ );
553
+ });
554
+
555
+ it('rejects SET ENFORCE on an unregistered nick', () => {
556
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
557
+ const conn = makeConn();
558
+ conn.account = 'alice';
559
+ conn.userModes.registered = true;
560
+ const ctx = makeCtx(conn, services);
561
+
562
+ const out = nickservReducer(conn, privmsg('SET ENFORCE ghost'), ctx);
563
+
564
+ expect(sentTexts(out.effects)).toContain(
565
+ ':NickServ!NickServ@services NOTICE alice :Nick alice is not registered.',
566
+ );
567
+ });
568
+
569
+ it('rejects SET ENFORCE with a missing policy value', () => {
570
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
571
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
572
+ const conn = makeConn();
573
+ conn.account = 'alice';
574
+ conn.userModes.registered = true;
575
+ const ctx = makeCtx(conn, services);
576
+
577
+ const out = nickservReducer(conn, privmsg('SET ENFORCE'), ctx);
578
+
579
+ expect(services.getNickEnforce('alice')).toBe('none');
580
+ expect(sentTexts(out.effects)).toContain(
581
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET ENFORCE none|ghost|kill',
582
+ );
583
+ });
584
+
585
+ it('rejects SET ENFORCE with an invalid policy value', () => {
586
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
587
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
588
+ const conn = makeConn();
589
+ conn.account = 'alice';
590
+ conn.userModes.registered = true;
591
+ const ctx = makeCtx(conn, services);
592
+
593
+ const out = nickservReducer(conn, privmsg('SET ENFORCE maybe'), ctx);
594
+
595
+ expect(services.getNickEnforce('alice')).toBe('none');
596
+ expect(sentTexts(out.effects)).toContain(
597
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET ENFORCE none|ghost|kill',
598
+ );
599
+ });
600
+
601
+ it('rejects SET ENFORCE with no nick on the connection (defensive)', () => {
602
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
603
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
604
+ const conn = makeConn();
605
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
606
+ delete conn.nick;
607
+ conn.account = 'alice';
608
+ conn.userModes.registered = true;
609
+ const ctx = makeCtx(conn, services);
610
+
611
+ const out = nickservReducer(conn, privmsg('SET ENFORCE ghost'), ctx);
612
+
613
+ expect(services.getNickEnforce('alice')).toBe('none');
614
+ expect(sentTexts(out.effects)).toContain(
615
+ ':NickServ!NickServ@services NOTICE * :You must identify before changing enforcement.',
616
+ );
617
+ });
618
+
619
+ it('is case-insensitive on the policy value', () => {
620
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
621
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
622
+ const conn = makeConn();
623
+ conn.account = 'alice';
624
+ conn.userModes.registered = true;
625
+ const ctx = makeCtx(conn, services);
626
+
627
+ nickservReducer(conn, privmsg('SET ENFORCE GHOST'), ctx);
628
+
629
+ expect(services.getNickEnforce('alice')).toBe('ghost');
630
+ });
631
+
632
+ it('ignores an extra trailing argument to SET ENFORCE', () => {
633
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
634
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
635
+ const conn = makeConn();
636
+ conn.account = 'alice';
637
+ conn.userModes.registered = true;
638
+ const ctx = makeCtx(conn, services);
639
+
640
+ nickservReducer(conn, privmsg('set enforce kill now'), ctx);
641
+
642
+ expect(services.getNickEnforce('alice')).toBe('kill');
643
+ });
644
+
645
+ it('returns the ENFORCE syntax notice for an unknown SET subkey', () => {
646
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
647
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
648
+ const conn = makeConn();
649
+ conn.account = 'alice';
650
+ conn.userModes.registered = true;
651
+ const ctx = makeCtx(conn, services);
652
+
653
+ const out = nickservReducer(conn, privmsg('SET FOO bar'), ctx);
654
+
655
+ expect(services.getNickEnforce('alice')).toBe('none');
656
+ expect(sentTexts(out.effects)).toContain(
657
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET ENFORCE none|ghost|kill',
658
+ );
659
+ });
660
+
661
+ it('returns the ENFORCE syntax notice for SET with no subkey', () => {
662
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
663
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
664
+ const conn = makeConn();
665
+ conn.account = 'alice';
666
+ conn.userModes.registered = true;
667
+ const ctx = makeCtx(conn, services);
668
+
669
+ const out = nickservReducer(conn, privmsg('SET'), ctx);
670
+
671
+ expect(sentTexts(out.effects)).toContain(
672
+ ':NickServ!NickServ@services NOTICE alice :Syntax: SET ENFORCE none|ghost|kill',
673
+ );
674
+ });
675
+ });
676
+
677
+ // ============================================================================
678
+ // enforceRegisteredNick — the enforcement hook fired by the NICK path
679
+ // ============================================================================
680
+
681
+ /** Default grace used by the reducer when ServerConfig omits the override. */
682
+ const DEFAULT_GRACE_MS = 30_000;
683
+
684
+ function makeServicesCtx(
685
+ state: ConnectionState,
686
+ services: ServicesStore,
687
+ clock = new FakeClock(NOW),
688
+ ): Ctx {
689
+ return makeCtx(state, services, clock);
690
+ }
691
+
692
+ describe('enforceRegisteredNick', () => {
693
+ it('returns no effects when no services store is bound', () => {
694
+ const conn = makeConn();
695
+ const ctx = makeCtx(conn);
696
+
697
+ expect(enforceRegisteredNick(conn, ctx)).toEqual<EffectType[]>([]);
698
+ });
699
+
700
+ it('returns no effects when the connection has no nick', () => {
701
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
702
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
703
+ const conn = makeConn();
704
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
705
+ delete conn.nick;
706
+ const ctx = makeServicesCtx(conn, services);
707
+
708
+ expect(enforceRegisteredNick(conn, ctx)).toEqual<EffectType[]>([]);
709
+ });
710
+
711
+ it('returns no effects when the nick is not registered', () => {
712
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
713
+ const conn = makeConn();
714
+ const ctx = makeServicesCtx(conn, services);
715
+
716
+ expect(enforceRegisteredNick(conn, ctx)).toEqual<EffectType[]>([]);
717
+ });
718
+
719
+ it('returns no effects when the connection is identified as the nick owner', () => {
720
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
721
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
722
+ services.setNickEnforce('alice', 'kill');
723
+ const conn = makeConn();
724
+ conn.account = 'alice';
725
+ const ctx = makeServicesCtx(conn, services);
726
+
727
+ expect(enforceRegisteredNick(conn, ctx)).toEqual<EffectType[]>([]);
728
+ });
729
+
730
+ it('policy none emits only a warning NOTICE (no EnforceNick effect)', () => {
731
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
732
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
733
+ services.setNickEnforce('alice', 'none');
734
+ const conn = makeConn();
735
+ const ctx = makeServicesCtx(conn, services);
736
+
737
+ const effects = enforceRegisteredNick(conn, ctx);
738
+
739
+ expect(effects.filter((e) => e.tag === 'EnforceNick')).toHaveLength(0);
740
+ expect(sentTexts(effects)).toContain(
741
+ ':NickServ!NickServ@services NOTICE alice :This nickname is registered and protected. If it is yours, IDENTIFY; otherwise choose another.',
742
+ );
743
+ });
744
+
745
+ it('policy kill emits the warning NOTICE plus EnforceNick(kill, graceMs=0)', () => {
746
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
747
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
748
+ services.setNickEnforce('alice', 'kill');
749
+ const conn = makeConn();
750
+ const ctx = makeServicesCtx(conn, services);
751
+
752
+ const effects = enforceRegisteredNick(conn, ctx);
753
+
754
+ const enforce = effects.filter((e) => e.tag === 'EnforceNick');
755
+ expect(enforce).toEqual<EffectType[]>([Effect.enforceNick('c1', 'alice', 'kill', 0)]);
756
+ });
757
+
758
+ it('policy ghost emits the warning NOTICE plus EnforceNick(ghost, graceMs=default)', () => {
759
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
760
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
761
+ services.setNickEnforce('alice', 'ghost');
762
+ const conn = makeConn();
763
+ const ctx = makeServicesCtx(conn, services);
764
+
765
+ const effects = enforceRegisteredNick(conn, ctx);
766
+
767
+ const enforce = effects.filter((e) => e.tag === 'EnforceNick');
768
+ expect(enforce).toEqual<EffectType[]>([
769
+ Effect.enforceNick('c1', 'alice', 'ghost', DEFAULT_GRACE_MS),
770
+ ]);
771
+ });
772
+
773
+ it('policy ghost honours a ServerConfig nickEnforceGraceMs override', () => {
774
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
775
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
776
+ services.setNickEnforce('alice', 'ghost');
777
+ const conn = makeConn();
778
+ const ctx: Ctx = buildCtx({
779
+ serverConfig: { ...serverConfig, nickEnforceGraceMs: 5_000 },
780
+ clock: new FakeClock(NOW),
781
+ ids: new SequentialIdFactory(),
782
+ motd: EmptyMotdProvider,
783
+ connection: conn,
784
+ services,
785
+ });
786
+
787
+ const effects = enforceRegisteredNick(conn, ctx);
788
+
789
+ const enforce = effects.filter((e) => e.tag === 'EnforceNick');
790
+ expect(enforce).toEqual<EffectType[]>([Effect.enforceNick('c1', 'alice', 'ghost', 5_000)]);
791
+ });
792
+
793
+ it('skips the account owner check when the nick has no account (guest)', () => {
794
+ // Connection using a registered nick they have not identified as. The
795
+ // nick is registered; the connection has no account; enforcement fires.
796
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
797
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
798
+ services.setNickEnforce('alice', 'kill');
799
+ const conn = makeConn();
800
+ expect(conn.account).toBeUndefined();
801
+ const ctx = makeServicesCtx(conn, services);
802
+
803
+ const effects = enforceRegisteredNick(conn, ctx);
804
+
805
+ expect(effects.filter((e) => e.tag === 'EnforceNick')).toHaveLength(1);
806
+ });
807
+ });