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,346 @@
1
+ /**
2
+ * Pure reducer for the OperServ services pseudo-client.
3
+ *
4
+ * OperServ is invoked via `PRIVMSG OperServ :<command>`. The actor routes
5
+ * any `PRIVMSG` whose target matches {@link OPERSERV_NICK}
6
+ * (case-insensitive) to {@link operservReducer} instead of the normal
7
+ * user-target reducer.
8
+ *
9
+ * Every subcommand requires oper privileges (`state.userModes.oper`); a
10
+ * non-oper is rejected with a NOTICE and no state change.
11
+ *
12
+ * Supported subcommands:
13
+ * - `AKILL ADD <mask> <reason…>` — records a network-wide ban against
14
+ * the bound {@link ServicesStore}. The admission path consults
15
+ * {@link checkAkillAdmission} on every new connection (and on every
16
+ * PRIVMSG via the actor layer) and force-disconnects on match.
17
+ * - `AKILL DEL <mask>` — removes the AKILL with the matching mask
18
+ * (case-insensitive).
19
+ * - `AKILL LIST` — enumerates every recorded AKILL.
20
+ * - `JUPE <nick> [reason…]` — blocks a nick / server name locally. The
21
+ * NICK reducer consults {@link enforceJupedNick} on every NICK change
22
+ * and returns `432 ERR_ERRONEUSNICKNAME` for a jupe'd target.
23
+ * - `UNJUPE <nick>` — clears the matching jupe.
24
+ * - `RAW <line>` — oper-only; emits the raw IRC line as a Send effect
25
+ * (interpreted by the actor as a raw injection) and confirms with a
26
+ * NOTICE. Recovery use only.
27
+ *
28
+ * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
29
+ * emitted as {@link Effect}s. AKILL / JUPE persistence lives in the
30
+ * synchronous {@link ServicesStore}.
31
+ */
32
+
33
+ import { Effect } from '../effects.js';
34
+ import type { Effect as EffectType, RawLine } from '../effects.js';
35
+ import { Numerics } from '../protocol/numerics.js';
36
+ import { hostmaskOf } from '../state/connection.js';
37
+ import type { ConnectionState } from '../state/connection.js';
38
+ import type { Ctx, Reducer } from '../types.js';
39
+
40
+ /** Canonical OperServ pseudo-client nick. */
41
+ export const OPERSERV_NICK = 'OperServ';
42
+
43
+ /** Services hostmask fragment used by every services pseudo-client. */
44
+ const SERVICES_HOST = 'services';
45
+
46
+ /** Canonical disconnect reason applied by the AKILL admission hook. */
47
+ export const AKILL_DISCONNECT_REASON = 'AKILL: network-wide ban';
48
+
49
+ /**
50
+ * Reducer for `PRIVMSG OperServ :<subcommand> <args…>`.
51
+ * Authority: the invoking connection's {@link ConnectionState}.
52
+ */
53
+ export const operservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
54
+ state.lastSeen = ctx.clock.now();
55
+
56
+ const effects: EffectType[] = [];
57
+
58
+ if (ctx.services === undefined) {
59
+ effects.push(notice(state, 'Services are not available on this server.'));
60
+ return { state, effects };
61
+ }
62
+
63
+ const body = msg.params[1] ?? '';
64
+ const tokens = body.split(/\s+/u).filter((t) => t.length > 0);
65
+ if (tokens.length === 0) {
66
+ effects.push(helpNotice(state));
67
+ return { state, effects };
68
+ }
69
+
70
+ // Oper gate: every subcommand requires oper privileges. Reject without
71
+ // mutating services state so a non-oper cannot add AKILLs / JUPEs.
72
+ if (!state.userModes.oper) {
73
+ effects.push(notice(state, 'Permission denied — oper privileges required.'));
74
+ return { state, effects };
75
+ }
76
+
77
+ const sub = tokens[0]?.toUpperCase();
78
+ const args = tokens.slice(1);
79
+
80
+ switch (sub) {
81
+ case 'AKILL':
82
+ return handleAkill(state, args, ctx, effects);
83
+ case 'JUPE':
84
+ return handleJupe(state, args, ctx, effects);
85
+ case 'UNJUPE':
86
+ return handleUnjupe(state, args, ctx, effects);
87
+ case 'RAW':
88
+ return handleRaw(state, args, effects);
89
+ default:
90
+ effects.push(unknownNotice(state));
91
+ return { state, effects };
92
+ }
93
+ };
94
+
95
+ // ============================================================================
96
+ // AKILL ADD / DEL / LIST
97
+ // ============================================================================
98
+
99
+ function handleAkill(
100
+ state: ConnectionState,
101
+ args: string[],
102
+ ctx: Ctx,
103
+ effects: EffectType[],
104
+ ): { state: ConnectionState; effects: EffectType[] } {
105
+ const services = ctx.services as NonNullable<Ctx['services']>;
106
+ const action = args[0]?.toUpperCase();
107
+ const rest = args.slice(1);
108
+
109
+ switch (action) {
110
+ case 'ADD':
111
+ return handleAkillAdd(state, rest, services, effects);
112
+ case 'DEL':
113
+ return handleAkillDel(state, rest, services, effects);
114
+ case 'LIST':
115
+ return handleAkillList(state, services, effects);
116
+ default:
117
+ effects.push(notice(state, 'Syntax: AKILL ADD|DEL|LIST [args]'));
118
+ return { state, effects };
119
+ }
120
+ }
121
+
122
+ function handleAkillAdd(
123
+ state: ConnectionState,
124
+ args: string[],
125
+ services: NonNullable<Ctx['services']>,
126
+ effects: EffectType[],
127
+ ): { state: ConnectionState; effects: EffectType[] } {
128
+ const mask = args[0];
129
+ const reasonTokens = args.slice(1);
130
+ if (mask === undefined || reasonTokens.length === 0) {
131
+ effects.push(notice(state, 'Syntax: AKILL ADD <mask> <reason>'));
132
+ return { state, effects };
133
+ }
134
+ const reason = reasonTokens.join(' ');
135
+ services.addAkill(mask, reason);
136
+ effects.push(notice(state, `AKILL ${mask} added (reason: ${reason}).`));
137
+ return { state, effects };
138
+ }
139
+
140
+ function handleAkillDel(
141
+ state: ConnectionState,
142
+ args: string[],
143
+ services: NonNullable<Ctx['services']>,
144
+ effects: EffectType[],
145
+ ): { state: ConnectionState; effects: EffectType[] } {
146
+ const mask = args[0];
147
+ if (mask === undefined) {
148
+ effects.push(notice(state, 'Syntax: AKILL DEL <mask>'));
149
+ return { state, effects };
150
+ }
151
+ if (services.removeAkill(mask)) {
152
+ effects.push(notice(state, `AKILL ${mask} removed.`));
153
+ return { state, effects };
154
+ }
155
+ effects.push(notice(state, `No AKILL matching ${mask}.`));
156
+ return { state, effects };
157
+ }
158
+
159
+ function handleAkillList(
160
+ state: ConnectionState,
161
+ services: NonNullable<Ctx['services']>,
162
+ effects: EffectType[],
163
+ ): { state: ConnectionState; effects: EffectType[] } {
164
+ const entries = services.listAkills();
165
+ if (entries.length === 0) {
166
+ effects.push(notice(state, 'No AKILLs are currently set.'));
167
+ return { state, effects };
168
+ }
169
+ for (const entry of entries) {
170
+ effects.push(notice(state, `${entry.mask} — ${entry.reason}`));
171
+ }
172
+ effects.push(notice(state, 'End of AKILL list.'));
173
+ return { state, effects };
174
+ }
175
+
176
+ // ============================================================================
177
+ // JUPE / UNJUPE
178
+ // ============================================================================
179
+
180
+ function handleJupe(
181
+ state: ConnectionState,
182
+ args: string[],
183
+ ctx: Ctx,
184
+ effects: EffectType[],
185
+ ): { state: ConnectionState; effects: EffectType[] } {
186
+ const services = ctx.services as NonNullable<Ctx['services']>;
187
+ const name = args[0];
188
+ if (name === undefined) {
189
+ effects.push(notice(state, 'Syntax: JUPE <nick> [reason]'));
190
+ return { state, effects };
191
+ }
192
+ const reasonTokens = args.slice(1);
193
+ if (reasonTokens.length === 0) {
194
+ services.addJupe(name);
195
+ effects.push(notice(state, `JUPE ${name} added.`));
196
+ return { state, effects };
197
+ }
198
+ const reason = reasonTokens.join(' ');
199
+ services.addJupe(name, reason);
200
+ effects.push(notice(state, `JUPE ${name} added (reason: ${reason}).`));
201
+ return { state, effects };
202
+ }
203
+
204
+ function handleUnjupe(
205
+ state: ConnectionState,
206
+ args: string[],
207
+ ctx: Ctx,
208
+ effects: EffectType[],
209
+ ): { state: ConnectionState; effects: EffectType[] } {
210
+ const services = ctx.services as NonNullable<Ctx['services']>;
211
+ const name = args[0];
212
+ if (name === undefined) {
213
+ effects.push(notice(state, 'Syntax: UNJUPE <nick>'));
214
+ return { state, effects };
215
+ }
216
+ if (services.removeJupe(name)) {
217
+ effects.push(notice(state, `JUPE ${name} removed.`));
218
+ return { state, effects };
219
+ }
220
+ effects.push(notice(state, `No JUPE matching ${name}.`));
221
+ return { state, effects };
222
+ }
223
+
224
+ // ============================================================================
225
+ // RAW
226
+ // ============================================================================
227
+
228
+ /**
229
+ * Handles `RAW <line>`. The line is emitted back to the oper via a Send
230
+ * effect (the actor layer interprets this as a raw injection; for the
231
+ * in-memory runtime it is observable verbatim), and a confirmation
232
+ * NOTICE follows. Logged at the dispatch layer via the standard
233
+ * observability surface.
234
+ */
235
+ function handleRaw(
236
+ state: ConnectionState,
237
+ args: string[],
238
+ effects: EffectType[],
239
+ ): { state: ConnectionState; effects: EffectType[] } {
240
+ if (args.length === 0) {
241
+ effects.push(notice(state, 'Syntax: RAW <line>'));
242
+ return { state, effects };
243
+ }
244
+ const line = args.join(' ');
245
+ const raw: RawLine = { text: line };
246
+ effects.push(Effect.send(state.id, [raw]));
247
+ effects.push(notice(state, 'RAW line accepted and logged.'));
248
+ return { state, effects };
249
+ }
250
+
251
+ // ============================================================================
252
+ // Admission hook — AKILL
253
+ // ============================================================================
254
+
255
+ /**
256
+ * Returns the effects to emit when a connection's hostmask is first
257
+ * computable (at the `$connect` / admission boundary). If the hostmask
258
+ * matches a recorded AKILL, the connection is force-disconnected via a
259
+ * {@link Effect.disconnect} (with a NOTICE explaining the ban). Returns
260
+ * `[]` when no services store is bound, the connection has no hostmask
261
+ * yet, or no AKILL matches.
262
+ *
263
+ * Called by the actor layer once the connection has a nick!user@host to
264
+ * check (post-registration / pre-welcome). Pure: the only input is the
265
+ * connection state + ctx; the only output is the effect list.
266
+ */
267
+ export function checkAkillAdmission(state: ConnectionState, ctx: Ctx): EffectType[] {
268
+ const services = ctx.services;
269
+ if (services === undefined) return [];
270
+ const hostmask = hostmaskOf(state);
271
+ if (hostmask === undefined) return [];
272
+ const hit = services.matchAkill(hostmask);
273
+ if (hit === undefined) return [];
274
+ // `hostmaskOf` returns undefined when `nick` is undefined; we returned
275
+ // early above on the undefined-hostmask path, so `state.nick` is
276
+ // guaranteed defined here.
277
+ // biome-ignore lint/style/noNonNullAssertion: invariant — see above.
278
+ const nick = state.nick!;
279
+ const noticeLine: RawLine = {
280
+ text: `:${OPERSERV_NICK}!${OPERSERV_NICK}@${SERVICES_HOST} NOTICE ${nick} :You are banned from this network (AKILL: ${hit.reason}).`,
281
+ };
282
+ return [
283
+ Effect.send(state.id, [noticeLine]),
284
+ Effect.disconnect(state.id, AKILL_DISCONNECT_REASON),
285
+ ];
286
+ }
287
+
288
+ // ============================================================================
289
+ // JUPE enforcement hook — NICK reducer
290
+ // ============================================================================
291
+
292
+ /**
293
+ * Returns the effects to emit when a client has just taken (or attempted
294
+ * to take) `nick`: if the nick is juped, returns a single
295
+ * `432 ERR_ERRONEUSNICKNAME` Send effect addressed to the connection.
296
+ * Returns `[]` when no services store is bound, the nick is undefined,
297
+ * or the nick is not juped.
298
+ *
299
+ * Called by the NICK reducer on every nick-change attempt so a jupe'd
300
+ * target surfaces the same numeric an invalid nick would.
301
+ */
302
+ export function enforceJupedNick(
303
+ state: ConnectionState,
304
+ nick: string | undefined,
305
+ ctx: Ctx,
306
+ ): EffectType[] {
307
+ const services = ctx.services;
308
+ if (services === undefined) return [];
309
+ if (nick === undefined) return [];
310
+ if (!services.isJuped(nick)) return [];
311
+ const sender = state.nick ?? '*';
312
+ const code = Numerics.ERR_ERRONEUSNICKNAME.toString().padStart(3, '0');
313
+ const line: RawLine = {
314
+ text: `:${ctx.serverName} ${code} ${sender} ${nick} :Erroneous nickname`,
315
+ };
316
+ return [Effect.send(state.id, [line])];
317
+ }
318
+
319
+ // ============================================================================
320
+ // Notice helpers
321
+ // ============================================================================
322
+
323
+ /** Builds a `:OperServ!OperServ@services NOTICE <nick> :<text>` line. */
324
+ function notice(state: ConnectionState, text: string): EffectType {
325
+ return Effect.send(state.id, [servicesNotice(OPERSERV_NICK, state, text)]);
326
+ }
327
+
328
+ /** Builds a help `:OperServ!OperServ@services NOTICE <nick> :…` line. */
329
+ function helpNotice(state: ConnectionState): EffectType {
330
+ return notice(state, 'Available commands: AKILL, JUPE, RAW');
331
+ }
332
+
333
+ function unknownNotice(state: ConnectionState): EffectType {
334
+ return notice(state, 'Unknown command. Available: AKILL, JUPE, RAW');
335
+ }
336
+
337
+ /**
338
+ * Formats a services NOTICE line. The source hostmask is
339
+ * `<svc>!<svc>@services` regardless of the deployment's serverName so the
340
+ * pseudo-clients are stable across deployments and easy to identify in
341
+ * client UIs.
342
+ */
343
+ function servicesNotice(svc: string, state: ConnectionState, text: string): RawLine {
344
+ const nick = state.nick ?? '*';
345
+ return { text: `:${svc}!${svc}@${SERVICES_HOST} NOTICE ${nick} :${text}` };
346
+ }
@@ -14,7 +14,9 @@
14
14
  * `306 RPL_NOWAWAY` is emitted as if the client had sent
