serverless-ircd 0.5.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 (167) 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 +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  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 +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  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/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -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);
@@ -166,6 +166,40 @@ describe('whoisReducer — happy path', () => {
166
166
  expect(send.lines.some((l) => l.text.endsWith(':is an IRC operator'))).toBe(true);
167
167
  }
168
168
  });
169
+
170
+ it('emits 276 RPL_WHOISSECURE when the target connected over TLS (user mode S)', () => {
171
+ const requester = makeConn();
172
+ const ctx = makeCtx(requester);
173
+ const target = makeTarget('c2', 'bob');
174
+ target.userModes.tls = true;
175
+
176
+ const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
177
+
178
+ const send = out.effects[0];
179
+ expect(send?.tag).toBe('Send');
180
+ if (send?.tag === 'Send') {
181
+ expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(true);
182
+ expect(
183
+ send.lines.some(
184
+ (l) => l.text === ':irc.example.com 276 alice bob :is using a secure connection',
185
+ ),
186
+ ).toBe(true);
187
+ }
188
+ });
189
+
190
+ it('does not emit 276 when the target is not on a secure connection', () => {
191
+ const requester = makeConn();
192
+ const ctx = makeCtx(requester);
193
+ const target = makeTarget('c2', 'bob');
194
+ // tls left at its default (false).
195
+
196
+ const out = whoisReducer(target, [], { command: 'WHOIS', params: ['bob'] }, ctx);
197
+
198
+ const send = out.effects[0];
199
+ if (send?.tag === 'Send') {
200
+ expect(send.lines.some((l) => l.text.includes(' 276 '))).toBe(false);
201
+ }
202
+ });
169
203
  });
170
204
 
171
205
  // ============================================================================
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  DEFAULT_CREATED_TEXT,
4
- DEFAULT_SERVER_CONFIG,
5
4
  DEFAULT_SERVER_VERSION,
6
5
  ServerConfigSchema,
7
6
  formatCreatedAt,
@@ -567,22 +566,34 @@ describe('ServerConfigSchema — failure diagnostics', () => {
567
566
  });
568
567
 
569
568
  // ============================================================================
570
- // ServerConfigSchema — defaults
569
+ // ServerConfigSchema — no placeholder serverName baseline
571
570
  // ============================================================================
572
571
 
573
- describe('ServerConfigSchema — DEFAULT_SERVER_CONFIG', () => {
574
- it('is a valid parsed config', () => {
575
- expect(DEFAULT_SERVER_CONFIG.serverName.length).toBeGreaterThan(0);
576
- expect(DEFAULT_SERVER_CONFIG.networkName.length).toBeGreaterThan(0);
577
- expect(Array.isArray(DEFAULT_SERVER_CONFIG.motdLines)).toBe(true);
578
- expect(DEFAULT_SERVER_CONFIG.channelPrefixes.length).toBeGreaterThan(0);
579
- expect(DEFAULT_SERVER_CONFIG.maxClients).toBeGreaterThan(0);
580
- expect(Array.isArray(DEFAULT_SERVER_CONFIG.operCreds)).toBe(true);
572
+ describe('ServerConfigSchema — required serverName', () => {
573
+ it('rejects an empty input with a serverName error (no placeholder fallback)', () => {
574
+ try {
575
+ parseServerConfig({});
576
+ expect.fail('expected parseServerConfig({}) to throw');
577
+ } catch (err) {
578
+ expect(err).toBeInstanceOf(Error);
579
+ const msg = (err as Error).message;
580
+ expect(msg).toMatch(/invalid server config/iu);
581
+ expect(msg).toMatch(/serverName/u);
582
+ }
581
583
  });
582
584
 
583
- it('round-trips through the schema unchanged', () => {
584
- const reparsed = parseServerConfig(DEFAULT_SERVER_CONFIG);
585
- expect(reparsed).toEqual(DEFAULT_SERVER_CONFIG);
585
+ it('rejects an empty-string serverName', () => {
586
+ expect(() => parseServerConfig({ serverName: '', networkName: 'n' })).toThrowError(
587
+ /serverName/u,
588
+ );
589
+ });
590
+
591
+ it('accepts a minimal {serverName, networkName} input', () => {
592
+ const cfg = parseServerConfig({ serverName: 'irc.test', networkName: 'TestNet' });
593
+ expect(cfg.serverName).toBe('irc.test');
594
+ expect(cfg.networkName).toBe('TestNet');
595
+ expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
596
+ expect(cfg.maxClients).toBeGreaterThan(0);
586
597
  });
587
598
  });
588
599
 
@@ -813,3 +824,82 @@ describe('ServerConfigSchema — perIpConnectionRate', () => {
813
824
  ).toThrowError(/perIpConnectionRate/u);
814
825
  });
815
826
  });
827
+
828
+ // ============================================================================
829
+ // ServerConfigSchema — sts (Strict Transport Security)
830
+ // ============================================================================
831
+
832
+ describe('ServerConfigSchema — sts', () => {
833
+ it('defaults sts to undefined when omitted (STS disabled)', () => {
834
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
835
+ expect(cfg.sts).toBeUndefined();
836
+ });
837
+
838
+ it('accepts a valid sts config with duration + port', () => {
839
+ const cfg = parseServerConfig({
840
+ serverName: 's',
841
+ networkName: 'n',
842
+ sts: { duration: 600, port: 6697 },
843
+ });
844
+ expect(cfg.sts).toEqual({ duration: 600, port: 6697 });
845
+ });
846
+
847
+ it('accepts an optional preload directive', () => {
848
+ const cfg = parseServerConfig({
849
+ serverName: 's',
850
+ networkName: 'n',
851
+ sts: { duration: 600, port: 6697, preload: true },
852
+ });
853
+ expect(cfg.sts?.preload).toBe(true);
854
+ });
855
+
856
+ it('rejects an sts config missing duration', () => {
857
+ expect(() =>
858
+ parseServerConfig({
859
+ serverName: 's',
860
+ networkName: 'n',
861
+ sts: { port: 6697 },
862
+ }),
863
+ ).toThrowError(/duration/u);
864
+ });
865
+
866
+ it('rejects an sts config missing port', () => {
867
+ expect(() =>
868
+ parseServerConfig({
869
+ serverName: 's',
870
+ networkName: 'n',
871
+ sts: { duration: 600 },
872
+ }),
873
+ ).toThrowError(/port/u);
874
+ });
875
+
876
+ it('rejects a zero duration', () => {
877
+ expect(() =>
878
+ parseServerConfig({
879
+ serverName: 's',
880
+ networkName: 'n',
881
+ sts: { duration: 0, port: 6697 },
882
+ }),
883
+ ).toThrowError(/duration/u);
884
+ });
885
+
886
+ it('rejects a zero port', () => {
887
+ expect(() =>
888
+ parseServerConfig({
889
+ serverName: 's',
890
+ networkName: 'n',
891
+ sts: { duration: 600, port: 0 },
892
+ }),
893
+ ).toThrowError(/port/u);
894
+ });
895
+
896
+ it('rejects a non-object sts value', () => {
897
+ expect(() =>
898
+ parseServerConfig({
899
+ serverName: 's',
900
+ networkName: 'n',
901
+ sts: 'secure',
902
+ }),
903
+ ).toThrowError(/sts/u);
904
+ });
905
+ });