serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -6,7 +6,9 @@ import {
6
6
  EmptyMotdProvider,
7
7
  FakeClock,
8
8
  InMemoryMessageStore,
9
+ InMemoryReadMarkerStore,
9
10
  type MessageStore,
11
+ type ReadMarkerStore,
10
12
  SequentialIdFactory,
11
13
  type StoredMessage,
12
14
  } from '../../src/ports';
@@ -30,6 +32,7 @@ function makeCtx(
30
32
  conn: ConnectionState,
31
33
  clock = new FakeClock(1_000),
32
34
  messages?: MessageStore,
35
+ readMarkers?: ReadMarkerStore,
33
36
  ): Ctx {
34
37
  return buildCtx({
35
38
  serverConfig,
@@ -38,6 +41,7 @@ function makeCtx(
38
41
  motd: EmptyMotdProvider,
39
42
  connection: conn,
40
43
  ...(messages !== undefined ? { messages } : {}),
44
+ ...(readMarkers !== undefined ? { readMarkers } : {}),
41
45
  });
42
46
  }
43
47
 
@@ -417,6 +421,328 @@ describe('tagmsgChannelReducer — echo-message', () => {
417
421
  });
418
422
  });
419
423
 
424
+ // ============================================================================
425
+ // tagmsgChannelReducer — draft/typing broadcast
426
+ // ============================================================================
427
+ //
428
+ // IRCv3 draft/typing: a TAGMSG carrying `+draft/typing=<active|paused|done>`
429
+ // fans out to channel members that negotiated `draft/typing` OR
430
+ // `message-tags`. The `message-tags` path is the baseline (every TAGMSG goes
431
+ // to message-tags peers); `draft/typing` adds the spec's client-tag
432
+ // exception — peers that lack `message-tags` but negotiated `draft/typing`
433
+ // still receive the typing TAGMSG. The reducer annotates the Broadcast
434
+ // effect with `caps` (OR semantics) so dispatch routes per-recipient.
435
+ // ============================================================================
436
+
437
+ describe('tagmsgChannelReducer — draft/typing broadcast', () => {
438
+ it('emits a Broadcast with caps=[message-tags, draft/typing] for a +draft/typing TAGMSG', () => {
439
+ const chan = makeChan('#foo');
440
+ addMember(chan, 'c1', 'alice');
441
+ addMember(chan, 'c2', 'bob');
442
+ const conn = makeConn();
443
+ const ctx = makeCtx(conn);
444
+
445
+ const out = tagmsgChannelReducer(
446
+ chan,
447
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },
448
+ ctx,
449
+ );
450
+
451
+ expect(out.effects).toEqual<EffectType[]>([
452
+ {
453
+ tag: 'Broadcast',
454
+ chan: '#foo',
455
+ lines: [L('@+draft/typing=active :alice!alice@example.com TAGMSG #foo')],
456
+ except: 'c1',
457
+ caps: ['message-tags', 'draft/typing'],
458
+ },
459
+ ]);
460
+ });
461
+
462
+ it('still uses cap=message-tags (single cap) for a non-typing TAGMSG', () => {
463
+ const chan = makeChan('#foo');
464
+ addMember(chan, 'c1', 'alice');
465
+ const conn = makeConn();
466
+ const ctx = makeCtx(conn);
467
+
468
+ const out = tagmsgChannelReducer(
469
+ chan,
470
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+typing': 'active' } },
471
+ ctx,
472
+ );
473
+
474
+ expect(out.effects).toEqual<EffectType[]>([
475
+ Effect.broadcast(
476
+ '#foo',
477
+ [L('@+typing=active :alice!alice@example.com TAGMSG #foo')],
478
+ 'c1',
479
+ 'message-tags',
480
+ ),
481
+ ]);
482
+ });
483
+
484
+ it('treats a valueless +draft/typing tag as a typing TAGMSG', () => {
485
+ const chan = makeChan('#foo');
486
+ addMember(chan, 'c1', 'alice');
487
+ const conn = makeConn();
488
+ const ctx = makeCtx(conn);
489
+
490
+ const out = tagmsgChannelReducer(
491
+ chan,
492
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': '' } },
493
+ ctx,
494
+ );
495
+
496
+ const broadcast = out.effects[0];
497
+ expect(broadcast?.tag).toBe('Broadcast');
498
+ expect((broadcast as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
499
+ 'message-tags',
500
+ 'draft/typing',
501
+ ]);
502
+ });
503
+
504
+ it('excludes the sender unless echo-message is negotiated (typing TAGMSG)', () => {
505
+ const chan = makeChan('#foo');
506
+ addMember(chan, 'c1', 'alice');
507
+ const conn = makeConn();
508
+ const ctx = makeCtx(conn);
509
+
510
+ const out = tagmsgChannelReducer(
511
+ chan,
512
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },
513
+ ctx,
514
+ );
515
+
516
+ expect(out.effects).toHaveLength(1);
517
+ expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).except).toBe('c1');
518
+ });
519
+
520
+ it('echoes the typing TAGMSG back to the sender when echo-message is negotiated', () => {
521
+ const chan = makeChan('#foo');
522
+ addMember(chan, 'c1', 'alice');
523
+ const conn = makeConn();
524
+ conn.caps.add('echo-message');
525
+ const ctx = makeCtx(conn);
526
+
527
+ const out = tagmsgChannelReducer(
528
+ chan,
529
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/typing': 'active' } },
530
+ ctx,
531
+ );
532
+
533
+ const line = L('@+draft/typing=active :alice!alice@example.com TAGMSG #foo');
534
+ expect(out.effects).toEqual<EffectType[]>([
535
+ {
536
+ tag: 'Broadcast',
537
+ chan: '#foo',
538
+ lines: [line],
539
+ except: 'c1',
540
+ caps: ['message-tags', 'draft/typing'],
541
+ },
542
+ Effect.send('c1', [line]),
543
+ ]);
544
+ });
545
+ });
546
+
547
+ // ============================================================================
548
+ // tagmsgChannelReducer — draft/read-marker broadcast + persistence
549
+ // ============================================================================
550
+ //
551
+ // IRCv3 draft/read-marker: a TAGMSG carrying `+draft/read-marker=<msgid>`
552
+ // marks `<msgid>` as the last read message for the sender's account in the
553
+ // target channel. The reducer (1) persists the marker via the bound
554
+ // ReadMarkerStore keyed by (account, channel), (2) advances the connection's
555
+ // in-memory lastReadMarkers, and (3) fans the TAGMSG out — carrying the
556
+ // `+draft/read-marker` tag — to members that negotiated `draft/read-marker`
557
+ // OR `message-tags` (OR-semantics, matching the draft/typing precedent).
558
+ // ============================================================================
559
+
560
+ describe('tagmsgChannelReducer — draft/read-marker broadcast + persistence', () => {
561
+ it('emits a Broadcast with caps=[message-tags, draft/read-marker] carrying the tag', () => {
562
+ const chan = makeChan('#foo');
563
+ addMember(chan, 'c1', 'alice');
564
+ addMember(chan, 'c2', 'bob');
565
+ const conn = makeConn();
566
+ const ctx = makeCtx(conn);
567
+
568
+ const out = tagmsgChannelReducer(
569
+ chan,
570
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm42' } },
571
+ ctx,
572
+ );
573
+
574
+ expect(out.effects).toEqual<EffectType[]>([
575
+ {
576
+ tag: 'Broadcast',
577
+ chan: '#foo',
578
+ lines: [L('@+draft/read-marker=m42 :alice!alice@example.com TAGMSG #foo')],
579
+ except: 'c1',
580
+ caps: ['message-tags', 'draft/read-marker'],
581
+ },
582
+ ]);
583
+ });
584
+
585
+ it('persists the marker to the ReadMarkerStore keyed by (account, channel)', () => {
586
+ const chan = makeChan('#foo');
587
+ addMember(chan, 'c1', 'alice');
588
+ const conn = makeConn();
589
+ conn.account = 'alice';
590
+ const store = new InMemoryReadMarkerStore();
591
+ const ctx = makeCtx(conn, undefined, undefined, store);
592
+
593
+ tagmsgChannelReducer(
594
+ chan,
595
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm7' } },
596
+ ctx,
597
+ );
598
+
599
+ expect(store.get('alice', '#foo')).toBe('m7');
600
+ });
601
+
602
+ it('advances the connection in-memory lastReadMarkers', () => {
603
+ const chan = makeChan('#foo');
604
+ addMember(chan, 'c1', 'alice');
605
+ const conn = makeConn();
606
+ conn.account = 'alice';
607
+ const ctx = makeCtx(conn, undefined, undefined, new InMemoryReadMarkerStore());
608
+
609
+ tagmsgChannelReducer(
610
+ chan,
611
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm7' } },
612
+ ctx,
613
+ );
614
+
615
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m7');
616
+ });
617
+
618
+ it('overwrites a previously stored marker when a newer msgid arrives', () => {
619
+ const chan = makeChan('#foo');
620
+ addMember(chan, 'c1', 'alice');
621
+ const conn = makeConn();
622
+ conn.account = 'alice';
623
+ const store = new InMemoryReadMarkerStore();
624
+ const ctx = makeCtx(conn, undefined, undefined, store);
625
+
626
+ tagmsgChannelReducer(
627
+ chan,
628
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
629
+ ctx,
630
+ );
631
+ tagmsgChannelReducer(
632
+ chan,
633
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm2' } },
634
+ ctx,
635
+ );
636
+
637
+ expect(store.get('alice', '#foo')).toBe('m2');
638
+ });
639
+
640
+ it('does NOT persist to the store when the connection has no account', () => {
641
+ const chan = makeChan('#foo');
642
+ addMember(chan, 'c1', 'alice');
643
+ const conn = makeConn(); // no account
644
+ const store = new InMemoryReadMarkerStore();
645
+ const ctx = makeCtx(conn, undefined, undefined, store);
646
+
647
+ tagmsgChannelReducer(
648
+ chan,
649
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
650
+ ctx,
651
+ );
652
+
653
+ expect(store.forAccount('alice')).toEqual([]);
654
+ // The in-memory marker still advances for the session.
655
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
656
+ });
657
+
658
+ it('does NOT persist when no ReadMarkerStore is bound but still fans out', () => {
659
+ const chan = makeChan('#foo');
660
+ addMember(chan, 'c1', 'alice');
661
+ const conn = makeConn();
662
+ conn.account = 'alice';
663
+ const ctx = makeCtx(conn); // no store
664
+
665
+ const out = tagmsgChannelReducer(
666
+ chan,
667
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
668
+ ctx,
669
+ );
670
+
671
+ expect(out.effects).toHaveLength(1);
672
+ expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
673
+ 'message-tags',
674
+ 'draft/read-marker',
675
+ ]);
676
+ expect(conn.lastReadMarkers?.get('#foo')).toBe('m1');
677
+ });
678
+
679
+ it('excludes the sender unless echo-message is negotiated', () => {
680
+ const chan = makeChan('#foo');
681
+ addMember(chan, 'c1', 'alice');
682
+ const conn = makeConn();
683
+ const ctx = makeCtx(conn);
684
+
685
+ const out = tagmsgChannelReducer(
686
+ chan,
687
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm1' } },
688
+ ctx,
689
+ );
690
+
691
+ expect(out.effects).toHaveLength(1);
692
+ expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).except).toBe('c1');
693
+ });
694
+
695
+ it('echoes the read-marker TAGMSG back to the sender when echo-message is negotiated', () => {
696
+ const chan = makeChan('#foo');
697
+ addMember(chan, 'c1', 'alice');
698
+ const conn = makeConn();
699
+ conn.caps.add('echo-message');
700
+ const ctx = makeCtx(conn);
701
+
702
+ const out = tagmsgChannelReducer(
703
+ chan,
704
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': 'm3' } },
705
+ ctx,
706
+ );
707
+
708
+ const line = L('@+draft/read-marker=m3 :alice!alice@example.com TAGMSG #foo');
709
+ expect(out.effects).toEqual<EffectType[]>([
710
+ {
711
+ tag: 'Broadcast',
712
+ chan: '#foo',
713
+ lines: [line],
714
+ except: 'c1',
715
+ caps: ['message-tags', 'draft/read-marker'],
716
+ },
717
+ Effect.send('c1', [line]),
718
+ ]);
719
+ });
720
+
721
+ it('fans out a valueless +draft/read-marker tag but persists no msgid', () => {
722
+ const chan = makeChan('#foo');
723
+ addMember(chan, 'c1', 'alice');
724
+ const conn = makeConn();
725
+ conn.account = 'alice';
726
+ const store = new InMemoryReadMarkerStore();
727
+ const ctx = makeCtx(conn, undefined, undefined, store);
728
+
729
+ const out = tagmsgChannelReducer(
730
+ chan,
731
+ { command: 'TAGMSG', params: ['#foo'], tags: { '+draft/read-marker': '' } },
732
+ ctx,
733
+ );
734
+
735
+ // Broadcast still widens to draft/read-marker peers (tag is present)...
736
+ expect((out.effects[0] as Extract<EffectType, { tag: 'Broadcast' }>).caps).toEqual([
737
+ 'message-tags',
738
+ 'draft/read-marker',
739
+ ]);
740
+ // ...but nothing is persisted (no msgid to store).
741
+ expect(store.forAccount('alice')).toEqual([]);
742
+ expect(conn.lastReadMarkers?.get('#foo')).toBeUndefined();
743
+ });
744
+ });
745
+
420
746
  // ============================================================================