15
15
  * `AWAY :reason` themselves.
16
16
  *
17
- * Mirrors the {@link ReadMarkerStore} plumbing from the read-marker cap.
17
+ * Mirrors the read-marker plumbing on the services store: the away
18
+ * persistence is gated on a store being bound AND the connection being
19
+ * identified, the same shape `persistReadMarker` uses.
18
20
  */
19
21
 
20
22
  import { PRE_AWAY_CAP_NAME } from '../caps/capabilities.js';
@@ -88,6 +88,20 @@ function maySpeakOnModerated(entry: { op: boolean; voice: boolean } | undefined)
88
88
  return entry.op || entry.voice;
89
89
  }
90
90
 
91
+ /**
92
+ * Returns true iff the sender may speak under the ChanServ `+M`
93
+ * (moderated-ident) mode: ops and voiced members always pass, and any
94
+ * identified connection (account set) passes; unidentified non-op/non-voice
95
+ * members are blocked.
96
+ */
97
+ function maySpeakOnModeratedIdent(
98
+ entry: { op: boolean; voice: boolean } | undefined,
99
+ account: string | undefined,
100
+ ): boolean {
101
+ if (entry !== undefined && (entry.op || entry.voice)) return true;
102
+ return account !== undefined;
103
+ }
104
+
91
105
  /** Command flavor: PRIVMSG surfaces errors; NOTICE is silent. */
