serverless-ircd 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.github/workflows/ci.yml +3 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +273 -29
  4. package/README.md +155 -55
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-worker/package.json +1 -1
  10. package/apps/cf-worker/src/worker.ts +4 -4
  11. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  12. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  13. package/apps/cf-worker/wrangler.test.toml +6 -6
  14. package/apps/cf-worker/wrangler.toml +7 -5
  15. package/apps/local-cli/package.json +1 -1
  16. package/apps/local-cli/src/config-loader.ts +8 -0
  17. package/apps/local-cli/src/main.ts +16 -0
  18. package/apps/local-cli/src/server.ts +1 -0
  19. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  20. package/apps/web/landing/index.html +14 -16
  21. package/apps/web/package.json +2 -2
  22. package/apps/web/scripts/build.mjs +23 -16
  23. package/apps/web/src/build-env.ts +1 -1
  24. package/apps/web/src/config-schema.ts +6 -6
  25. package/apps/web/tests/build-smoke.test.ts +14 -14
  26. package/apps/web/tests/config-schema.test.ts +1 -1
  27. package/docs/AWS-Deployment.md +21 -8
  28. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  29. package/docs/PlanExtensions.md +113 -3
  30. package/docs/Release-Process.md +23 -13
  31. package/docs/Services.md +546 -0
  32. package/docs/WebClientGuide.md +32 -31
  33. package/package.json +2 -2
  34. package/packages/aws-adapter/package.json +1 -1
  35. package/packages/aws-adapter/src/aws-runtime.ts +5 -0
  36. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  37. package/packages/aws-adapter/src/config-loader.ts +11 -0
  38. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  39. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  40. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  41. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  42. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  43. package/packages/aws-adapter/src/handlers/nlb-stream.ts +15 -0
  44. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  45. package/packages/aws-adapter/src/index.ts +4 -0
  46. package/packages/aws-adapter/src/stats.ts +6 -1
  47. package/packages/aws-adapter/src/tables.ts +34 -4
  48. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  49. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  50. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  51. package/packages/aws-adapter/tests/connection-counter.test.ts +127 -0
  52. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  53. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  54. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  55. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  56. package/packages/cf-adapter/package.json +1 -1
  57. package/packages/cf-adapter/src/config-loader.ts +11 -0
  58. package/packages/cf-adapter/src/connection-do.ts +112 -2
  59. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  60. package/packages/cf-adapter/src/env.ts +8 -0
  61. package/packages/cf-adapter/src/index.ts +5 -0
  62. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  63. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  64. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  65. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  66. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  67. package/packages/in-memory-runtime/package.json +1 -1
  68. package/packages/irc-core/package.json +1 -1
  69. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  70. package/packages/irc-core/src/commands/account-auth.ts +172 -0
  71. package/packages/irc-core/src/commands/chanserv.ts +882 -0
  72. package/packages/irc-core/src/commands/hostserv.ts +487 -0
  73. package/packages/irc-core/src/commands/index.ts +12 -0
  74. package/packages/irc-core/src/commands/join.ts +164 -8
  75. package/packages/irc-core/src/commands/markread.ts +202 -0
  76. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  77. package/packages/irc-core/src/commands/mode.ts +96 -4
  78. package/packages/irc-core/src/commands/nickserv.ts +390 -0
  79. package/packages/irc-core/src/commands/oper.ts +18 -1
  80. package/packages/irc-core/src/commands/operserv.ts +346 -0
  81. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  82. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  83. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  84. package/packages/irc-core/src/commands/registration.ts +61 -6
  85. package/packages/irc-core/src/commands/sasl.ts +18 -49
  86. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  87. package/packages/irc-core/src/commands/topic.ts +37 -0
  88. package/packages/irc-core/src/config.ts +36 -5
  89. package/packages/irc-core/src/effects.ts +56 -1
  90. package/packages/irc-core/src/ports.ts +1653 -84
  91. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  92. package/packages/irc-core/src/state/channel.ts +21 -1
  93. package/packages/irc-core/src/state/connection.ts +25 -1
  94. package/packages/irc-core/src/types.ts +48 -12
  95. package/packages/irc-core/tests/commands/chanserv.test.ts +1668 -0
  96. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  97. package/packages/irc-core/tests/commands/hostserv.test.ts +935 -0
  98. package/packages/irc-core/tests/commands/join.test.ts +393 -1
  99. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  100. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  101. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  102. package/packages/irc-core/tests/commands/nickserv.test.ts +807 -0
  103. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  104. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  105. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  106. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  107. package/packages/irc-core/tests/commands/registration.test.ts +788 -14
  108. package/packages/irc-core/tests/commands/sasl.test.ts +185 -12
  109. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  111. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  112. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  113. package/packages/irc-core/tests/config.test.ts +49 -5
  114. package/packages/irc-core/tests/effects.test.ts +19 -0
  115. package/packages/irc-core/tests/message-store.test.ts +63 -0
  116. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  117. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  118. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  119. package/packages/irc-server/package.json +1 -1
  120. package/packages/irc-server/src/actor.ts +71 -16
  121. package/packages/irc-server/src/dispatch.ts +94 -7
  122. package/packages/irc-server/src/routing.ts +19 -0
  123. package/packages/irc-server/tests/actor.test.ts +623 -12
  124. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  125. package/packages/irc-server/tests/routing.test.ts +6 -0
  126. package/packages/irc-test-support/package.json +1 -1
  127. package/packages/irc-test-support/src/in-memory-harness.ts +29 -3
  128. package/packages/irc-test-support/src/index.ts +1 -0
  129. package/packages/irc-test-support/tests/in-memory-harness.test.ts +32 -0
  130. package/tools/ci-hardening/package.json +1 -1
  131. package/tools/load-test/package.json +1 -1
  132. package/tools/tcp-ws-forwarder/package.json +1 -1
  133. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +2 -2
  134. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  135. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -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.8.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,30 @@ 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,
