serverless-ircd 0.8.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -413,6 +413,31 @@ function handleList(
413
413
  // vhost application helpers
414
414
  // ============================================================================
415
415
 
416
+ /**
417
+ * Consults the bound {@link ServicesStore} for an assigned vhost for `account`
418
+ * and, when one is present, applies it to {@link ConnectionState} via
419
+ * {@link applyVhost}. This is the shared login-time hook invoked by every
420
+ * account-success path (SASL PLAIN, PASS-auth via {@link applyAccountSuccess},
421
+ * and NickServ `IDENTIFY` via {@link applyIdentify}) so a reconnecting user
422
+ * with an assigned vhost does not have to re-run `HostServ ON` every session.
423
+ *
424
+ * No-op (no effects emitted, no state change) when no vhost is recorded. The
425
+ * CHGHOST fanout appended to `effects` is empty at SASL / PASS-auth time
426
+ * (registration has not completed and/or no channels are joined yet) but fires
427
+ * for a post-registration NickServ `IDENTIFY` when the user is already in
428
+ * channels — reusing {@link applyVhost} so the wire shape matches `HostServ ON`.
429
+ */
430
+ export function applyAssignedVhost(
431
+ state: ConnectionState,
432
+ services: ServicesStore,
433
+ account: string,
434
+ effects: EffectType[],
435
+ ): void {
436
+ const vhost = services.getVhost(account);
437
+ if (vhost === undefined) return;
438
+ applyVhost(state, vhost, effects);
439
+ }
440
+
416
441
  /**
417
442
  * Applies `vhost` to {@link ConnectionState.host}, records the prior host
418
443
  * on {@link ConnectionState.preVhostHost}, flips
@@ -421,7 +446,7 @@ function handleList(
421
446
  * {@link emitChghost} helper so the wire shape matches every other
422
447
  * user/host mutation.
423
448
  */
424
- function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
449
+ export function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
425
450
  const oldUser = state.user ?? '';
426
451
  const oldHost = state.host ?? '';
427
452
  state.preVhostHost = oldHost;
@@ -457,13 +482,23 @@ function notice(state: ConnectionState, text: string): EffectType {
457
482
  return Effect.send(state.id, [servicesNotice(HOSTSERV_NICK, state, text)]);
458
483
  }
459
484
 
485
+ /** User-visible HostServ subcommands (shown to every caller). */
486
+ const USER_COMMANDS = 'ON, OFF, REQUEST';
487
+
488
+ /** Oper-only HostServ subcommands, appended to {@link USER_COMMANDS} for opers. */
489
+ const OPER_COMMANDS = 'SET, APPROVE, REJECT, LIST';
490
+
460
491
  /** Builds a help `:HostServ!HostServ@services NOTICE <nick> :…` line. */
461
492
  function helpNotice(state: ConnectionState): EffectType {
462
- return notice(state, 'Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST');
493
+ const commands =
494
+ state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
495
+ return notice(state, `Available commands: ${commands}`);
463
496
  }
464
497
 
465
498
  function unknownNotice(state: ConnectionState): EffectType {
466
- return notice(state, 'Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST');
499
+ const commands =
500
+ state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
501
+ return notice(state, `Unknown command. Available: ${commands}`);
467
502
  }
468
503
 
469
504
  /**
@@ -110,3 +110,4 @@ export {
110
110
  } from './chanserv.js';
111
111
  export { HOSTSERV_NICK, hostservReducer } from './hostserv.js';
112
112
  export { OPERSERV_NICK, operservReducer } from './operserv.js';
113
+ export { SERVICE_NICKS, SHORTCUT_TO_SERVICE } from './service-aliases.js';
@@ -27,6 +27,7 @@ import type { ConnectionState } from '../state/connection.js';
27
27
  import type { Ctx, Reducer } from '../types.js';
28
28
  import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
29
29
  import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
30
+ import { topicNumerics } from './topic.js';
30
31
 
31
32
  /**
32
33
  * RFC 1459/2812 channel-name grammar. The first character must be a channel
@@ -265,16 +266,27 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
265
266
  }
266
267
  }
267
268
 
268
- // All checks passed: add to roster. First joiner gets op per RFC 2812.
269
+ // All checks passed: add to roster. First joiner gets op per RFC 2812,
270
+ // unless the channel is ChanServ-registered — in that case the first
271
+ // joiner is opped only when they are the founder or meet the AUTOOP
272
+ // level (computeChanservAutoPrefix), so a random user reconnecting
273
+ // first after a roster-wiping deploy cannot seize op in a channel they
274
+ // do not own. The real founder / access-list users who join later are
275
+ // opped by the same access check on the non-empty path.
269
276
  const wasEmpty = state.members.size === 0;
270
- // ChanServ ACCESS auto-prefix: when services are bound, the channel is
271
- // registered, and this is not the RFC 2812 first-joiner case, look up
272
- // the joiner's access level and apply the matching channel prefix.
273
- // `chanservPrefix` is non-null only when ChanServ (not the RFC 2812
274
- // rule) granted the prefix, so the JOIN hook knows to emit a ChanServ
275
- // MODE broadcast alongside the roster mutation.
276
- const autoPrefix = wasEmpty ? null : computeChanservAutoPrefix(ctx, state.nameLower);
277
- const grantOp = wasEmpty || (autoPrefix?.op ?? false);
277
+ // ChanServ ACCESS auto-prefix: always look up the joiner's access level
278
+ // so the empty-channel gating above and the non-empty case share one
279
+ // code path. `autoPrefix` is non-null only when ChanServ could resolve
280
+ // the joiner's access (services bound, channel registered, joiner
281
+ // identified); it carries {op:false,voice:false} when the joiner is on
282
+ // no list, and null when ChanServ is not authoritative for this join
283
+ // (services unbound / channel unregistered / joiner unidentified).
284
+ const autoPrefix = computeChanservAutoPrefix(ctx, state.nameLower);
285
+ const channelRegistered = ctx.services?.getChannel(state.nameLower) !== undefined;
286
+ // RFC 2812 first-joiner op applies only when the channel is NOT
287
+ // registered. A registered channel's first joiner gets op solely via
288
+ // the access check (founder / AUTOOP), never by the empty-channel rule.
289
+ const grantOp = (wasEmpty && !channelRegistered) || (autoPrefix?.op ?? false);
278
290
  const grantVoice = autoPrefix?.voice ?? false;
279
291
  const joinerNick = ctx.connection.nick ?? '?';
280
292
  state.members.set(ctx.connId, {
@@ -288,13 +300,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
288
300
  // before this join and services have a persisted topic snapshot for a
289
301
  // keep-topic-enabled registration, restore the topic onto the
290
302
  // authoritative ChannelState. The 332/333 numerics are emitted to the
291
- // joiner below alongside the NAMES list.
292
- let restoredTopic: ChannelTopic | undefined;
303
+ // joiner below alongside the NAMES list, keyed off `state.topic`.
293
304
  if (wasEmpty) {
294
- restoredTopic = restoreKeepTopic(ctx, state.nameLower);
295
- if (restoredTopic !== undefined) {
296
- state.topic = restoredTopic;
297
- effects.push(Effect.applyChannelDelta(state.name, { topic: restoredTopic }));
305
+ const restored = restoreKeepTopic(ctx, state.nameLower);
306
+ if (restored !== undefined) {
307
+ state.topic = restored;
308
+ effects.push(Effect.applyChannelDelta(state.name, { topic: restored }));
298
309
  }
299
310
  }
300
311
 
@@ -323,13 +334,15 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
323
334
  }),
324
335
  );
325
336
 
326
- // ChanServ auto-prefix broadcast: when ChanServ granted op or voice
327
- // (i.e. NOT the RFC 2812 first-joiner case), emit a
328
- // `:ChanServ!ChanServ@services MODE #chan +o nick` broadcast so every
329
- // client in the channel updates its prefix display. Supersedes the
330
- // implicit voice when op is granted (the channel-prefix grammar
331
- // already renders `@` for ops).
332
- if (autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
337
+ // ChanServ auto-prefix broadcast: when ChanServ granted op or voice on
338
+ // a non-empty channel, emit a `:ChanServ!ChanServ@services MODE #chan
339
+ // +o nick` broadcast so existing clients update their prefix display.
340
+ // Suppressed on the empty-channel first-join (wasEmpty) because the
341
+ // joiner is the channel's only client and already sees their prefix in
342
+ // the NAMES list the op grant is silent. Supersedes the implicit
343
+ // voice when op is granted (the channel-prefix grammar already renders
344
+ // `@` for ops).
345
+ if (!wasEmpty && autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
333
346
  effects.push(buildChanservPrefixBroadcast(state.name, joinerNick, autoPrefix));
334
347
  }
335
348
 
@@ -379,19 +392,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
379
392
 
380
393
  effects.push(Effect.send(ctx.connId, lines));
381
394
 
382
- // When ChanServ KEEPTOPIC restored a topic this JOIN, emit the 332/333
383
- // topic numerics to the joiner so their client renders the restored
384
- // topic (mirroring the TOPIC read-form reply shape).
385
- if (restoredTopic !== undefined) {
386
- effects.push(
387
- Effect.send(ctx.connId, [
388
- { text: `:${ctx.serverName} 332 ${nick} ${chanName} :${restoredTopic.text}` },
389
- {
390
- text: `:${ctx.serverName} 333 ${nick} ${chanName} ${restoredTopic.setter} ${restoredTopic.setAt}`,
391
- },
392
- ]),
393
- );
394
- }
395
+ // Emit 332/333 topic numerics to the joiner whenever a topic is set on
396
+ // the channel, regardless of whether it came from a ChanServ KEEPTOPIC
397
+ // restore this join, a deploy snapshot, or an earlier joiner. Mirrors
398
+ // the TOPIC read-form reply shape (RFC 2812 §3.2.1).
399
+ const topicReply = topicNumerics(state, ctx);
400
+ if (topicReply !== undefined) effects.push(topicReply);
395
401
 
396
402
  return { state, effects };
397
403
  };
@@ -36,6 +36,7 @@ import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
36
36
  import type { ConnectionState } from '../state/connection.js';
37
37
  import type { Ctx, Reducer } from '../types.js';
38
38
  import { emitAccountNotify } from './account-notify.js';
39
+ import { applyAssignedVhost } from './hostserv.js';
39
40
  import { deliverUnreadMemos } from './memoserv.js';
40
41
 
41
42
  /** Canonical NickServ pseudo-client nick. */
@@ -74,6 +75,7 @@ export const nickservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
74
75
  switch (sub) {
75
76
  case 'REGISTER':
76
77
  return handleRegister(state, args, ctx, effects);
78
+ case 'ID':
77
79
  case 'IDENTIFY':
78
80
  return handleIdentify(state, args, ctx, effects);
79
81
  case 'DROP':
@@ -166,9 +168,10 @@ function handleIdentify(
166
168
  }
167
169
 
168
170
  /**
169
- * Applies a successful identify: records the account, sets `+r`, emits the
170
- * confirmation NOTICE, fans `ACCOUNT` out to peers, and delivers queued
171
- * memos.
171
+ * Applies a successful identify: records the account, sets `+r`, re-applies any
172
+ * assigned HostServ vhost (so a reconnecting user does not have to re-run
173
+ * `HostServ ON`), emits the confirmation NOTICE, fans `ACCOUNT` out to peers,
174
+ * and delivers queued memos.
172
175
  */
173
176
  function applyIdentify(
174
177
  state: ConnectionState,
@@ -178,6 +181,10 @@ function applyIdentify(
178
181
  ): void {
179
182
  state.account = account;
180
183
  state.userModes.registered = true;
184
+ // Re-apply an assigned HostServ vhost. At identify time the connection is
185
+ // usually pre-JOIN so the CHGHOST fanout is empty, but a post-JOIN IDENTIFY
186
+ // fans CHGHOST out to the user's current `chghost`-capable peers.
187
+ applyAssignedVhost(state, services, account, effects);
181
188
  effects.push(notice(state, `You are now identified for nick ${account}.`));
182
189
  effects.push(...emitAccountNotify({ conn: state, account }));
183
190
  deliverUnreadMemos(state, services, account, effects);
@@ -241,7 +248,12 @@ function handleInfo(
241
248
 
242
249
  effects.push(notice(state, `Nick: ${rec.nick}`));
243
250
  effects.push(notice(state, `Account: ${rec.account}`));
244
- effects.push(notice(state, `Email: ${rec.email}`));
251
+ // Email is private: only the account owner or an oper may see it. Mirrors
252
+ // the `state.account === rec.account` gate used by SET ENFORCE / the
253
+ // enforcement hook, extended to opers.
254
+ if (state.account === rec.account || state.userModes.oper) {
255
+ effects.push(notice(state, `Email: ${rec.email}`));
256
+ }
245
257
  return { state, effects };
246
258
  }
247
259
 
@@ -22,7 +22,7 @@ import type { ChanName } from '../state/channel.js';
22
22
  import { hostmaskOf } from '../state/connection.js';
23
23
  import type { ConnectionState } from '../state/connection.js';
24
24
  import type { Ctx, Reducer } from '../types.js';
25
- import { passBasedAccountAuth } from './account-auth.js';
25
+ import { attemptPassAccountAuth, passBasedAccountAuth } from './account-auth.js';
26
26
  import { generateIsupport } from './isupport.js';
27
27
  import { buildMotdNumerics } from './motd-lines.js';
28
28
  import { enforceRegisteredNick } from './nickserv.js';
@@ -161,12 +161,16 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
161
161
  * Replaces `state.host` with a deterministic cloak derived from the
162
162
  * deployment's cloaking secret + the current (real) host. No-op when
163
163
  * cloaking is disabled or no host is set yet (e.g. transport didn't supply
164
- * 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.
165
168
  */
166
169
  function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
167
170
  const cloaking = ctx.serverConfig.cloaking;
168
171
  if (cloaking === undefined || !cloaking.enabled) return;
169
172
  if (state.host === undefined) return;
173
+ if (state.vhostActive === true) return;
170
174
  state.host = cloakHost(state.host, cloaking, ctx.networkName);
171
175
  }
172
176
 
@@ -340,33 +344,40 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
340
344
  /**
341
345
  * Reducer for `PASS <password>`.
342
346
  *
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.
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.
352
358
  */
353
359
  export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
354
360
  const effects: EffectType[] = [];
355
361
 
356
- if (state.registration === 'registered') {
362
+ const password = msg.params[0];
363
+ if (password === undefined) {
357
364
  effects.push(
358
365
  Effect.send(ctx.connId, [
359
- numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
366
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
360
367
  ]),
361
368
  );
362
369
  return { state, effects };
363
370
  }
364
371
 
365
- const password = msg.params[0];
366
- 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) {
367
378
  effects.push(
368
379
  Effect.send(ctx.connId, [
369
- numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
380
+ numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
370
381
  ]),
371
382
  );
372
383
  return { state, effects };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Service shortcut command aliases (Atheme/Anope convention).
3
+ *
4
+ * Clients and user expectations support shortcut verbs like
5
+ * `NICKSERV REGISTER pw` as an alias for `PRIVMSG NickServ :REGISTER pw`.
6
+ * These are not RFC-standardized but are a de facto convention from
7
+ * services packages (Atheme/Anope) and IRCd alias modules (InspIRCd
8
+ * `m_alias`, UnrealIRCd `alias{}`).
9
+ *
10
+ * The actor's dispatcher rewrites any of these verbs to a
11
+ * `PRIVMSG <ServiceNick> :<joined args>` and delegates to the existing
12
+ * `routeMessageTarget` path, so the service reducers see the canonical
13
+ * `params=[serviceNick, body]` shape unchanged.
14
+ *
15
+ * This module is the **single source of truth** for the alias → service
16
+ * nick mapping. Both the dispatcher rewrite (in `irc-server`'s actor) and
17
+ * the service-nick recognition in `serviceReducerFor` consult the derived
18
+ * {@link SERVICE_NICKS} list rather than maintaining their own copies.
19
+ */
20
+
21
+ import { CHANSERV_NICK } from './chanserv.js';
22
+ import { HOSTSERV_NICK } from './hostserv.js';
23
+ import { MEMOSERV_NICK } from './memoserv.js';
24
+ import { NICKSERV_NICK } from './nickserv.js';
25
+ import { OPERSERV_NICK } from './operserv.js';
26
+
27
+ /**
28
+ * Shortcut command verb (uppercased) → canonical service nick. Keyed by
29
+ * uppercased command so the dispatcher's `msg.command.toUpperCase()` lookup
30
+ * is case-insensitive. The full set of Atheme/Anope-convention aliases.
31
+ */
32
+ export const SHORTCUT_TO_SERVICE: ReadonlyMap<string, string> = new Map<string, string>([
33
+ ['NICKSERV', NICKSERV_NICK],
34
+ ['NS', NICKSERV_NICK],
35
+ ['CHANSERV', CHANSERV_NICK],
36
+ ['CS', CHANSERV_NICK],
37
+ ['HOSTSERV', HOSTSERV_NICK],
38
+ ['HS', HOSTSERV_NICK],
39
+ ['MEMOSERV', MEMOSERV_NICK],
40
+ ['MS', MEMOSERV_NICK],
41
+ ['OPERSERV', OPERSERV_NICK],
42
+ ['OS', OPERSERV_NICK],
43
+ ]);
44
+
45
+ /**
46
+ * The canonical, de-duplicated list of service nicks derived from
47
+ * {@link SHORTCUT_TO_SERVICE}'s values. Used by `serviceReducerFor` for the
48
+ * "is this target a service nick?" membership check so the service-nick list
49
+ * lives in exactly one place (this module), not duplicated across an
50
+ * `if`-chain.
51
+ */
52
+ export const SERVICE_NICKS: readonly string[] = Array.from(new Set(SHORTCUT_TO_SERVICE.values()));
@@ -153,7 +153,8 @@ function readTopic(
153
153
  ctx: Ctx,
154
154
  effects: EffectType[],
155
155
  ): { state: ChannelState; effects: EffectType[] } {
156
- if (state.topic === undefined) {
156
+ const topicReply = topicNumerics(state, ctx);
157
+ if (topicReply === undefined) {
157
158
  effects.push(
158
159
  Effect.send(ctx.connId, [
159
160
  numericErr(ctx, Numerics.RPL_NOTOPIC, 'No topic is set', state.name),
@@ -161,19 +162,31 @@ function readTopic(
161
162
  );
162
163
  return { state, effects };
163
164
  }
165
+ effects.push(topicReply);
166
+ return { state, effects };
167
+ }
164
168
 
169
+ /**
170
+ * Builds the `332 RPL_TOPIC` + `333 RPL_TOPICWHOTIME` {@link Effect.send}
171
+ * for the connection's current nick, or `undefined` when no topic is set
172
+ * on `state.topic`.
173
+ *
174
+ * Shared by the TOPIC read-form ({@link readTopic}) and the JOIN success
175
+ * path so both emit an identical numeric block whenever a topic is
176
+ * present, regardless of whether the topic came from a ChanServ KEEPTOPIC
177
+ * restore, a deploy snapshot, or an earlier joiner.
178
+ */
179
+ export function topicNumerics(state: ChannelState, ctx: Ctx): EffectType | undefined {
180
+ if (state.topic === undefined) return undefined;
165
181
  const nick = ctx.connection.nick ?? '*';
166
182
  const codeStr332 = Numerics.RPL_TOPIC.toString().padStart(3, '0');
167
183
  const codeStr333 = Numerics.RPL_TOPICWHOTIME.toString().padStart(3, '0');
168
- effects.push(
169
- Effect.send(ctx.connId, [
170
- { text: `:${ctx.serverName} ${codeStr332} ${nick} ${state.name} :${state.topic.text}` },
171
- {
172
- text: `:${ctx.serverName} ${codeStr333} ${nick} ${state.name} ${state.topic.setter} ${state.topic.setAt}`,
173
- },
174
- ]),
175
- );
176
- return { state, effects };
184
+ return Effect.send(ctx.connId, [
185
+ { text: `:${ctx.serverName} ${codeStr332} ${nick} ${state.name} :${state.topic.text}` },
186
+ {
187
+ text: `:${ctx.serverName} ${codeStr333} ${nick} ${state.name} ${state.topic.setter} ${state.topic.setAt}`,
188
+ },
189
+ ]);
177
190
  }
178
191
 
179
192
  // ============================================================================
@@ -92,6 +92,23 @@ export interface MembershipDelta {
92
92
  conn: ConnId;
93
93
  /** Required on `add`; ignored on `remove`. */
94
94
  nick?: Nick;
95
+ /**
96
+ * Nick-keyed resolution hint for ChanServ prefix/kick commands. When set,
97
+ * the actor layer resolves nick→connId against the channel roster at apply
98
+ * time and overwrites {@link conn} with the resolved id; if the nick is not
99
+ * on the channel, the delta is a no-op and ChanServ NOTICEs the caller.
100
+ *
101
+ * ChanServ operates on `ConnectionState` and cannot see the roster, so it
102
+ * emits a placeholder {@link conn} (`''`) plus this hint — see
103
+ * `resolveMembershipTarget` in `commands/chanserv.ts`.
104
+ *
105
+ * When `targetNick` is present on an `add` delta, the actor treats the
106
+ * `op`/`voice` fields as a *patch*: only fields set on the delta are
107
+ * applied, others retain the existing entry's value. This lets ChanServ
108
+ * op a voiced user without clobbering their voice (a pure reducer cannot
109
+ * read both flags ahead of time).
110
+ */
111
+ targetNick?: Nick;
95
112
  op?: boolean;
96
113
  voice?: boolean;
97
114
  }