92
106
  type MessageCommand = 'PRIVMSG' | 'NOTICE';
93
107
 
@@ -152,6 +166,34 @@ function buildChannelReducer(command: MessageCommand): Reducer<ChannelState> {
152
166
  return { state, effects };
153
167
  }
154
168
 
169
+ // ChanServ +M (moderated-ident): ops/voiced/identified pass; everyone
170
+ // else is blocked with 404.
171
+ if (
172
+ state.modes.moderatedIdent &&
173
+ !maySpeakOnModeratedIdent(senderEntry, ctx.connection.account)
174
+ ) {
175
+ if (!silent) {
176
+ effects.push(
177
+ Effect.send(ctx.connId, [
178
+ numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
179
+ ]),
180
+ );
181
+ }
182
+ return { state, effects };
183
+ }
184
+
185
+ // ChanServ +R (block unidentified): sender must be identified.
186
+ if (state.modes.blockUnidentified && ctx.connection.account === undefined) {
187
+ if (!silent) {
188
+ effects.push(
189
+ Effect.send(ctx.connId, [
190
+ numericErr(ctx, Numerics.ERR_CANNOTSENDTOCHAN, 'Cannot send to channel', state.name),
191
+ ]),
192
+ );
193
+ }
194
+ return { state, effects };
195
+ }
196
+
155
197
  // +b (ban): sender's hostmask must not match any ban mask.