55
59
  type ServerConfig,
56
60
  type ServerStats,
61
+ type ServicesStore,
57
62
  applyLabelToEffects,
58
63
  buildCtx,
59
64
  buildMultilineFanout,
65
+ caseFold,
60
66
  chathistoryReducer,
61
67
  extractLabel,
62
68
  finalizeMultilineBatch,
@@ -168,18 +174,6 @@ export interface ConnectionActorOptions {
168
174
  * preserves the no-store behaviour: `WHOWAS <nick>` emits `406`+`369`.
169
175
  */
170
176
  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
177
  /**
184
178
  * Away-status persistence source for IRCv3 `draft/pre-away` (absent
185
179
  * when the deployment does not persist away reasons). When bound,
@@ -192,6 +186,22 @@ export interface ConnectionActorOptions {
192
186
  * is not shared across connections or restored on reconnect.
193
187
  */
194
188
  readonly away?: AwayStore;
189
+ /**
190
+ * IRC services persistence source for NickServ / ChanServ / HostServ /
191
+ * MemoServ, plus the per-account read-marker persistence for IRCv3
192
+ * `draft/read-marker` (absent when services are not wired). When bound,
193
+ * the actor routes `PRIVMSG NickServ :…` to the NickServ reducer
194
+ * (REGISTER / IDENTIFY / DROP / INFO) against this store, a
195
+ * `+draft/read-marker=<msgid>` TAGMSG from an identified, cap-enabled
196
+ * client updates the per-`(account, channel)` marker via
197
+ * {@link ServicesStore.setLastReadMarker}, SASL identify seeds the
198
+ * connection's `lastReadMarkers` from {@link ServicesStore.listReadMarkers},
199
+ * and SASL PLAIN falls back to it for nicks registered via NickServ.
200
+ * Omitted (→ no services routing) preserves the existing behaviour:
201
+ * `PRIVMSG NickServ :…` is treated as a normal nick-target message and
202
+ * read-marker persistence stays session-only.
203
+ */
204
+ readonly services?: ServicesStore;
195
205
  /**
196
206
  * Monitor-subscription registry for the IRCv3 `MONITOR` command (absent
197
207
  * when MONITOR push notifications are disabled). When bound, the actor
@@ -262,8 +272,8 @@ export class ConnectionActor {
262
272
  private readonly accounts: AccountStore | undefined;
263
273
  private readonly mtlsIdentity: MtlsIdentityProvider | undefined;
264
274
  private readonly history: NickHistoryStore | undefined;
265
- private readonly readMarkers: ReadMarkerStore | undefined;
266
275
  private readonly away: AwayStore | undefined;
276
+ private readonly services: ServicesStore | undefined;
267
277
  private readonly monitor: MonitorStore | undefined;
268
278
  private readonly stats: ServerStats | undefined;
269
279
  private readonly reducers: RoutedReducers;
@@ -316,8 +326,8 @@ export class ConnectionActor {
316
326
  this.accounts = opts.accounts;
317
327
  this.mtlsIdentity = opts.mtlsIdentity;
318
328
  this.history = opts.history;
319
- this.readMarkers = opts.readMarkers;
320
329
  this.away = opts.away;
330
+ this.services = opts.services;
321
331
  this.monitor = opts.monitor;
322
332
  this.stats = opts.stats;
323
333
  this.logger = opts.logger ?? NoopLoggerInstance;
@@ -502,8 +512,8 @@ export class ConnectionActor {
502
512
  ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
503
513
  ...(this.mtlsIdentity !== undefined ? { mtlsIdentity: this.mtlsIdentity } : {}),
504
514
  ...(this.history !== undefined ? { history: this.history } : {}),
505
- ...(this.readMarkers !== undefined ? { readMarkers: this.readMarkers } : {}),
506
515
  ...(this.away !== undefined ? { away: this.away } : {}),
516
+ ...(this.services !== undefined ? { services: this.services } : {}),
507
517
  ...(label !== undefined ? { label } : {}),
508
518
  });
509
519
 
@@ -631,6 +641,20 @@ export class ConnectionActor {
631
641
  )
632
642
  : this.reducers.userMode(this.state, msg, ctx).effects;
633
643
 
644
+ case 'MARKREAD': {
645
+ // draft/read-marker MARKREAD verb. Read-marker persistence is
646
+ // channel-scoped in this deployment, so a non-channel (nick) target
647
+ // is unsupported and silently ignored. An empty target is routed
648
+ // through the reducer so it can FAIL NEED_MORE_PARAMS rather than
649
+ // hitting the generic 421 fallthrough.
650
+ const markreadTarget = msg.params[0] ?? '';
651
+ if (markreadTarget.length === 0 || isChannelTarget(markreadTarget)) {
652
+ const chan = this.channels.getOrCreateChannel(markreadTarget);
653
+ return this.reducers.markreadChannel(chan, msg, ctx).effects;
654
+ }
655
+ return [];
656
+ }
657
+
634
658
  default:
635
659
  // Remaining standard IRC verbs not yet implemented. They fall through
636
660
  // to a graceful `421 ERR_UNKNOWNCOMMAND`. Triage of remaining verbs:
@@ -1327,6 +1351,36 @@ export class ConnectionActor {
1327
1351
  if (isChannelTarget(target)) {
1328
1352
  const chan = this.channels.getOrCreateChannel(target);
1329
1353
  effects.push(...channelReducer(chan, singleParamMsg, ctx).effects);
1354
+ } else if (
1355
+ msg.command === 'PRIVMSG' &&
1356
+ this.services !== undefined &&
1357
+ caseFold('rfc1459', target) === caseFold('rfc1459', NICKSERV_NICK)
1358
+ ) {
1359
+ effects.push(...this.reducers.nickserv(this.state, singleParamMsg, ctx).effects);
1360
+ } else if (
1361
+ msg.command === 'PRIVMSG' &&
1362
+ this.services !== undefined &&
1363
+ caseFold('rfc1459', target) === caseFold('rfc1459', CHANSERV_NICK)
1364
+ ) {
1365
+ effects.push(...this.reducers.chanserv(this.state, singleParamMsg, ctx).effects);
1366
+ } else if (
1367
+ msg.command === 'PRIVMSG' &&
1368
+ this.services !== undefined &&
1369
+ caseFold('rfc1459', target) === caseFold('rfc1459', HOSTSERV_NICK)
1370
+ ) {
1371
+ effects.push(...this.reducers.hostserv(this.state, singleParamMsg, ctx).effects);
1372
+ } else if (
1373
+ msg.command === 'PRIVMSG' &&
1374
+ this.services !== undefined &&
1375
+ caseFold('rfc1459', target) === caseFold('rfc1459', MEMOSERV_NICK)
1376
+ ) {
1377
+ effects.push(...this.reducers.memoserv(this.state, singleParamMsg, ctx).effects);
1378
+ } else if (
1379
+ msg.command === 'PRIVMSG' &&
1380
+ this.services !== undefined &&
1381
+ caseFold('rfc1459', target) === caseFold('rfc1459', OPERSERV_NICK)
1382
+ ) {
1383
+ effects.push(...this.reducers.operserv(this.state, singleParamMsg, ctx).effects);
1330
1384
  } else {
1331
1385
  effects.push(...userReducer(this.state, singleParamMsg, ctx).effects);
1332
1386
  }
@@ -1405,6 +1459,7 @@ export function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
1405
1459
  case 'PRIVMSG':
1406
1460
  case 'NOTICE':
1407
1461
  case 'TAGMSG':
1462
+ case 'MARKREAD':
1408
1463
  return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
1409
1464
  case 'INVITE':
1410
1465
  // 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
  }