421
747
  // tagmsgChannelReducer — chathistory recording
422
748
  // ============================================================================
@@ -631,6 +957,37 @@ describe('tagmsgUserReducer', () => {
631
957
  ]);
632
958
  });
633
959
 
960
+ it('routes a +draft/typing TAGMSG to a single nick target', () => {
961
+ const conn = makeConn();
962
+ const ctx = makeCtx(conn);
963
+
964
+ const out = tagmsgUserReducer(
965
+ conn,
966
+ { command: 'TAGMSG', params: ['bob'], tags: { '+draft/typing': 'active' } },
967
+ ctx,
968
+ );
969
+
970
+ expect(out.effects).toEqual<EffectType[]>([
971
+ Effect.sendToNick('bob', 'c1', [
972
+ L('@+draft/typing=active :alice!alice@example.com TAGMSG bob'),
973
+ ]),
974
+ ]);
975
+ });
976
+
977
+ it('excludes the sender from the echo for a +draft/typing TAGMSG without echo-message', () => {
978
+ const conn = makeConn();
979
+ const ctx = makeCtx(conn);
980
+
981
+ const out = tagmsgUserReducer(
982
+ conn,
983
+ { command: 'TAGMSG', params: ['bob'], tags: { '+draft/typing': 'active' } },
984
+ ctx,
985
+ );
986
+
987
+ expect(out.effects).toHaveLength(1);
988
+ expect(out.effects[0]?.tag).toBe('SendToNick');
989
+ });
990
+
634
991
  it('updates lastSeen to ctx.clock.now()', () => {
635
992
  const conn = makeConn();
636
993
  const clock = new FakeClock(12_000);
@@ -0,0 +1,282 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { traceReducer } from '../../src/commands/trace';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const serverConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {
22
+ return buildCtx({
23
+ serverConfig,
24
+ clock,
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: conn,
28
+ });
29
+ }
30
+
31
+ function makeConn(id = 'c1', nick = 'alice', oper = false): ConnectionState {
32
+ const s = createConnection({ id, connectedSince: 0 });
33
+ s.nick = nick;
34
+ s.user = nick;
35
+ s.host = 'example.com';
36
+ s.realname = 'Alice';
37
+ s.registration = 'registered';
38
+ s.userModes.oper = oper;
39
+ return s;
40
+ }
41
+
42
+ function makeTarget(
43
+ id: string,
44
+ nick: string,
45
+ opts: { oper?: boolean; invisible?: boolean } = {},
46
+ ): ConnectionState {
47
+ const s = createConnection({ id, connectedSince: 0 });
48
+ s.nick = nick;
49
+ s.user = nick;
50
+ s.host = 'example.com';
51
+ s.realname = 'Real Name';
52
+ s.registration = 'registered';
53
+ if (opts.oper !== undefined) s.userModes.oper = opts.oper;
54
+ if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
55
+ return s;
56
+ }
57
+
58
+ const L = (text: string): RawLine => ({ text });
59
+
60
+ const trace = (target?: string) =>
61
+ ({
62
+ command: 'TRACE',
63
+ params: target === undefined ? [] : [target],
64
+ tags: {},
65
+ }) as const;
66
+
67
+ // ============================================================================
68
+ // traceReducer — no target
69
+ // ============================================================================
70
+
71
+ describe('traceReducer — no target', () => {
72
+ it('emits the 262 terminator with no middle when there is no target', () => {
73
+ const requester = makeConn();
74
+ const ctx = makeCtx(requester);
75
+
76
+ const out = traceReducer(undefined, trace(), ctx);
77
+
78
+ expect(out.effects).toEqual<EffectType[]>([
79
+ Effect.send('c1', [L(':irc.example.com 262 alice :End of TRACE')]),
80
+ ]);
81
+ });
82
+
83
+ it('omits any 200/204/205 detail line when there is no target', () => {
84
+ const requester = makeConn();
85
+ const ctx = makeCtx(requester);
86
+
87
+ const out = traceReducer(undefined, trace(), ctx);
88
+
89
+ const text = out.effects
90
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
91
+ .join('|');
92
+ expect(text).not.toContain(' 200 ');
93
+ expect(text).not.toContain(' 204 ');
94
+ expect(text).not.toContain(' 205 ');
95
+ });
96
+
97
+ it('updates the requester lastSeen to ctx.clock.now()', () => {
98
+ const requester = makeConn();
99
+ expect(requester.lastSeen).toBe(0);
100
+ const ctx = makeCtx(requester);
101
+
102
+ traceReducer(undefined, trace(), ctx);
103
+
104
+ expect(requester.lastSeen).toBe(5_000);
105
+ });
106
+
107
+ it('uses "*" as the nick when the requester has none', () => {
108
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
109
+ const ctx = makeCtx(requester);
110
+
111
+ const out = traceReducer(undefined, trace(), ctx);
112
+
113
+ expect(out.effects).toEqual<EffectType[]>([
114
+ Effect.send('c1', [L(':irc.example.com 262 * :End of TRACE')]),
115
+ ]);
116
+ });
117
+ });
118
+
119
+ // ============================================================================
120
+ // traceReducer — non-oper detail suppression
121
+ // ============================================================================
122
+
123
+ describe('traceReducer — non-oper suppression', () => {
124
+ it('emits only the 262 terminator for a non-oper TRACE <nick> (no leak)', () => {
125
+ const requester = makeConn('c1', 'alice', false);
126
+ const ctx = makeCtx(requester);
127
+ const target = makeTarget('c2', 'bob');
128
+
129
+ const out = traceReducer(target, trace('bob'), ctx);
130
+
131
+ expect(out.effects).toEqual<EffectType[]>([
132
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
133
+ ]);
134
+ });
135
+
136
+ it('does not emit a 205 detail line for a non-oper requester', () => {
137
+ const requester = makeConn('c1', 'alice', false);
138
+ const ctx = makeCtx(requester);
139
+ const target = makeTarget('c2', 'bob');
140
+
141
+ const out = traceReducer(target, trace('bob'), ctx);
142
+
143
+ const text = out.effects
144
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
145
+ .join('|');
146
+ expect(text).not.toContain(' 205 ');
147
+ expect(text).not.toContain(' 204 ');
148
+ });
149
+ });
150
+
151
+ // ============================================================================
152
+ // traceReducer — oper detail
153
+ // ============================================================================
154
+
155
+ describe('traceReducer — oper detail', () => {
156
+ it('emits a 205 RPL_TRACEUSER line for an oper tracing a normal user', () => {
157
+ const requester = makeConn('c1', 'alice', true);
158
+ const ctx = makeCtx(requester);
159
+ const target = makeTarget('c2', 'bob');
160
+
161
+ const out = traceReducer(target, trace('bob'), ctx);
162
+
163
+ expect(out.effects).toEqual<EffectType[]>([
164
+ Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),
165
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
166
+ ]);
167
+ });
168
+
169
+ it('emits a 204 RPL_TRACEOPERATOR line for an oper tracing another oper', () => {
170
+ const requester = makeConn('c1', 'alice', true);
171
+ const ctx = makeCtx(requester);
172
+ const target = makeTarget('c2', 'bob', { oper: true });
173
+
174
+ const out = traceReducer(target, trace('bob'), ctx);
175
+
176
+ expect(out.effects).toEqual<EffectType[]>([
177
+ Effect.send('c1', [L(':irc.example.com 204 alice Oper 0 bob bob example.com')]),
178
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
179
+ ]);
180
+ });
181
+
182
+ it('uses "*" nick fallbacks in 204/205 lines for an oper without a nick', () => {
183
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
184
+ requester.userModes.oper = true;
185
+ const ctx = makeCtx(requester);
186
+ const target = makeTarget('c2', 'bob');
187
+
188
+ const out = traceReducer(target, trace('bob'), ctx);
189
+
190
+ expect(out.effects[0]).toEqual<EffectType>(
191
+ Effect.send('c1', [L(':irc.example.com 205 * User 0 bob bob example.com')]),
192
+ );
193
+ });
194
+
195
+ it('uses "?" / "*" fallbacks in 205 line when target lacks user/host/nick', () => {
196
+ const requester = makeConn('c1', 'alice', true);
197
+ const ctx = makeCtx(requester);
198
+ const target = createConnection({ id: 'c2', connectedSince: 0 });
199
+ target.registration = 'registered';
200
+
201
+ const out = traceReducer(target, trace('ghost'), ctx);
202
+
203
+ expect(out.effects[0]).toEqual<EffectType>(
204
+ Effect.send('c1', [L(':irc.example.com 205 alice User 0 * ? ?')]),
205
+ );
206
+ });
207
+ });
208
+
209
+ // ============================================================================
210
+ // traceReducer — +i invisible filtering
211
+ // ============================================================================
212
+
213
+ describe('traceReducer — +i invisible filtering', () => {
214
+ it('omits detail for an invisible target a non-oper does not share a channel with', () => {
215
+ const requester = makeConn('c1', 'alice', false);
216
+ const ctx = makeCtx(requester);
217
+ const target = makeTarget('c2', 'bob', { invisible: true });
218
+
219
+ const out = traceReducer(target, trace('bob'), ctx);
220
+
221
+ expect(out.effects).toEqual<EffectType[]>([
222
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
223
+ ]);
224
+ });
225
+
226
+ it('shows a 205 line for an invisible target when the requester is an oper (oper bypasses +i)', () => {
227
+ const requester = makeConn('c1', 'alice', true);
228
+ const ctx = makeCtx(requester);
229
+ const target = makeTarget('c2', 'bob', { invisible: true });
230
+
231
+ const out = traceReducer(target, trace('bob'), ctx);
232
+
233
+ expect(out.effects[0]).toEqual<EffectType>(
234
+ Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),
235
+ );
236
+ });
237
+ });
238
+
239
+ // ============================================================================
240
+ // traceReducer — remote server target
241
+ // ============================================================================
242
+
243
+ describe('traceReducer — remote server target', () => {
244
+ it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {
245
+ const requester = makeConn('c1', 'alice', true);
246
+ const ctx = makeCtx(requester);
247
+
248
+ const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');
249
+
250
+ expect(out.effects).toEqual<EffectType[]>([
251
+ Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),
252
+ ]);
253
+ });
254
+
255
+ it('uses "*" nick in 402 when the requester has none', () => {
256
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
257
+ const ctx = makeCtx(requester);
258
+
259
+ const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');
260
+
261
+ expect(out.effects).toEqual<EffectType[]>([
262
+ Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),
263
+ ]);
264
+ });
265
+ });
266
+
267
+ // ============================================================================
268
+ // traceReducer — unknown nick (actor could not resolve)
269
+ // ============================================================================
270
+
271
+ describe('traceReducer — unknown nick', () => {
272
+ it('emits 262 with the requested nick when target resolves to undefined', () => {
273
+ const requester = makeConn('c1', 'alice', true);
274
+ const ctx = makeCtx(requester);
275
+
276
+ const out = traceReducer(undefined, trace('ghost'), ctx);
277
+
278
+ expect(out.effects).toEqual<EffectType[]>([
279
+ Effect.send('c1', [L(':irc.example.com 262 alice ghost :End of TRACE')]),
280
+ ]);
281
+ });
282
+ });