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
@@ -25,6 +25,9 @@ describe('emptyModes', () => {
25
25
  moderated: false,
26
26
  secret: false,
27
27
  private: false,
28
+ registered: false,
29
+ blockUnidentified: false,
30
+ moderatedIdent: false,
28
31
  });
29
32
  // optional fields are absent, not undefined
30
33
  expect('key' in modes).toBe(false);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -28,6 +28,7 @@
28
28
  import {
29
29
  type AccountStore,
30
30
  type AwayStore,
31
+ CHANSERV_NICK,
31
32
  type ChanName,
32
33
  type ChannelState,
33
34
  type Clock,
@@ -38,25 +39,32 @@ import {
38
39
  Effect,
39
40
  type Effect as EffectType,
40
41
  EmptyMotdProvider,
42
+ HOSTSERV_NICK,
41
43
  type IdFactory,
42
44
  type IrcMessage,
43
45
  LABELED_RESPONSE_CAP_NAME,
44
46
  type Logger,
47
+ MEMOSERV_NICK,
45
48
  type MessageStore,
46
49
  type MonitorStore,
47
50
  type MotdProvider,
48
51
  type MtlsIdentityProvider,
49
52
  type MultilineEntry,
53
+ NICKSERV_NICK,
50
54
  type NickHistoryStore,
51
55
  NoopLoggerInstance,
52
56
  Numerics,
57
+ OPERSERV_NICK,
53
58
  type RawLine,
54
- type ReadMarkerStore,
59
+ type Reducer,
60
+ SHORTCUT_TO_SERVICE,
55
61
  type ServerConfig,
56
62
  type ServerStats,
63
+ type ServicesStore,
57
64
  applyLabelToEffects,
58
65
  buildCtx,
59
66
  buildMultilineFanout,
67
+ caseFold,
60
68
  chathistoryReducer,
61
69
  extractLabel,
62
70
  finalizeMultilineBatch,
@@ -168,18 +176,6 @@ export interface ConnectionActorOptions {
168
176
  * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
169
177
  */
170
178
  readonly history?: NickHistoryStore;
171
- /**
172
- * Read-marker persistence source for IRCv3 `draft/read-marker` (absent
173
- * when the deployment does not persist read markers). When bound,
174
- * `ctx.readMarkers` is defined so a `+draft/read-marker=<msgid>` TAGMSG
175
- * from an identified, cap-enabled client updates the per-`(account,
176
- * channel)` marker, and SASL identify seeds the connection's
177
- * `lastReadMarkers` from the stored values. Omitted (→ `ctx.readMarkers`
178
- * undefined) preserves the session-only marker behaviour: the connection
179
- * still tracks its own read position via chathistory playback, but the
180
- * marker is not shared across connections or restored on reconnect.
181
- */
182
- readonly readMarkers?: ReadMarkerStore;
183
179
  /**
184
180
  * Away-status persistence source for IRCv3 `draft/pre-away` (absent
185
181
  * when the deployment does not persist away reasons). When bound,
@@ -192,6 +188,22 @@ export interface ConnectionActorOptions {
192
188
  * is not shared across connections or restored on reconnect.
193
189
  */
194
190
  readonly away?: AwayStore;
191
+ /**
192
+ * IRC services persistence source for NickServ / ChanServ / HostServ /
193
+ * MemoServ, plus the per-account read-marker persistence for IRCv3
194
+ * `draft/read-marker` (absent when services are not wired). When bound,
195
+ * the actor routes `PRIVMSG NickServ :…` to the NickServ reducer
196
+ * (REGISTER / IDENTIFY / DROP / INFO) against this store, a
197
+ * `+draft/read-marker=<msgid>` TAGMSG from an identified, cap-enabled
198
+ * client updates the per-`(account, channel)` marker via
199
+ * {@link ServicesStore.setLastReadMarker}, SASL identify seeds the
200
+ * connection's `lastReadMarkers` from {@link ServicesStore.listReadMarkers},
201
+ * and SASL PLAIN falls back to it for nicks registered via NickServ.
202
+ * Omitted (→ no services routing) preserves the existing behaviour:
203
+ * `PRIVMSG NickServ :…` is treated as a normal nick-target message and
204
+ * read-marker persistence stays session-only.
205
+ */
206
+ readonly services?: ServicesStore;
195
207
  /**
196
208
  * Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
197
209
  * when MONITOR push notifications are disabled). When bound, the actor
@@ -262,8 +274,8 @@ export class ConnectionActor {
262
274
  private readonly accounts: AccountStore | undefined;
263
275
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
264
276
  private readonly history: NickHistoryStore | undefined;
265
- private readonly readMarkers: ReadMarkerStore | undefined;
266
277
  private readonly away: AwayStore | undefined;
278
+ private readonly services: ServicesStore | undefined;
267
279
  private readonly monitor: MonitorStore | undefined;
268
280
  private readonly stats: ServerStats | undefined;
269
281
  private readonly reducers: RoutedReducers;
@@ -316,8 +328,8 @@ export class ConnectionActor {
316
328
  this.accounts = opts.accounts;
317
329
  this.mtlsIdentity = opts.mtlsIdentity;
318
330
  this.history = opts.history;
319
- this.readMarkers = opts.readMarkers;
320
331
  this.away = opts.away;
332
+ this.services = opts.services;
321
333
  this.monitor = opts.monitor;
322
334
  this.stats = opts.stats;
323
335
  this.logger = opts.logger ?? NoopLoggerInstance;
@@ -502,8 +514,8 @@ export class ConnectionActor {
502
514
  ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
503
515
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
504
516
  ...(this.history !== undefined ? { history: this.history } : {}),
505
- ...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
506
517
  ...(this.away !== undefined ? { away: this.away } : {}),
518
+ ...(this.services !== undefined ? { services: this.services } : {}),
507
519
  ...(label !== undefined ? { label } : {}),
508
520
  });
509
521
 
@@ -517,6 +529,28 @@ export class ConnectionActor {
517
529
  return this.accumulateMultilineLine(msg);
518
530
  }
519
531
 
532
+ // Service shortcut commands (Atheme/Anope convention): rewrite verbs
533
+ // like `NICKSERV IDENTIFY pw` / `NS INFO` / `CS REGISTER #home` to the
534
+ // canonical `PRIVMSG <ServiceNick> :<joined args>` and delegate to the
535
+ // existing service-routing path (`routeMessageTarget` →
536
+ // `serviceReducerFor`). `SHORTCUT_TO_SERVICE` is the single source of
537
+ // truth for the alias → service nick mapping; the service reducers run
538
+ // unchanged against the rewritten `params=[serviceNick, body]` shape.
539
+ const serviceShortcutTarget = SHORTCUT_TO_SERVICE.get(msg.command.toUpperCase());
540
+ if (serviceShortcutTarget !== undefined) {
541
+ const rewritten: IrcMessage = {
542
+ ...msg,
543
+ command: 'PRIVMSG',
544
+ params: [serviceShortcutTarget, msg.params.join(' ')],
545
+ };
546
+ return this.routeMessageTarget(
547
+ rewritten,
548
+ this.reducers.privmsgChannel,
549
+ this.reducers.privmsgUser,
550
+ ctx,
551
+ );
552
+ }
553
+
520
554
  switch (msg.command) {
521
555
  // -------- Connection-authority commands --------
522
556
  case 'PING':
@@ -631,6 +665,20 @@ export class ConnectionActor {
631
665
  )
632
666
  : this.reducers.userMode(this.state, msg, ctx).effects;
633
667
 
668
+ case 'MARKREAD': {
669
+ // draft/read-marker MARKREAD verb. Read-marker persistence is
670
+ // channel-scoped in this deployment, so a non-channel (nick) target
671
+ // is unsupported and silently ignored. An empty target is routed
672
+ // through the reducer so it can FAIL NEED_MORE_PARAMS rather than
673
+ // hitting the generic 421 fallthrough.
674
+ const markreadTarget = msg.params[0] ?? '';
675
+ if (markreadTarget.length === 0 || isChannelTarget(markreadTarget)) {
676
+ const chan = this.channels.getOrCreateChannel(markreadTarget);
677
+ return this.reducers.markreadChannel(chan, msg, ctx).effects;
678
+ }
679
+ return [];
680
+ }
681
+
634
682
  default:
635
683
  // Remaining standard IRC verbs not yet implemented. They fall through
636
684
  // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
@@ -1324,9 +1372,22 @@ export class ConnectionActor {
1324
1372
  }
1325
1373
  for (const target of targets) {
1326
1374
  const singleParamMsg: IrcMessage = { ...msg, params: [target, ...tail] };
1375
+ const serviceReducer = this.serviceReducerFor(target, msg.command);
1327
1376
  if (isChannelTarget(target)) {
1328
1377
  const chan = this.channels.getOrCreateChannel(target);
1329
1378
  effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
1379
+ } else if (serviceReducer !== undefined) {
1380
+ // Echo the sender's own PRIVMSG back BEFORE the service reducer's NOTICE
1381
+ // reply — matching how every other PRIVMSG renders (your outgoing line
1382
+ // first, then any reply). Without this ordering a client sees the
1383
+ // service's response above its own message, which reads as a bug.
1384
+ if (ctx.connection.caps.has('echo-message')) {
1385
+ const hostmask = hostmaskOf(this.state) ?? this.state.nick ?? '?';
1386
+ const text = singleParamMsg.params[1] ?? '';
1387
+ const echoLine: RawLine = { text: `:${hostmask} PRIVMSG ${target} :${text}` };
1388
+ effects.push(Effect.send(ctx.connId, [echoLine]));
1389
+ }
1390
+ effects.push(...serviceReducer(this.state, singleParamMsg, ctx).effects);
1330
1391
  } else {
1331
1392
  effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
1332
1393
  }
@@ -1334,6 +1395,26 @@ export class ConnectionActor {
1334
1395
  return effects;
1335
1396
  }
1336
1397
 
1398
+ /**
1399
+ * Resolves the service pseudo-client reducer for a nick target, or
1400
+ * `undefined` when the target is not a service nick / the command is not
1401
+ * `PRIVMSG` / no services store is bound. Centralised so the
1402
+ * `echo-message` echo (and any future service-routing concern) lives in a
1403
+ * single place rather than being duplicated across five near-identical
1404
+ * branches. `NOTICE` / `TAGMSG` to a service nick intentionally return
1405
+ * `undefined` so they fall through to the normal user-target reducer.
1406
+ *
1407
+ * The membership test is a table lookup against {@link SERVICE_REDUCER_KEYS}
1408
+ * (built from the same `*_NICK` constants that back
1409
+ * {@link SHORTCUT_TO_SERVICE}), so the service-nick list has one source of
1410
+ * truth rather than a per-service `if`-chain here.
1411
+ */
1412
+ private serviceReducerFor(target: string, command: string): Reducer<ConnectionState> | undefined {
1413
+ if (command !== 'PRIVMSG' || this.services === undefined) return undefined;
1414
+ const key = SERVICE_REDUCER_KEYS.get(caseFold('rfc1459', target));
1415
+ return key === undefined ? undefined : this.reducers[key];
1416
+ }
1417
+
1337
1418
  private unknownCommandEffect(command: string): EffectType {
1338
1419
  const nick = this.state.nick ?? '*';
1339
1420
  const code = Numerics.ERR_UNKNOWNCOMMAND.toString().padStart(3, '0');
@@ -1362,6 +1443,29 @@ function splitCsv(param: string): string[] {
1362
1443
  return param.split(',').filter((p) => p.length > 0);
1363
1444
  }
1364
1445
 
1446
+ /**
1447
+ * Service reducer keys on {@link RoutedReducers}. Narrower than
1448
+ * `keyof RoutedReducers` so the table lookup resolves to a
1449
+ * `Reducer<ConnectionState>` without a widening cast.
1450
+ */
1451
+ type ServiceReducerKey = 'nickserv' | 'chanserv' | 'hostserv' | 'memoserv' | 'operserv';
1452
+
1453
+ /**
1454
+ * rfc1459 case-folded service nick → reducer key. The single source of
1455
+ * truth for "which targets are service nicks" — built from the canonical
1456
+ * `*_NICK` constants that also back {@link SHORTCUT_TO_SERVICE}, so the
1457
+ * dispatcher's shortcut rewrite and `serviceReducerFor`'s routing consult
1458
+ * one set of service nicks rather than each maintaining its own
1459
+ * hand-written `if`-chain.
1460
+ */
1461
+ const SERVICE_REDUCER_KEYS: ReadonlyMap<string, ServiceReducerKey> = new Map([
1462
+ [caseFold('rfc1459', NICKSERV_NICK), 'nickserv'],
1463
+ [caseFold('rfc1459', CHANSERV_NICK), 'chanserv'],
1464
+ [caseFold('rfc1459', HOSTSERV_NICK), 'hostserv'],
1465
+ [caseFold('rfc1459', MEMOSERV_NICK), 'memoserv'],
1466
+ [caseFold('rfc1459', OPERSERV_NICK), 'operserv'],
1467
+ ]);
1468
+
1365
1469
  /**
1366
1470
  * Resolves the `<server>` parameter shared by `LUSERS` / `STATS` (and any
1367
1471
  * future single-server-aware query verb). Returns `undefined` when the
@@ -1405,6 +1509,7 @@ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
1405
1509
  case 'PRIVMSG':
1406
1510
  case 'NOTICE':
1407
1511
  case 'TAGMSG':
1512
+ case 'MARKREAD':
1408
1513
  return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
1409
1514
  case 'INVITE':
1410
1515
  // INVITE <nick> <channel> — the channel is the second param.
@@ -24,7 +24,7 @@ import type {
24
24
  MonitorStore,
25
25
  RawLine,
26
26
  } from '@serverless-ircd/irc-core';
27
- import { LogLevel, filterClientTags } from '@serverless-ircd/irc-core';
27
+ import { LogLevel, caseFold, filterClientTags } from '@serverless-ircd/irc-core';
28
28
  import type { IrcRuntime } from './runtime.js';
29
29
 
30
30
  /**
@@ -32,12 +32,15 @@ import type { IrcRuntime } from './runtime.js';
32
32
  * The handler also receives the bound {@link MonitorStore} (or
33
33
  * `undefined`) so ReserveNick / ChangeNick / ReleaseNick can react to the
34
34
  * runtime's success/failure and enqueue `730`/`731` pushes for subscribed
35
- * watchers.
35
+ * watchers. The optional 4th argument carries {@link DispatchOptions} for
36
+ * handlers that need it (currently only `EnforceNick`, which reads the
37
+ * injectable grace scheduler); most handlers ignore it.
36
38
  */
37
39
  type Handler<E extends Effect> = (
38
40
  effect: E,
39
41
  runtime: IrcRuntime,
40
42
  monitor: MonitorStore | undefined,
43
+ opts: DispatchOptions | undefined,
41
44
  ) => Promise<void>;
42
45
 
43
46
  const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } = {
@@ -50,6 +53,50 @@ const handlers: { [K in Effect['tag']]: Handler<Extract<Effect, { tag: K }>> } =
50
53
  ReleaseNick: (e, r, m) => releaseNickEtc(e, r, m),
51
54
  ApplyChannelDelta: (e, r) => r.applyChannelDelta(e.chan, e.delta),
52
55
  BroadcastWallops: (e, r) => r.broadcastWallops(e.lines, e.except),
56
+ EnforceNick: (e, r, _m, opts) => enforceNick(e, r, opts),
57
+ };
58
+
59
+ /** Canonical disconnect reason applied by the EnforceNick handler. */
60
+ const ENFORCE_NICK_REASON = 'Nick enforced by NickServ';
61
+
62
+ /**
63
+ * Host timer used by the default grace scheduler. Declared locally because
64
+ * the package's `lib` is ES2022 (no DOM/WebWorker/Node globals); on every
65
+ * target (Node, Cloudflare Workers, Lambda) `setTimeout` is a real global,
66
+ * so this purely satisfies the type-checker. Scoped to this module.
67
+ */
68
+ declare function setTimeout(callback: () => void, ms?: number): unknown;
69
+
70
+ /**
71
+ * Interprets an {@link EnforceNickEffect} against the runtime:
72
+ * - `none` — no-op (the reducer already sent the warning NOTICE).
73
+ * - `kill` — immediate `disconnect`.
74
+ * - `ghost` — `disconnect` after `graceMs` via the injectable scheduler
75
+ * ({@link DispatchOptions.scheduler}, defaulting to `setTimeout`); when
76
+ * `graceMs === 0` the disconnect is immediate (matches `kill`).
77
+ */
78
+ async function enforceNick(
79
+ e: Extract<Effect, { tag: 'EnforceNick' }>,
80
+ r: IrcRuntime,
81
+ opts: DispatchOptions | undefined,
82
+ ): Promise<void> {
83
+ if (e.policy === 'none') return;
84
+ if (e.policy === 'kill' || e.graceMs <= 0) {
85
+ await r.disconnect(e.conn, ENFORCE_NICK_REASON);
86
+ return;
87
+ }
88
+ // ghost with a positive grace: schedule the disconnect so the client has
89
+ // a window to IDENTIFY. The scheduler is injectable for deterministic
90
+ // tests; production defaults to setTimeout.
91
+ const schedule = opts?.scheduler ?? defaultScheduler;
92
+ schedule(() => {
93
+ void r.disconnect(e.conn, ENFORCE_NICK_REASON);
94
+ }, e.graceMs);
95
+ }
96
+
97
+ /** Default scheduler: defers to the host's `setTimeout`. */
98
+ const defaultScheduler: DispatchScheduler = (cb, ms) => {
99
+ setTimeout(cb, ms);
53
100
  };
54
101
 
55
102
  async function reserveNickEtc(
@@ -115,8 +162,29 @@ export interface DispatchOptions {
115
162
  * dispatch behaviour for callers that have not opted into MONITOR.
116
163
  */
117
164
  readonly monitor?: MonitorStore;
165
+ /**
166
+ * Grace-timer scheduler consulted by the `EnforceNick(ghost)` handler.
167
+ * When supplied, the handler defers the `disconnect` by the effect's
168
+ * `graceMs` via this callback instead of firing immediately — giving
169
+ * the client a window to IDENTIFY before NickServ reclaims the nick.
170
+ * Production adapters inject a thin `setTimeout` wrapper (or a Durable
171
+ * Object alarm on Cloudflare); tests inject a capturing fake so the
172
+ * scheduled delay and the flushed disconnect are both deterministic.
173
+ *
174
+ * Omitted → dispatch falls back to the host `setTimeout`. The fallback
175
+ * only matters for `ghost` enforcements with a positive `graceMs`;
176
+ * `kill` and `graceMs === 0` always disconnect immediately.
177
+ */
178
+ readonly scheduler?: DispatchScheduler;
118
179
  }
119
180
 
181
+ /**
182
+ * Scheduler signature for {@link DispatchOptions.scheduler}. Mirrors the
183
+ * subset of `setTimeout` the `EnforceNick(ghost)` handler needs so tests
184
+ * can substitute a deterministic fake.
185
+ */
186
+ export type DispatchScheduler = (callback: () => void, ms: number) => void;
187
+
120
188
  /**
121
189
  * Returns true iff two effects are interchangeable — used to coalesce
122
190
  * consecutive duplicates so a reducer's accidental double-send is a no-op.
@@ -178,7 +246,7 @@ async function handle(
178
246
  // for exhaustive-union dispatch.
179
247
  const handler = handlers[effect.tag] as Handler<Effect>;
180
248
  try {
181
- await handler(effect, runtime, monitor);
249
+ await handler(effect, runtime, monitor, opts);
182
250
  } catch (err) {
183
251
  if (opts?.logger !== undefined) {
184
252
  opts.logger.on(logLevelFor(err), 'dispatch.error', {
@@ -247,7 +315,7 @@ function logLevelFor(_err: unknown): LogLevel {
247
315
  * (the connection has gone) is treated as lacking the cap.
248
316
  */
249
317
  async function broadcast(e: BroadcastEffect, r: IrcRuntime): Promise<void> {
250
- if (e.cap === undefined && e.caps === undefined) {
318
+ if (e.cap === undefined && e.caps === undefined && e.account === undefined) {
251
319
  await r.broadcast(e.chan, e.lines, e.except);
252
320
  return;
253
321
  }
@@ -256,14 +324,33 @@ async function broadcast(e: BroadcastEffect, r: IrcRuntime): Promise<void> {
256
324
  if (snapshot === null) return;
257
325
 
258
326
  // `caps` (OR semantics) takes precedence; otherwise the single `cap` is
259
- // the only entry. The early-return above guarantees one of them is set.
260
- const capsList: readonly string[] = e.caps ?? [e.cap as string];
327
+ // the only entry. When neither cap field is set (only `account`), the
328
+ // recipient set is account-scoped with no cap gate — every same-account
329
+ // member receives `lines`. The early-return above guarantees at least one
330
+ // of the three filters is set.
331
+ const capsList: readonly string[] | undefined =
332
+ e.caps ?? (e.cap !== undefined ? [e.cap] : undefined);
333
+ // RFC 1459 case-fold the account filter once so per-recipient comparison
334
+ // is a cheap string equality (draft/read-marker privacy is account-scoped).
335
+ const accountFolded = e.account !== undefined ? caseFold('rfc1459', e.account) : undefined;
261
336
  for (const connId of snapshot.members.keys()) {
262
337
  if (connId === e.except) continue;
263
338
 
264
339
  const info = await r.getConnectionInfo(connId);
340
+ // Account-scoped recipient gate (draft/read-marker). When set, only
341
+ // members authenticated as this account pass; unidentified peers (no
342
+ // account) and other accounts are excluded — a read marker is private.
343
+ if (accountFolded !== undefined) {
344
+ const recipientAccount = info?.account;
345
+ if (
346
+ recipientAccount === undefined ||
347
+ caseFold('rfc1459', recipientAccount) !== accountFolded
348
+ ) {
349
+ continue;
350
+ }
351
+ }
265
352
  const recipientCaps = info?.caps ?? EMPTY_CAPS;
266
- const hasAnyCap = capsList.some((c) => recipientCaps.has(c));
353
+ const hasAnyCap = capsList === undefined || capsList.some((c) => recipientCaps.has(c));
267
354
 
268
355
  let toSend: RawLine[] | undefined;
269
356
  if (e.capLines !== undefined) {
@@ -34,17 +34,23 @@ import {
34
34
  awayReducer,
35
35
  capReducer,
36
36
  channelModeReducer,
37
+ chanservReducer,
37
38
  defaultCommandCost,
39
+ hostservReducer,
38
40
  infoReducer,
39
41
  inviteReducer,
40
42
  joinReducer,
41
43
  kickReducer,
44
+ markreadChannelReducer,
45
+ memoservReducer,
42
46
  motdReducer,
43
47
  namesReducer,
44
48
  nickReducer,
49
+ nickservReducer,
45
50
  noticeChannelReducer,
46
51
  noticeUserReducer,
47
52
  operReducer,
53
+ operservReducer,
48
54
  partReducer,
49
55
  passReducer,
50
56
  pingReducer,
@@ -100,8 +106,15 @@ export interface RoutedReducers {
100
106
  readonly noticeUser: Reducer<ConnectionState>;
101
107
  readonly tagmsgChannel: Reducer<ChannelState>;
102
108
  readonly tagmsgUser: Reducer<ConnectionState>;
109
+ readonly markreadChannel: Reducer<ChannelState>;
103
110
  readonly channelMode: Reducer<ChannelState>;
104
111
  readonly userMode: Reducer<ConnectionState>;
112
+ // Services reducers (target-routed, not command-routed).
113
+ readonly nickserv: Reducer<ConnectionState>;
114
+ readonly chanserv: Reducer<ConnectionState>;
115
+ readonly hostserv: Reducer<ConnectionState>;
116
+ readonly memoserv: Reducer<ConnectionState>;
117
+ readonly operserv: Reducer<ConnectionState>;
105
118
  }
106
119
 
107
120
  /**
@@ -169,7 +182,13 @@ export function buildRoutedReducers(serverConfig: ServerConfig): RoutedReducers
169
182
  noticeUser: wrap(noticeUserReducer),
170
183
  tagmsgChannel: wrap(tagmsgChannelReducer),
171
184
  tagmsgUser: wrap(tagmsgUserReducer),
185
+ markreadChannel: wrap(markreadChannelReducer),
172
186
  channelMode: wrap(channelModeReducer),
173
187
  userMode: wrap(userModeReducer),
188
+ nickserv: wrap(nickservReducer),
189
+ chanserv: wrap(chanservReducer),
190
+ hostserv: wrap(hostservReducer),
191
+ memoserv: wrap(memoservReducer),
192
+ operserv: wrap(operservReducer),
174
193
  };
175
194
  }