serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  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-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. 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 { attemptPassAccountAuth, 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,19 +138,39 @@ 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
  /**
133
161
  * Replaces `state.host` with a deterministic cloak derived from the
134
162
  * deployment's cloaking secret + the current (real) host. No-op when
135
163
  * cloaking is disabled or no host is set yet (e.g. transport didn't supply
136
- * one, or the connection sets it later via CHGHOST).
164
+ * one, or the connection sets it later via CHGHOST). Also a no-op when a
165
+ * HostServ vhost is already active (`vhostActive`) — the vhost was re-applied
166
+ * by {@link applyAccountSuccess} during SASL / PASS-auth and must win over the
167
+ * cloak so it remains visible in the `001` welcome.
137
168
  */
138
169
  function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
139
170
  const cloaking = ctx.serverConfig.cloaking;
140
171
  if (cloaking === undefined || !cloaking.enabled) return;
141
172
  if (state.host === undefined) return;
173
+ if (state.vhostActive === true) return;
142
174
  state.host = cloakHost(state.host, cloaking, ctx.networkName);
143
175
  }
144
176
 
@@ -209,6 +241,16 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
209
241
  return { state, effects };
210
242
  }
211
243
 
244
+ // OperServ JUPE: a jupe'd nick is rejected with the same 432 numeric a
245
+ // grammar-invalid nick would surface, for both pre- and post-registration
246
+ // attempts. Returns [] when no services store is bound or the nick is not
247
+ // juped, so the normal flow is undisturbed.
248
+ const jupe = enforceJupedNick(state, nick, ctx);
249
+ if (jupe.length > 0) {
250
+ effects.push(...jupe);
251
+ return { state, effects };
252
+ }
253
+
212
254
  if (state.registration === 'registered') {
213
255
  const oldNick = state.nick;
214
256
  if (oldNick === undefined) {
@@ -230,7 +272,19 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
230
272
  }
231
273
  state.nick = nick;
232
274
  effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
233
- effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));
275
+ // The `:oldhostmask NICK newnick` line goes to the originator AND every
276
+ // peer sharing a channel with them (RFC 2812 §3.1.2). Each channel
277
+ // broadcast excludes the originator, who already received the echo via
278
+ // the Send above — matching the QUIT/CHGHOST fanout convention.
279
+ const nickLine: RawLine = { text: `:${source} NICK ${nick}` };
280
+ effects.push(Effect.send(ctx.connId, [nickLine]));
281
+ for (const chan of state.joinedChannels) {
282
+ effects.push(Effect.broadcast(chan as ChanName, [nickLine], ctx.connId));
283
+ }
284
+ // Fire NickServ nick-enforcement when a registered client changes onto a
285
+ // registered nick they are not identified as. Returns [] when no
286
+ // services store is bound or the new nick is not registered.
287
+ effects.push(...enforceRegisteredNick(state, ctx));
234
288
  return { state, effects };
235
289
  }
236
290
 
@@ -290,28 +344,40 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
290
344
  /**
291
345
  * Reducer for `PASS <password>`.
292
346
  *
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.
347
+ * Pre-registration: stashes the attempt as `state.passAttempt` for the
348
+ * server-password gate ({@link passwordGateFailure}) and the at-completion
349
+ * PASS-auth ({@link passBasedAccountAuth}). A second `PASS` before
350
+ * registration completes is rejected with `462 ERR_ALREADYREGISTRED`.
351
+ *
352
+ * Post-registration: a bare server password is meaningless, so only the
353
+ * `<nick>:<password>` form is honoured — as a late account login via
354
+ * {@link attemptPassAccountAuth} against the bound `ctx.accounts`. Every
355
+ * other outcome (bare value, no store, already identified, verify failure)
356
+ * is a silent no-op: no `462`, no disconnect. This lets a client that
357
+ * completed `NICK`+`USER` before sending `PASS` identify after the fact.
297
358
  */
298
359
  export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
299
360
  const effects: EffectType[] = [];
300
361
 
301
- if (state.registration === 'registered') {
362
+ const password = msg.params[0];
363
+ if (password === undefined) {
302
364
  effects.push(
303
365
  Effect.send(ctx.connId, [
304
- numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
366
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
305
367
  ]),
306
368
  );
307
369
  return { state, effects };
308
370
  }
309
371
 
310
- const password = msg.params[0];
311
- if (password === undefined) {
372
+ if (state.registration === 'registered') {
373
+ effects.push(...attemptPassAccountAuth(state, ctx, password));
374
+ return { state, effects };
375
+ }
376
+
377
+ if (state.passAttempt !== undefined) {
312
378
  effects.push(
313
379
  Effect.send(ctx.connId, [
314
- numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
380
+ numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
315
381
  ]),
316
382
  );
317
383
  return { state, effects };