156
198
  if (state.banMasks.size > 0) {
157
199
  for (const mask of state.banMasks) {
@@ -14,7 +14,7 @@
14
14
  * `TAGMSG <target>` carrying `+draft/read-marker=<msgid>`. The
15
15
  * {@link tagmsgChannelReducer} detects the tag, calls
16
16
  * {@link persistReadMarker} to advance the per-`(account, channel)`
17
- * record in the bound {@link ReadMarkerStore}, and fans the TAGMSG out
17
+ * record in the bound {@link ServicesStore}, and fans the TAGMSG out
18
18
  * (carrying the tag) so the user's other connections learn the new
19
19
  * read position.
20
20
  * - On SASL identify, the actor calls {@link seedReadMarkers} so a fresh
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { READ_MARKER_CAP_NAME } from '../caps/capabilities.js';
26
- import type { ReadMarkerStore } from '../ports.js';
26
+ import type { ServicesStore } from '../ports.js';
27
27
  import type { ConnectionState } from '../state/connection.js';
28
28
  import type { Ctx } from '../types.js';
29
29
 
@@ -61,7 +61,7 @@ export function readMarkerMsgid(tags: Readonly<Record<string, string>>): string
61
61
  * Advances the per-`(account, channel)` last-read marker.
62
62
  *
63
63
  * Always advances the connection's in-memory {@link ConnectionState.lastReadMarkers}
64
- * so the current session tracks the read position. When a {@link ReadMarkerStore}
64
+ * so the current session tracks the read position. When a {@link ServicesStore}
65
65
  * is bound AND the connection is identified (has an `account`), the marker is
66
66
  * also persisted so it survives reconnects and is shared across the account's
67
67
  * connections.
@@ -75,10 +75,10 @@ export function persistReadMarker(ctx: Ctx, chanLower: string, msgid: string): v
75
75
  }
76
76
  ctx.connection.lastReadMarkers.set(chanLower, msgid);
77
77
 
78
- const store = ctx.readMarkers;
78
+ const services = ctx.services;
79
79
  const account = ctx.connection.account;
80
- if (store !== undefined && account !== undefined) {
81
- store.set(account, chanLower, msgid);
80
+ if (services !== undefined && account !== undefined) {
81
+ services.setLastReadMarker(account, chanLower, msgid);
82
82
  }
83
83
  }
84
84
 
@@ -92,10 +92,10 @@ export function persistReadMarker(ctx: Ctx, chanLower: string, msgid: string): v
92
92
  */
93
93
  export function seedReadMarkers(
94
94
  conn: ConnectionState,
95
- store: ReadMarkerStore,
95
+ store: ServicesStore,
96
96
  account: string,
97
97
  ): void {
98
- const entries = store.forAccount(account);
98
+ const entries = store.listReadMarkers(account);
99
99
  if (entries.length === 0) return;
100
100
  if (conn.lastReadMarkers === undefined) {
101
101
  conn.lastReadMarkers = new Map<string, string>();
@@ -18,11 +18,15 @@ import { formatCreatedAt, resolveServerVersion } from '../config.js';
18
18
  import { Effect } from '../effects.js';
19
19
  import type { Effect as EffectType, RawLine } from '../effects.js';
20
20
  import { Numerics } from '../protocol/numerics.js';
21
+ import type { ChanName } from '../state/channel.js';
21
22
  import { hostmaskOf } from '../state/connection.js';
22
23
  import type { ConnectionState } from '../state/connection.js';
23
24
  import type { Ctx, Reducer } from '../types.js';
25
+ import { passBasedAccountAuth } from './account-auth.js';
24
26
  import { generateIsupport } from './isupport.js';
25
27
  import { buildMotdNumerics } from './motd-lines.js';
28
+ import { enforceRegisteredNick } from './nickserv.js';
29
+ import { checkAkillAdmission, enforceJupedNick } from './operserv.js';
26
30
 
27
31
  /** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
28
32
  const USER_MODES_004 = 'iosw';
@@ -110,6 +114,14 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
110
114
  if (state.nick === undefined || state.user === undefined) return [];
111
115
  if (state.capNegotiating) return [];
112
116
 
117
+ // PASS-based account auth runs before the bare server-password gate. On
118
+ // success it identifies the connection (`state.account` set) and emits
119
+ // the account-login numerics (900/903 + downstream effects), satisfying
120
+ // the gate as a side effect. On every other outcome it returns `[]` and
121
+ // the gate below decides. No-op when already identified (SASL), when no
122
+ // `<nick>:<password>` form is present, or when no AccountStore is bound.
123
+ const authEffects = passBasedAccountAuth(state, ctx);
124
+
113
125
  const gateFailure = passwordGateFailure(state, ctx);
114
126
  if (gateFailure !== null) return gateFailure;
115
127
 
@@ -126,7 +138,23 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
126
138
  }
127
139
 
128
140
  state.registration = 'registered';
129
- return [Effect.send(ctx.connId, buildWelcomeLines(state, ctx))];
141
+ // Fire NickServ nick-enforcement when an unidentified client just took a
142
+ // registered nick. Returns [] when no services store is bound or the nick
143
+ // is not registered / the client is identified as the owner. Appended
144
+ // after the welcome Send so the 001 block lands before the warning NOTICE.
145
+ const enforcement = enforceRegisteredNick(state, ctx);
146
+ // OperServ AKILL admission: once the hostmask is computable (post-cloak)
147
+ // check the bound services store for a network-wide ban. Returns [] when
148
+ // no services store is bound, no hostmask is available, or no mask
149
+ // matches. On match the hostmask IS rejected (Disconnect), matching the
150
+ // `$connect` admission contract.
151
+ const akill = checkAkillAdmission(state, ctx);
152
+ return [
153
+ ...authEffects,
154
+ Effect.send(ctx.connId, buildWelcomeLines(state, ctx)),
155
+ ...enforcement,
156
+ ...akill,
157
+ ];
130
158
  }
131
159
 
132
160
  /**
@@ -209,6 +237,16 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
209
237
  return { state, effects };
210
238
  }
211
239
 
240
+ // OperServ JUPE: a jupe'd nick is rejected with the same 432 numeric a
241
+ // grammar-invalid nick would surface, for both pre- and post-registration
242
+ // attempts. Returns [] when no services store is bound or the nick is not
243
+ // juped, so the normal flow is undisturbed.
244
+ const jupe = enforceJupedNick(state, nick, ctx);
245
+ if (jupe.length > 0) {
246
+ effects.push(...jupe);
247
+ return { state, effects };
248
+ }
249
+
212
250
  if (state.registration === 'registered') {
213
251
  const oldNick = state.nick;
214
252
  if (oldNick === undefined) {
@@ -230,7 +268,19 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
230
268
  }
231
269
  state.nick = nick;
232
270
  effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
233
- effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));
271
+ // The `:oldhostmask NICK newnick` line goes to the originator AND every
272
+ // peer sharing a channel with them (RFC 2812 §3.1.2). Each channel
273
+ // broadcast excludes the originator, who already received the echo via
274
+ // the Send above — matching the QUIT/CHGHOST fanout convention.
275
+ const nickLine: RawLine = { text: `:${source} NICK ${nick}` };
276
+ effects.push(Effect.send(ctx.connId, [nickLine]));
277
+ for (const chan of state.joinedChannels) {
278
+ effects.push(Effect.broadcast(chan as ChanName, [nickLine], ctx.connId));
279
+ }
280
+ // Fire NickServ nick-enforcement when a registered client changes onto a
281
+ // registered nick they are not identified as. Returns [] when no
282
+ // services store is bound or the new nick is not registered.
283
+ effects.push(...enforceRegisteredNick(state, ctx));
234
284
  return { state, effects };
235
285
  }
236
286
 
@@ -290,10 +340,15 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
290
340
  /**
291
341
  * Reducer for `PASS <password>`.
292
342
  *
293
- * Only stashes the attempt as `state.passAttempt`. Actual server-password
294
- * enforcement lives in the actor layer / security hardening
295
- * since it requires comparing against the deployment's configured
296
- * password, which the pure reducer does not see.
343
+ * Stashes the attempt as `state.passAttempt`. The pure-reducer gate
344
+ * itself lives in {@link passwordGateFailure} (same file), which
345
+ * {@link emitWelcomeIfReady} consults at registration completion:
346
+ * it compares `state.passAttempt` against
347
+ * `ctx.serverConfig.serverPassword` and, on mismatch, emits
348
+ * `464 ERR_PASSWDMISMATCH` plus a `Disconnect("Bad Password")`. A
349
+ * SASL-identified connection (`state.account !== undefined`) short-
350
+ * circuits the gate, so PASS is unnecessary when SASL has already
351
+ * authenticated an account.
297
352
  */
298
353
  export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
299
354
  const effects: EffectType[] = [];
@@ -32,12 +32,9 @@ import type { Effect as EffectType, RawLine } from '../effects.js';
32
32
  import type { SaslResult } from '../ports.js';
33
33
  import { decodeBase64 } from '../protocol/base64.js';
34
34
  import { Numerics } from '../protocol/numerics.js';
35
- import { hostmaskOf } from '../state/connection.js';
36
35
  import type { ConnectionState } from '../state/connection.js';
37
36
  import type { Ctx, Reducer } from '../types.js';
38
- import { emitAccountNotify } from './account-notify.js';
39
- import { nowAwayLine, replayPersistedAway } from './pre-away.js';
40
- import { seedReadMarkers } from './read-marker.js';
37
+ import { applyAccountSuccess } from './account-auth.js';
41
38
 
42
39
  /** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
43
40
  const SASL_CHUNK_BYTES = 400;
@@ -172,18 +169,28 @@ function finalizePlain(
172
169
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
173
170
  }
174
171
 
172
+ // Prefer the static AccountStore; fall back to the ServicesStore so a
173
+ // nick registered via NickServ can authenticate via SASL PLAIN without a
174
+ // separate static credential table. When neither verifies, fail.
175
175
  const store = ctx.accounts;
176
- if (store === undefined) {
177
- clearSasl(state);
178
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
179
- }
180
-
181
- const result = store.verify('PLAIN', {
176
+ let result = store?.verify('PLAIN', {
182
177
  kind: 'PLAIN',
183
178
  username: parsed.username,
184
179
  password: parsed.password,
185
180
  });
186
181
 
182
+ if (result === undefined || !result.ok) {
183
+ const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
184
+ if (servicesResult?.ok) {
185
+ result = servicesResult;
186
+ }
187
+ }
188
+
189
+ if (result === undefined) {
190
+ clearSasl(state);
191
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
192
+ }
193
+
187
194
  return applyResult(state, result, ctx);
188
195
  }
189
196
 
@@ -220,31 +227,7 @@ function applyResult(
220
227
  clearSasl(state);
221
228
 
222
229
  if (result.ok) {
223
- state.account = result.account;
224
- // IRCv3 draft/read-marker: restore the user's per-channel last-read
225
- // markers from the persisted store so a reconnect resumes at their
226
- // last read position. No-op when no ReadMarkerStore is bound.
227
- if (ctx.readMarkers !== undefined) {
228
- seedReadMarkers(state, ctx.readMarkers, result.account);
229
- }
230
- // IRCv3 draft/pre-away: replay the user's persisted away reason onto
231
- // the fresh connection so a reconnect restores the away state, and
232
- // emit the 306 numeric so the user sees they are still away. No-op
233
- // when no AwayStore is bound or no reason is stored.
234
- const replayedAway =
235
- ctx.away !== undefined ? replayPersistedAway(state, ctx.away, result.account) : undefined;
236
- const awayEffects: EffectType[] =
237
- replayedAway !== undefined
238
- ? [Effect.send(ctx.connId, [nowAwayLine(state, ctx.serverName)])]
239
- : [];
240
- return {
241
- state,
242
- effects: [
243
- Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)]),
244
- ...awayEffects,
245
- ...emitAccountNotify({ conn: state, account: result.account }),
246
- ],
247
- };
230
+ return { state, effects: applyAccountSuccess(state, ctx, result.account) };
248
231
  }
249
232
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
250
233
  }
@@ -274,20 +257,6 @@ function clearSasl(state: ConnectionState): void {
274
257
  delete state.saslBuffer;
275
258
  }
276
259
 
277
- function loggedIn(ctx: Ctx, account: string): RawLine {
278
- const nick = nickOf(ctx);
279
- const hostmask = hostmaskOf(ctx.connection) ?? nick;
280
- return L(
281
- `:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
282
- );
283
- }
284
-
285
- function saslSuccess(ctx: Ctx): RawLine {
286
- return L(
287
- `:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
288
- );
289
- }
290
-
291
260
  function saslFail(ctx: Ctx): RawLine {
292
261
  return L(
293
262
  `:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,