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
@@ -18,14 +18,16 @@
18
18
  import { caseFold } from '../case-fold.js';
19
19
  import { Effect } from '../effects.js';
20
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
21
- import type { StoredMessage } from '../ports.js';
21
+ import type { PersistedTopic, StoredMessage } from '../ports.js';
22
22
  import { wrapBatch } from '../protocol/batch.js';
23
23
  import { Numerics } from '../protocol/numerics.js';
24
- import { type ChanName, type ChannelState, prefixOf } from '../state/channel.js';
24
+ import { type ChanName, type ChannelState, type ChannelTopic, prefixOf } from '../state/channel.js';
25
25
  import { hostmaskOf } from '../state/connection.js';
26
26
  import type { ConnectionState } from '../state/connection.js';
27
27
  import type { Ctx, Reducer } from '../types.js';
28
+ import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
28
29
  import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
30
+ import { topicNumerics } from './topic.js';
29
31
 
30
32
  /**
31
33
  * RFC 1459/2812 channel-name grammar. The first character must be a channel
@@ -226,6 +228,21 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
226
228
  return { state, effects };
227
229
  }
228
230
 
231
+ // ChanServ +R check: unidentified clients may not join a +R channel.
232
+ if (state.modes.blockUnidentified && ctx.connection.account === undefined) {
233
+ effects.push(
234
+ Effect.send(ctx.connId, [
235
+ numericErr(
236
+ ctx,
237
+ Numerics.ERR_ALLMUSTREGISTER,
238
+ 'You must register with NickServ to join this channel',
239
+ rawName,
240
+ ),
241
+ ]),
242
+ );
243
+ return { state, effects };
244
+ }
245
+
229
246
  // Limit check.
230
247
  if (state.modes.limit !== undefined && state.members.size >= state.modes.limit) {
231
248
  effects.push(
@@ -249,15 +266,49 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
249
266
  }
250
267
  }
251
268
 
252
- // All checks passed: add to roster. First joiner gets op per RFC 2812.
253
- const isFirst = state.members.size === 0;
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.
276
+ const wasEmpty = state.members.size === 0;
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);
290
+ const grantVoice = autoPrefix?.voice ?? false;
291
+ const joinerNick = ctx.connection.nick ?? '?';
254
292
  state.members.set(ctx.connId, {
255
293
  conn: ctx.connId,
256
- nick: ctx.connection.nick ?? '?',
257
- op: isFirst,
258
- voice: false,
294
+ nick: joinerNick,
295
+ op: grantOp,
296
+ voice: grantVoice,
259
297
  });
260
298
 
299
+ // ChanServ KEEPTOPIC restore: when the channel was empty immediately
300
+ // before this join and services have a persisted topic snapshot for a
301
+ // keep-topic-enabled registration, restore the topic onto the
302
+ // authoritative ChannelState. The 332/333 numerics are emitted to the
303
+ // joiner below alongside the NAMES list, keyed off `state.topic`.
304
+ if (wasEmpty) {
305
+ const restored = restoreKeepTopic(ctx, state.nameLower);
306
+ if (restored !== undefined) {
307
+ state.topic = restored;
308
+ effects.push(Effect.applyChannelDelta(state.name, { topic: restored }));
309
+ }
310
+ }
311
+
261
312
  // Pending invite is consumed on a successful join.
262
313
  if (myNick !== undefined) state.pendingInvites.delete(caseFold('rfc1459', myNick));
263
314
 
@@ -275,13 +326,26 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
275
326
  {
276
327
  type: 'add',
277
328
  conn: ctx.connId,
278
- nick: ctx.connection.nick ?? '?',
279
- op: isFirst,
329
+ nick: joinerNick,
330
+ op: grantOp,
331
+ voice: grantVoice,
280
332
  },
281
333
  ],
282
334
  }),
283
335
  );
284
336
 
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)) {
346
+ effects.push(buildChanservPrefixBroadcast(state.name, joinerNick, autoPrefix));
347
+ }
348
+
285
349
  // IRCv3 draft/chathistory auto-playback: a cap-enabled joiner with a
286
350
  // bound MessageStore receives the recent backlog as a BATCH *before*
287
351
  // the JOIN broadcast, then the per-(connection, channel) "last read"
@@ -328,6 +392,13 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
328
392
 
329
393
  effects.push(Effect.send(ctx.connId, lines));
330
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);
401
+
331
402
  return { state, effects };
332
403
  };
333
404
 
@@ -360,3 +431,94 @@ export function handleJoinZero(ctx: Ctx): EffectType[] {
360
431
  ctx.connection.joinedChannels.clear();
361
432
  return effects;
362
433
  }
434
+
435
+ // ============================================================================
436
+ // ChanServ KEEPTOPIC restore
437
+ // ============================================================================
438
+
439
+ /**
440
+ * Returns the persisted topic snapshot to restore on a JOIN that re-creates
441
+ * an empty channel, or `undefined` when:
442
+ * - no services store is bound,
443
+ * - the channel is not registered,
444
+ * - KEEPTOPIC is off, or
445
+ * - no topic snapshot is stored.
446
+ *
447
+ * Reads the per-channel lowercased name from the joiner's `nameLower`
448
+ * field; services-store keys are rfc1459-folded so the lookup is
449
+ * case-insensitive.
450
+ */
451
+ function restoreKeepTopic(ctx: Ctx, nameLower: string): ChannelTopic | undefined {
452
+ const services = ctx.services;
453
+ if (services === undefined) return undefined;
454
+ const rec = services.getChannel(nameLower);
455
+ if (rec === undefined || !rec.keepTopic) return undefined;
456
+ const persisted: PersistedTopic | undefined = services.getChannelTopic(nameLower);
457
+ if (persisted === undefined) return undefined;
458
+ return { text: persisted.text, setter: persisted.setter, setAt: persisted.setAt };
459
+ }
460
+
461
+ // ============================================================================
462
+ // ChanServ ACCESS auto-prefix
463
+ // ============================================================================
464
+
465
+ /**
466
+ * Decides whether ChanServ should auto-grant a channel prefix to the
467
+ * joining connection on a non-empty registered channel.
468
+ *
469
+ * Returns `{ op: true, voice: false }` for the channel founder or any
470
+ * account whose access level meets the founder-configured `AUTOOP`
471
+ * threshold; `{ op: false, voice: true }` for accounts that meet the
472
+ * `AUTOVOICE` threshold; `{ op: false, voice: false }` otherwise.
473
+ *
474
+ * Returns `null` when no services store is bound, the channel is not
475
+ * registered, or the joiner is unidentified — the caller treats all of
476
+ * those as "no ChanServ-derived prefix" and falls back to the RFC 2812
477
+ * first-joiner rule or the plain-roster defaults.
478
+ *
479
+ * Threshold lookup consults the founder-redefined overrides via
480
+ * {@link ServicesStore.getChannelLevel} and falls back to
481
+ * {@link DEFAULT_CHANNEL_LEVELS} for ops the founder has not overridden.
482
+ */
483
+ function computeChanservAutoPrefix(
484
+ ctx: Ctx,
485
+ nameLower: string,
486
+ ): { op: boolean; voice: boolean } | null {
487
+ const services = ctx.services;
488
+ if (services === undefined) return null;
489
+ const rec = services.getChannel(nameLower);
490
+ if (rec === undefined) return null;
491
+ const account = ctx.connection.account;
492
+ if (account === undefined) return null;
493
+ if (caseFold('rfc1459', rec.founder) === caseFold('rfc1459', account)) {
494
+ return { op: true, voice: false };
495
+ }
496
+ const level = services.getChannelAccess(nameLower, account);
497
+ const autoOp = services.getChannelLevel(nameLower, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
498
+ const autoVoice =
499
+ services.getChannelLevel(nameLower, 'AUTOVOICE') ?? DEFAULT_CHANNEL_LEVELS.AUTOVOICE;
500
+ if (level >= autoOp) return { op: true, voice: false };
501
+ if (level >= autoVoice) return { op: false, voice: true };
502
+ return { op: false, voice: false };
503
+ }
504
+
505
+ /**
506
+ * Builds the `Broadcast` effect for a ChanServ-emitted channel prefix
507
+ * grant. The line shape mirrors the MODE reducer's ChanServ-MLOCK
508
+ * reassert broadcast so existing clients already special-case
509
+ * `:ChanServ!ChanServ@services MODE …` correctly.
510
+ *
511
+ * Op grants emit `+o` only (the channel-prefix grammar renders `@` for
512
+ * ops, so a redundant `+v` would only confuse client UIs that show both
513
+ * prefixes side-by-side).
514
+ */
515
+ function buildChanservPrefixBroadcast(
516
+ chanName: ChanName,
517
+ nick: string,
518
+ prefix: { op: boolean; voice: boolean },
519
+ ): EffectType {
520
+ const mode = prefix.op ? '+o' : '+v';
521
+ return Effect.broadcast(chanName, [
522
+ { text: `:${CHANSERV_HOSTMASK} MODE ${chanName} ${mode} ${nick}` },
523
+ ]);
524
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `draft/read-marker` `MARKREAD` command.
3
+ *
4
+ * Spec (work-in-progress): https://ircv3.net/specs/extensions/read-marker
5
+ *
6
+ * The serverless-ircd deployment stores read markers **by msgid** (see
7
+ * {@link ../commands/read-marker.ts}) so they compose with the chathistory
8
+ * last-read plumbing. The draft `MARKREAD` verb, by contrast, is
9
+ * timestamp-based: a client sends `MARKREAD <target> <timestamp>` where the
10
+ * timestamp is the `time` tag of the last read message. This reducer is the
11
+ * translation layer — it resolves the client timestamp to the most recent
12
+ * retained message at or before it ({@link MessageStore.findAtOrBefore}),
13
+ * advances the msgid-based marker via {@link persistReadMarker}, and replies
14
+ * with the resolved message's timestamp so the client observes a value that
15
+ * corresponds to a real `time` tag.
16
+ *
17
+ * Wire forms handled:
18
+ * - `MARKREAD <target>` (GET) — replies with the stored marker's timestamp
19
+ * (or `*` when none / unresolvable).
20
+ * - `MARKREAD <target> <timestamp>` (SET) — advances the marker subject to
21
+ * monotonicity (the marker never moves backwards) and replies with the
22
+ * resolved timestamp, or with the stored value when the client timestamp
23
+ * is older-or-equal.
24
+ *
25
+ * Errors use the IRCv3 `standard-replies` `FAIL` form (the draft specifies no
26
+ * legacy numerics for `MARKREAD`):
27
+ * - `FAIL MARKREAD NEED_MORE_PARAMS :Missing parameters`
28
+ * - `FAIL MARKREAD INVALID_PARAMS :Invalid parameters`
29
+ *
30
+ * The command is gated behind the `draft/read-marker` capability: a client
31
+ * that did not negotiate it sees `421 ERR_UNKNOWNCOMMAND` (the spec's
32
+ * permitted "unknown command" fallback, matching the `TAGMSG` precedent).
33
+ *
34
+ * Membership mirrors the `TAGMSG` read-marker path: a non-member on a `+n`
35
+ * channel is ignored silently (the user has no marker to set or get).
36
+ */
37
+
38
+ import { Effect } from '../effects.js';
39
+ import type { RawLine } from '../effects.js';
40
+ import { Numerics } from '../protocol/numerics.js';
41
+ import { formatServerTime } from '../protocol/outbound.js';
42
+ import type { ChannelState } from '../state/channel.js';
43
+ import type { Ctx, Reducer } from '../types.js';
44
+ import { READ_MARKER_CAP, persistReadMarker } from './read-marker.js';
45
+
46
+ /** Prefix that introduces the server-time timestamp in a SET param. */
47
+ const TIMESTAMP_PREFIX = 'timestamp=';
48
+
49
+ /** Literal timestamp meaning "no known read marker" (server replies only). */
50
+ const NO_MARKER = '*';
51
+
52
+ /** Formats a numeric error line addressed to the connection's nick (or `*`). */
53
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
54
+ const nick = ctx.connection.nick ?? '*';
55
+ const codeStr = code.toString().padStart(3, '0');
56
+ return { text: [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`].join(' ') };
57
+ }
58
+
59
+ /** Formats a `FAIL MARKREAD <code> :<desc>` standard-reply line. */
60
+ function failReply(ctx: Ctx, code: string, desc: string): RawLine {
61
+ return { text: `:${ctx.serverName} FAIL MARKREAD ${code} :${desc}` };
62
+ }
63
+
64
+ /** Formats the `:<server> MARKREAD <target> <timestamp>` server reply line. */
65
+ function markreadReply(ctx: Ctx, target: string, timestamp: string): RawLine {
66
+ return { text: `:${ctx.serverName} MARKREAD ${target} ${timestamp}` };
67
+ }
68
+
69
+ /**
70
+ * Formats `ms` as the wire timestamp token carried by every server
71
+ * `MARKREAD` reply: `timestamp=<server-time>` (matching the client SET form
72
+ * and the spec examples). `NO_MARKER` (`*`) is returned verbatim by callers
73
+ * and never passes through here.
74
+ */
75
+ function timestampTag(ms: number): string {
76
+ return `${TIMESTAMP_PREFIX}${formatServerTime(ms)}`;
77
+ }
78
+
79
+ /**
80
+ * Parses a SET timestamp param (`timestamp=<iso>`) into epoch ms, or
81
+ * `undefined` when the param is malformed (missing prefix or an unparseable
82
+ * date). The literal `*` is rejected upstream (it is invalid for SET) before
83
+ * this helper runs.
84
+ */
85
+ function parseTimestampParam(raw: string): number | undefined {
86
+ if (!raw.startsWith(TIMESTAMP_PREFIX)) return undefined;
87
+ const value = raw.slice(TIMESTAMP_PREFIX.length);
88
+ if (value.length === 0) return undefined;
89
+ const ms = Date.parse(value);
90
+ return Number.isNaN(ms) ? undefined : ms;
91
+ }
92
+
93
+ /**
94
+ * Resolves the connection's current marker for `chanLower` to its message's
95
+ * epoch-ms time, or `undefined` when there is no marker, no bound message
96
+ * store, or the marker's message is no longer retained. The epoch-ms form
97
+ * drives the monotonicity comparison; {@link markerTimestampTag} formats the
98
+ * wire form for replies.
99
+ */
100
+ function markerTime(
101
+ ctx: Ctx,
102
+ chanLower: string,
103
+ messages: MessageStoreLike | undefined,
104
+ ): number | undefined {
105
+ const msgid = ctx.connection.lastReadMarkers?.get(chanLower);
106
+ if (msgid === undefined || messages === undefined) return undefined;
107
+ return messages.getMsg(chanLower, msgid)?.time;
108
+ }
109
+
110
+ interface MessageStoreLike {
111
+ getMsg(chan: string, msgid: string): { time: number } | undefined;
112
+ findAtOrBefore(chan: string, ts: number): { msgid: string; time: number } | undefined;
113
+ }
114
+
115
+ /** Reducer for `MARKREAD <channel>` (GET) and `MARKREAD <channel> <ts>` (SET). */
116
+ export const markreadChannelReducer: Reducer<ChannelState> = (state, msg, ctx) => {
117
+ ctx.connection.lastSeen = ctx.clock.now();
118
+
119
+ // Cap gate: clients that did not negotiate draft/read-marker see RFC 2812
120
+ // only (the spec's permitted "unknown command" fallback).
121
+ if (!ctx.connection.caps.has(READ_MARKER_CAP)) {
122
+ return {
123
+ state,
124
+ effects: [
125
+ Effect.send(ctx.connId, [
126
+ numericErr(ctx, Numerics.ERR_UNKNOWNCOMMAND, 'Unknown command', 'MARKREAD'),
127
+ ]),
128
+ ],
129
+ };
130
+ }
131
+
132
+ const target = msg.params[0];
133
+ if (target === undefined || target.length === 0) {
134
+ return {
135
+ state,
136
+ effects: [
137
+ Effect.send(ctx.connId, [failReply(ctx, 'NEED_MORE_PARAMS', 'Missing parameters')]),
138
+ ],
139
+ };
140
+ }
141
+
142
+ // Membership: a non-member on a +n channel has no marker to set or get.
143
+ // Silent, mirroring the TAGMSG read-marker path.
144
+ const senderEntry = state.members.get(ctx.connId);
145
+ if (state.modes.noExternal && senderEntry === undefined) {
146
+ return { state, effects: [] };
147
+ }
148
+
149
+ const messages = ctx.messages as MessageStoreLike | undefined;
150
+
151
+ // GET command: reply with the stored marker timestamp (or *).
152
+ if (msg.params.length < 2) {
153
+ const stored = markerTime(ctx, state.nameLower, messages);
154
+ const tag = stored === undefined ? NO_MARKER : timestampTag(stored);
155
+ return { state, effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, tag)])] };
156
+ }
157
+
158
+ // SET command.
159
+ const raw = msg.params[1] as string;
160
+ if (raw === NO_MARKER) {
161
+ // The literal * is reserved for server replies; a client SET MUST NOT use it.
162
+ return {
163
+ state,
164
+ effects: [Effect.send(ctx.connId, [failReply(ctx, 'INVALID_PARAMS', 'Invalid parameters')])],
165
+ };
166
+ }
167
+ const clientMs = parseTimestampParam(raw);
168
+ if (clientMs === undefined) {
169
+ return {
170
+ state,
171
+ effects: [Effect.send(ctx.connId, [failReply(ctx, 'INVALID_PARAMS', 'Invalid parameters')])],
172
+ };
173
+ }
174
+
175
+ // Translate the client timestamp to the most recent retained message at or
176
+ // before it. Without a bound store, or when nothing matches (e.g. the
177
+ // timestamp predates every retained message), there is no msgid to advance
178
+ // to: reply with the current marker and do not mutate state.
179
+ const resolved = messages?.findAtOrBefore(state.nameLower, clientMs);
180
+ if (resolved === undefined) {
181
+ const stored = markerTime(ctx, state.nameLower, messages);
182
+ const tag = stored === undefined ? NO_MARKER : timestampTag(stored);
183
+ return { state, effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, tag)])] };
184
+ }
185
+
186
+ // Monotonicity: the marker only ever advances. A client timestamp that
187
+ // resolves to a message at or before the stored marker is rejected — the
188
+ // server replies with the stored (newer) value and ignores the client value.
189
+ const storedMs = markerTime(ctx, state.nameLower, messages);
190
+ if (storedMs !== undefined && resolved.time <= storedMs) {
191
+ return {
192
+ state,
193
+ effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, timestampTag(storedMs))])],
194
+ };
195
+ }
196
+
197
+ persistReadMarker(ctx, state.nameLower, resolved.msgid);
198
+ return {
199
+ state,
200
+ effects: [Effect.send(ctx.connId, [markreadReply(ctx, target, timestampTag(resolved.time))])],
201
+ };
202
+ };