serverless-ircd 0.10.0 → 0.11.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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AwayStore,
3
3
  CapturingLogger,
4
+ type CertIdentity,
4
5
  type ChanName,
5
6
  type ChanSnapshot,
6
7
  type ChannelDelta,
@@ -18,12 +19,14 @@ import {
18
19
  InMemoryServicesStore,
19
20
  LogLevel,
20
21
  type Logger,
22
+ MAX_TAGS,
21
23
  type MessageStore,
22
24
  type MonitorStore,
23
25
  type MtlsIdentityProvider,
24
26
  type Nick,
25
27
  type NickHistoryStore,
26
28
  Numerics,
29
+ OperFailureStats,
27
30
  type RawLine,
28
31
  SequentialIdFactory,
29
32
  type ServerConfig,
@@ -32,6 +35,7 @@ import {
32
35
  type ServicesStore,
33
36
  type StoredMessage,
34
37
  applyChannelDelta as applyDelta,
38
+ certIdentityFromSubject,
35
39
  createChannel,
36
40
  createConnection,
37
41
  encodeBase64,
@@ -113,6 +117,9 @@ class FakeRuntime implements IrcRuntime {
113
117
  async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
114
118
  this.calls.push({ method: 'broadcastWallops', args: [lines, except] });
115
119
  }
120
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
121
+ this.calls.push({ method: 'broadcastOperNotice', args: [lines, except] });
122
+ }
116
123
  async disconnect(conn: ConnId, reason?: string): Promise<void> {
117
124
  this.calls.push({ method: 'disconnect', args: [conn, reason] });
118
125
  }
@@ -226,6 +233,8 @@ function makeActor(opts: {
226
233
  monitor?: MonitorStore;
227
234
  away?: AwayStore;
228
235
  services?: ServicesStore;
236
+ operFailures?: OperFailureStats;
237
+ sasl?: ServerConfig['sasl'];
229
238
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
230
239
  const clock = opts.clock ?? new FakeClock(1_000);
231
240
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -252,6 +261,7 @@ function makeActor(opts: {
252
261
  quitMessage: 'Client Quit',
253
262
  ...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
254
263
  ...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
264
+ ...(opts.sasl !== undefined ? { sasl: opts.sasl } : {}),
255
265
  };
256
266
  const ids = new SequentialIdFactory();
257
267
  type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
@@ -303,6 +313,9 @@ function makeActor(opts: {
303
313
  if (opts.services !== undefined) {
304
314
  finalOpts.services = opts.services;
305
315
  }
316
+ if (opts.operFailures !== undefined) {
317
+ finalOpts.operFailures = opts.operFailures;
318
+ }
306
319
  const actor = new ConnectionActor(finalOpts);
307
320
  return { actor, runtime, conn };
308
321
  }
@@ -581,6 +594,27 @@ describe('ConnectionActor — malformed input', () => {
581
594
  expect(firstLines[0]?.text).toContain('BOGUS');
582
595
  });
583
596
 
597
+ it('emits 421 with the too-many-tags reason for a 16-tag frame and continues', async () => {
598
+ const logger = new CapturingLogger();
599
+ const { actor, runtime } = makeActor({ logger });
600
+ const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
601
+ await actor.receiveTextFrame(`@${tags.join(';')} PRIVMSG #foo :hi\r\nPING :tok\r\n`);
602
+
603
+ // Parse-error log carries the stable reason code, never the raw line
604
+ // (the tag section is attacker-controlled payload).
605
+ const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
606
+ expect(errs).toHaveLength(1);
607
+ expect(errs[0]?.fields?.reason).toBe('too-many-tags');
608
+ expect(JSON.stringify(errs[0])).not.toContain('PRIVMSG');
609
+ // Connection continues: 421 for the offending frame, PONG for the next.
610
+ const sends = runtime.calls.filter((c) => c.method === 'send');
611
+ expect(sends).toHaveLength(2);
612
+ const firstLines = sends[0]?.args[1] as RawLine[];
613
+ expect(firstLines[0]?.text).toContain('421');
614
+ const secondLines = sends[1]?.args[1] as RawLine[];
615
+ expect(secondLines[0]?.text).toBe('PONG :tok');
616
+ });
617
+
584
618
  it('does not crash when the frame is an empty string', async () => {
585
619
  const { actor, runtime } = makeActor({});
586
620
  await expect(actor.receiveTextFrame('')).resolves.toBeUndefined();
@@ -643,6 +677,147 @@ describe('ConnectionActor — routing', () => {
643
677
  });
644
678
  });
645
679
 
680
+ describe('ConnectionActor — maxTargetsPerCommand cap on multi-target commands', () => {
681
+ // makeActor() configures serverConfig.maxTargetsPerCommand = 10.
682
+ const CAP = 10;
683
+ const OVER = 27;
684
+
685
+ /** Builds a comma-separated target list of `count` `<prefix><NN>` names. */
686
+ function csv(count: number, prefix: string): string {
687
+ return Array.from(
688
+ { length: count },
689
+ (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`,
690
+ ).join(',');
691
+ }
692
+
693
+ /** Same list, space-separated (USERHOST/ISON take separate params). */
694
+ function spaced(count: number, prefix: string): string {
695
+ return csv(count, prefix).split(',').join(' ');
696
+ }
697
+
698
+ /** Collects the `send` payloads whose lines carry the 407 numeric. */
699
+ function tooManyTargetSends(runtime: FakeRuntime): RawLine[][] {
700
+ return runtime.calls
701
+ .filter(
702
+ (c) =>
703
+ c.method === 'send' && (c.args[1] as RawLine[]).some((l) => l.text.includes(' 407 ')),
704
+ )
705
+ .map((c) => c.args[1] as RawLine[]);
706
+ }
707
+
708
+ it('caps multi-target PRIVMSG at maxTargetsPerCommand and 407s the dropped targets', async () => {
709
+ // Flood control disabled: the default token bucket would otherwise
710
+ // silently drop per-target reducer calls past its capacity, masking
711
+ // the target cap under test.
712
+ const { actor, runtime } = makeActor({ floodControl: null });
713
+ runtime.calls.length = 0;
714
+
715
+ await actor.receiveTextFrame(`PRIVMSG ${csv(OVER, 'n')} :hi\r\n`);
716
+
717
+ // Only the first CAP targets fan out.
718
+ const sendsToNick = runtime.calls.filter((c) => c.method === 'sendToNick');
719
+ expect(sendsToNick).toHaveLength(CAP);
720
+ expect(sendsToNick[0]?.args[0]).toBe('n01');
721
+ expect(sendsToNick[CAP - 1]?.args[0]).toBe('n10');
722
+
723
+ // Each dropped target gets a dedicated 407 ERR_TOOMANYTARGETS line.
724
+ const drops = tooManyTargetSends(runtime);
725
+ expect(drops).toHaveLength(OVER - CAP);
726
+ expect(drops[0]?.[0]?.text).toContain(' 407 alice n11 ');
727
+ expect(drops[0]?.[0]?.text).toContain(':Too many targets');
728
+ expect(drops[OVER - CAP - 1]?.[0]?.text).toContain('n27');
729
+ });
730
+
731
+ it('multi-target PRIVMSG below the cap is unaffected (no 407)', async () => {
732
+ const { actor, runtime } = makeActor({ floodControl: null });
733
+ runtime.calls.length = 0;
734
+
735
+ await actor.receiveTextFrame('PRIVMSG a,b,c :hi\r\n');
736
+
737
+ expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(3);
738
+ expect(tooManyTargetSends(runtime)).toHaveLength(0);
739
+ });
740
+
741
+ it('caps multi-target NOTICE at maxTargetsPerCommand and 407s the dropped targets', async () => {
742
+ const { actor, runtime } = makeActor({ floodControl: null });
743
+ runtime.calls.length = 0;
744
+
745
+ await actor.receiveTextFrame(`NOTICE ${csv(OVER, 'n')} :hi\r\n`);
746
+
747
+ expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(CAP);
748
+ const drops = tooManyTargetSends(runtime);
749
+ expect(drops).toHaveLength(OVER - CAP);
750
+ expect(drops[0]?.[0]?.text).toContain(' 407 alice n11 ');
751
+ });
752
+
753
+ it('caps multi-target TAGMSG at maxTargetsPerCommand and 407s the dropped targets', async () => {
754
+ const { actor, runtime } = makeActor({
755
+ floodControl: null,
756
+ conn: { caps: new Set<string>(['message-tags']) },
757
+ });
758
+ runtime.calls.length = 0;
759
+
760
+ await actor.receiveTextFrame(`@+typing=active TAGMSG ${csv(OVER, 'n')}\r\n`);
761
+
762
+ expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(CAP);
763
+ expect(tooManyTargetSends(runtime)).toHaveLength(OVER - CAP);
764
+ });
765
+
766
+ it('caps multi-channel PART at maxTargetsPerCommand and 407s the dropped channels', async () => {
767
+ const { actor, runtime } = makeActor({ floodControl: null });
768
+ for (let i = 1; i <= OVER; i++) {
769
+ const name = `#c${String(i).padStart(2, '0')}` as ChanName;
770
+ await runtime.applyChannelDelta(name, {
771
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
772
+ });
773
+ }
774
+ runtime.calls.length = 0;
775
+
776
+ await actor.receiveTextFrame(`PART ${csv(OVER, '#c')} :bye\r\n`);
777
+
778
+ // Only the first CAP parts apply.
779
+ const parts = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
780
+ expect(parts).toHaveLength(CAP);
781
+ const drops = tooManyTargetSends(runtime);
782
+ expect(drops).toHaveLength(OVER - CAP);
783
+ expect(drops[0]?.[0]?.text).toContain(' 407 alice #c11 ');
784
+ });
785
+
786
+ it('407s dropped targets with the * placeholder when the connection has no nick yet', async () => {
787
+ const { actor, runtime, conn } = makeActor({ floodControl: null });
788
+ conn.registration = 'pre-registration';
789
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
790
+ delete conn.nick;
791
+ runtime.calls.length = 0;
792
+
793
+ await actor.receiveTextFrame(`NOTICE ${csv(OVER, 'n')} :hi\r\n`);
794
+
795
+ const drops = tooManyTargetSends(runtime);
796
+ expect(drops).toHaveLength(OVER - CAP);
797
+ expect(drops[0]?.[0]?.text).toContain(' 407 * n11 ');
798
+ });
799
+
800
+ it('USERHOST still resolves at most maxTargetsPerCommand nicks (regression guard)', async () => {
801
+ const { actor, runtime } = makeActor({ floodControl: null });
802
+ runtime.calls.length = 0;
803
+
804
+ await actor.receiveTextFrame(`USERHOST ${spaced(OVER, 'n')}\r\n`);
805
+
806
+ expect(runtime.calls.filter((c) => c.method === 'lookupNick')).toHaveLength(CAP);
807
+ expect(tooManyTargetSends(runtime)).toHaveLength(0);
808
+ });
809
+
810
+ it('ISON still resolves at most maxTargetsPerCommand nicks (regression guard)', async () => {
811
+ const { actor, runtime } = makeActor({ floodControl: null });
812
+ runtime.calls.length = 0;
813
+
814
+ await actor.receiveTextFrame(`ISON ${spaced(OVER, 'n')}\r\n`);
815
+
816
+ expect(runtime.calls.filter((c) => c.method === 'lookupNick')).toHaveLength(CAP);
817
+ expect(tooManyTargetSends(runtime)).toHaveLength(0);
818
+ });
819
+ });
820
+
646
821
  describe('ConnectionActor — secure transport (user mode `S`)', () => {
647
822
  it('marks the connection state secure when constructed with secure: true', () => {
648
823
  const { conn } = makeActor({ secure: true });
@@ -1276,6 +1451,53 @@ describe('ConnectionActor — additional routing', () => {
1276
1451
  }
1277
1452
  });
1278
1453
 
1454
+ it('rejects pre-registration OPER with 451 ERR_NOTREGISTERED without granting oper', async () => {
1455
+ const { actor, runtime, conn } = makeActor({
1456
+ conn: { registration: 'pre-registration' },
1457
+ operCreds: [{ user: 'alice', password: 'secret' }],
1458
+ });
1459
+ // Strip the registration handshake fields: a genuine pre-registration
1460
+ // client has no nick/user yet. Object.assign is the exactOptionalPropertyTypes
1461
+ // escape hatch (a `nick: undefined` literal or `delete` is rejected).
1462
+ Object.assign(conn, { nick: undefined, user: undefined });
1463
+ runtime.calls.length = 0;
1464
+
1465
+ // Valid credentials: without the gate the reducer would grant oper here.
1466
+ await actor.receiveTextFrame('OPER alice secret\r\n');
1467
+
1468
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1469
+ expect(sends).toHaveLength(1);
1470
+ const lines = sends[0]?.args[1] as RawLine[];
1471
+ expect(lines[0]?.text).toBe(':irc.example.com 451 * :You have not registered');
1472
+ expect(conn.userModes.oper).toBe(false);
1473
+ });
1474
+
1475
+ it('grants oper with 381 once the same connection completes registration after a gated attempt', async () => {
1476
+ const { actor, runtime, conn } = makeActor({
1477
+ conn: { registration: 'pre-registration' },
1478
+ operCreds: [{ user: 'alice', password: 'secret' }],
1479
+ });
1480
+ Object.assign(conn, { nick: undefined, user: undefined });
1481
+ runtime.calls.length = 0;
1482
+
1483
+ await actor.receiveTextFrame('OPER alice secret\r\n');
1484
+ expect(conn.userModes.oper).toBe(false);
1485
+
1486
+ // Complete the handshake; the gate must not stick.
1487
+ await actor.receiveTextFrame('NICK alice\r\n');
1488
+ await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
1489
+ expect(conn.registration).toBe('registered');
1490
+ runtime.calls.length = 0;
1491
+
1492
+ await actor.receiveTextFrame('OPER alice secret\r\n');
1493
+
1494
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1495
+ expect(sends).toHaveLength(2);
1496
+ const firstLines = sends[0]?.args[1] as RawLine[];
1497
+ expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
1498
+ expect(conn.userModes.oper).toBe(true);
1499
+ });
1500
+
1279
1501
  it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
1280
1502
  const { actor, runtime } = makeActor({});
1281
1503
  await actor.receiveTextFrame('TOPIC\r\n');
@@ -1379,6 +1601,56 @@ describe('ConnectionActor — additional routing', () => {
1379
1601
  }
1380
1602
  });
1381
1603
 
1604
+ it('threads the shared per-IP OPER failure tracker: 3 failures then 491 + broadcastOperNotice', async () => {
1605
+ const clock = new FakeClock(1_000);
1606
+ const tracker = new OperFailureStats(clock);
1607
+ const { actor, runtime } = makeActor({
1608
+ clock,
1609
+ operCreds: [{ user: 'alice', password: 'secret' }],
1610
+ operFailures: tracker,
1611
+ });
1612
+
1613
+ for (let i = 0; i < 3; i++) {
1614
+ runtime.calls.length = 0;
1615
+ await actor.receiveTextFrame(`OPER alice :wrong-${i}\r\n`);
1616
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
1617
+ expect(sends).toHaveLength(1);
1618
+ expect((sends[0]?.args[1] as RawLine[])[0]?.text).toContain(' 464 ');
1619
+ // No oper notice until the lockout actually trips.
1620
+ expect(runtime.calls.some((c) => c.method === 'broadcastOperNotice')).toBe(false);
1621
+ }
1622
+
1623
+ runtime.calls.length = 0;
1624
+ await actor.receiveTextFrame('OPER alice :secret\r\n');
1625
+
1626
+ // Locked out: 491 to the connection (never 464 — the credential store
1627
+ // was not consulted) and one oper-notice fanout to every +o connection.
1628
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
1629
+ expect(sends).toHaveLength(1);
1630
+ expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe(
1631
+ ':irc.example.com 491 alice :No O-lines for your host',
1632
+ );
1633
+ const notices = runtime.calls.filter((c) => c.method === 'broadcastOperNotice');
1634
+ expect(notices).toHaveLength(1);
1635
+ const lines = notices[0]?.args[0] as RawLine[];
1636
+ expect(lines[0]?.text).toBe(
1637
+ ':irc.example.com NOTICE * :OPER lockout triggered for example.com',
1638
+ );
1639
+ });
1640
+
1641
+ it('keeps OPER unlocked when no tracker is bound to the actor', async () => {
1642
+ const { actor, runtime } = makeActor({
1643
+ operCreds: [{ user: 'alice', password: 'secret' }],
1644
+ });
1645
+ for (let i = 0; i < 5; i++) {
1646
+ await actor.receiveTextFrame('OPER alice :wrong\r\n');
1647
+ }
1648
+ runtime.calls.length = 0;
1649
+ await actor.receiveTextFrame('OPER alice :secret\r\n');
1650
+ const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
1651
+ expect((sends[0]?.args[1] as RawLine[])[0]?.text).toContain(' 381 ');
1652
+ });
1653
+
1382
1654
  it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
1383
1655
  const { actor, runtime, conn } = makeActor({
1384
1656
  conn: {
@@ -1892,7 +2164,7 @@ describe('ConnectionActor — observability', () => {
1892
2164
  }
1893
2165
  });
1894
2166
 
1895
- it('emits a warn-level frame.parse-error record on a malformed line', async () => {
2167
+ it('emits a warn-level frame.parse-error record with token/length/reason', async () => {
1896
2168
  const logger = new CapturingLogger();
1897
2169
  const { actor } = makeActor({ logger, traceId: 't1' });
1898
2170
 
@@ -1903,7 +2175,53 @@ describe('ConnectionActor — observability', () => {
1903
2175
  expect(errs).toHaveLength(1);
1904
2176
  expect(errs[0]?.level).toBe(LogLevel.Warn);
1905
2177
  expect(errs[0]?.traceId).toBe('t1');
1906
- expect(typeof errs[0]?.fields?.line).toBe('string');
2178
+ expect(errs[0]?.fields?.token).toBe('12345');
2179
+ expect(errs[0]?.fields?.length).toBe('12345'.length);
2180
+ expect(errs[0]?.fields?.reason).toBe('invalid-command-token');
2181
+ });
2182
+
2183
+ it('never includes the raw line in a frame.parse-error record', async () => {
2184
+ const logger = new CapturingLogger();
2185
+ const { actor } = makeActor({ logger, traceId: 't2' });
2186
+
2187
+ // The 5-digit command trips the parser while the trailing PASS
2188
+ // payload carries a credential: a hostile client can smuggle secret
2189
+ // material into the warn record unless the payload is scrubbed. The
2190
+ // record must carry no `line` field and no trace of the secret.
2191
+ await actor.receiveTextFrame('12345 PASS hunter2\r\n');
2192
+
2193
+ const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
2194
+ expect(errs).toHaveLength(1);
2195
+ expect(errs[0]?.fields?.line).toBeUndefined();
2196
+ expect(JSON.stringify(errs[0])).not.toContain('hunter2');
2197
+ });
2198
+
2199
+ it('truncates an over-long leading token in a parse-error record', async () => {
2200
+ const logger = new CapturingLogger();
2201
+ const { actor } = makeActor({ logger, traceId: 't3' });
2202
+
2203
+ // 32 letters + a digit is an illegal command token (mixed) AND the
2204
+ // 33-char token exceeds the 32-char log cap.
2205
+ await actor.receiveTextFrame(`${'X'.repeat(32)}9 PASS hunter2\r\n`);
2206
+
2207
+ const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
2208
+ expect(errs).toHaveLength(1);
2209
+ expect(errs[0]?.fields?.token).toBe(`${'X'.repeat(32)}...`);
2210
+ expect(JSON.stringify(errs[0])).not.toContain('hunter2');
2211
+ });
2212
+
2213
+ it('reports the input-cap reason for an over-long line', async () => {
2214
+ const logger = new CapturingLogger();
2215
+ const { actor } = makeActor({ logger, traceId: 't4' });
2216
+
2217
+ // 600 bytes exceeds the 512-byte input cap → the byte-cap branch of
2218
+ // the parser fires before the command-token check.
2219
+ await actor.receiveTextFrame(`${'A'.repeat(600)}\r\n`);
2220
+
2221
+ const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
2222
+ expect(errs).toHaveLength(1);
2223
+ expect(errs[0]?.fields?.reason).toBe('input-cap-exceeded');
2224
+ expect(errs[0]?.fields?.length).toBe(600);
1907
2225
  });
1908
2226
 
1909
2227
  it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
@@ -2204,10 +2522,10 @@ describe('ConnectionActor — SASL ServicesStore plumbing', () => {
2204
2522
  });
2205
2523
 
2206
2524
  describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2207
- /** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
2525
+ /** Fake MtlsIdentityProvider returning a canned cert identity for 'c1'. */
2208
2526
  class FakeMtlsProvider implements MtlsIdentityProvider {
2209
- constructor(private readonly identity: string | undefined) {}
2210
- getIdentity(connId: string): string | undefined {
2527
+ constructor(private readonly identity: CertIdentity | undefined) {}
2528
+ getIdentity(connId: string): CertIdentity | undefined {
2211
2529
  return connId === 'c1' ? this.identity : undefined;
2212
2530
  }
2213
2531
  }
@@ -2220,15 +2538,19 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2220
2538
  };
2221
2539
  }
2222
2540
 
2223
- it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
2541
+ it('completes SASL EXTERNAL with 900/903 when mTLS provider, operator opt-in, secure transport, and store accept', async () => {
2224
2542
  const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2225
2543
  services.registerNick('alice', 'secret', 'alice@example.com');
2226
2544
  services.addCertFP('alice', 'CN=alice');
2227
- const mtlsIdentity = new FakeMtlsProvider('CN=alice');
2545
+ const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
2228
2546
  const { actor, runtime, conn } = makeActor({
2229
2547
  conn: saslReadyConn(),
2230
2548
  services,
2231
2549
  mtlsIdentity,
2550
+ // The EXTERNAL triple gate: mTLS provider bound (above) +
2551
+ // operator opt-in + a secure (TLS) connection.
2552
+ sasl: { externalEnabled: true },
2553
+ secure: true,
2232
2554
  });
2233
2555
  conn.nick = 'alice';
2234
2556
  runtime.calls.length = 0;
@@ -2264,6 +2586,31 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2264
2586
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2265
2587
  expect(conn.account).toBeUndefined();
2266
2588
  });
2589
+
2590
+ it('answers 908 when the operator opt-in is missing even with mTLS and a secure connection', async () => {
2591
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2592
+ services.registerNick('alice', 'secret', 'alice@example.com');
2593
+ services.addCertFP('alice', 'CN=alice');
2594
+ const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
2595
+ // mTLS provider bound + secure connection, but sasl.externalEnabled
2596
+ // left at the default (off) → EXTERNAL is refused with 908.
2597
+ const { actor, runtime, conn } = makeActor({
2598
+ conn: saslReadyConn(),
2599
+ services,
2600
+ mtlsIdentity,
2601
+ secure: true,
2602
+ });
2603
+ conn.nick = 'alice';
2604
+ runtime.calls.length = 0;
2605
+
2606
+ await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
2607
+
2608
+ const sends = runtime.calls.filter((c) => c.method === 'send');
2609
+ const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
2610
+ expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
2611
+ expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2612
+ expect(conn.account).toBeUndefined();
2613
+ });
2267
2614
  });
2268
2615
 
2269
2616
  describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
@@ -3971,6 +4318,196 @@ describe('ConnectionActor — draft/multiline BATCH', () => {
3971
4318
  ).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
3972
4319
  expect(result).toEqual([]);
3973
4320
  });
4321
+
4322
+ it('aborts the batch mid-stream when an inner line pushes it over the byte budget (no close needed)', async () => {
4323
+ const { actor, runtime, conn } = makeActor({});
4324
+ conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
4325
+ // Tiny budget: 6 bytes + separator + 6 bytes = 13 > 10 on the second push.
4326
+ (actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
4327
+ ...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
4328
+ multilineMaxBytes: 10,
4329
+ };
4330
+ await setupMultilineChannel(runtime, [
4331
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4332
+ { conn: 'c2', nick: 'bob' },
4333
+ ]);
4334
+ runtime.calls.length = 0;
4335
+
4336
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4337
+ await actor.receiveTextFrame('PRIVMSG #foo :aaaaaa\r\n');
4338
+ // No `BATCH -m1` close — the over-budget push itself must abort the batch.
4339
+ await actor.receiveTextFrame('PRIVMSG #foo :bbbbbb\r\n');
4340
+
4341
+ const aliceLines = sentTo(runtime, 'c1');
4342
+ // A synthesized `BATCH -m1` closes the aborted batch client-side...
4343
+ expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
4344
+ // ...followed by the FAIL rejection.
4345
+ expect(
4346
+ aliceLines.some(
4347
+ (l) =>
4348
+ l.text ===
4349
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
4350
+ ),
4351
+ ).toBe(true);
4352
+ // Nothing was relayed to the channel.
4353
+ expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
4354
+ // The accumulator is gone: a late `BATCH -m1` close hits the unknown-id 421 path.
4355
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4356
+ expect(sentTo(runtime, 'c1').some((l) => l.text.includes(' 421 '))).toBe(true);
4357
+ });
4358
+
4359
+ it('accepts an inner line that lands exactly at the byte budget (boundary is inclusive)', async () => {
4360
+ const { actor, runtime, conn } = makeActor({});
4361
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4362
+ // Budget 13: 6 bytes + separator + 6 bytes = 13 → accepted.
4363
+ (actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
4364
+ ...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
4365
+ multilineMaxBytes: 13,
4366
+ };
4367
+ await setupMultilineChannel(runtime, [
4368
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4369
+ { conn: 'c2', nick: 'bob' },
4370
+ ]);
4371
+ runtime.calls.length = 0;
4372
+
4373
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4374
+ await actor.receiveTextFrame('PRIVMSG #foo :aaaaaa\r\n');
4375
+ await actor.receiveTextFrame('PRIVMSG #foo :cccccc\r\n');
4376
+ expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
4377
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4378
+
4379
+ // The at-budget batch finalizes normally: bob gets the joined PRIVMSG.
4380
+ const bobLines = sentTo(runtime, 'c2');
4381
+ expect(
4382
+ bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :aaaaaa\ncccccc'),
4383
+ ).toBe(true);
4384
+ });
4385
+
4386
+ it('rejects the 101st inner line by entry count even when under the byte budget', async () => {
4387
+ const { actor, runtime, conn } = makeActor({});
4388
+ conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
4389
+ await setupMultilineChannel(runtime, [
4390
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4391
+ { conn: 'c2', nick: 'bob' },
4392
+ ]);
4393
+ runtime.calls.length = 0;
4394
+
4395
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4396
+ // 100 single-byte entries: joined bytes 199 < 4096, all accepted.
4397
+ await actor.receiveTextFrame('PRIVMSG #foo :x\r\n'.repeat(100));
4398
+ expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
4399
+
4400
+ // The 101st push trips the defense-in-depth entry cap.
4401
+ await actor.receiveTextFrame('PRIVMSG #foo :x\r\n');
4402
+ const aliceLines = sentTo(runtime, 'c1');
4403
+ expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
4404
+ expect(
4405
+ aliceLines.some(
4406
+ (l) =>
4407
+ l.text ===
4408
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_LINES #foo :Multi-line batch exceeds the 100 line limit',
4409
+ ),
4410
+ ).toBe(true);
4411
+
4412
+ // The accumulator is gone: a normal PRIVMSG now delivers instead of
4413
+ // accumulating into the aborted batch.
4414
+ runtime.calls.length = 0;
4415
+ await actor.receiveTextFrame('PRIVMSG #foo :plain\r\n');
4416
+ expect(
4417
+ sentTo(runtime, 'c2').some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :plain'),
4418
+ ).toBe(true);
4419
+ });
4420
+
4421
+ it('honours a configured multilineMaxEntries override', async () => {
4422
+ const { actor, runtime, conn } = makeActor({});
4423
+ conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
4424
+ (actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
4425
+ ...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
4426
+ multilineMaxEntries: 2,
4427
+ };
4428
+ await setupMultilineChannel(runtime, [
4429
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4430
+ ]);
4431
+ runtime.calls.length = 0;
4432
+
4433
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4434
+ await actor.receiveTextFrame('PRIVMSG #foo :one\r\n');
4435
+ await actor.receiveTextFrame('PRIVMSG #foo :two\r\n');
4436
+ await actor.receiveTextFrame('PRIVMSG #foo :three\r\n');
4437
+
4438
+ const aliceLines = sentTo(runtime, 'c1');
4439
+ expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
4440
+ expect(
4441
+ aliceLines.some(
4442
+ (l) =>
4443
+ l.text ===
4444
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_LINES #foo :Multi-line batch exceeds the 2 line limit',
4445
+ ),
4446
+ ).toBe(true);
4447
+ });
4448
+
4449
+ it('clears an abandoned open batch on connection teardown (dispose)', async () => {
4450
+ const { actor, runtime, conn } = makeActor({});
4451
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4452
+ await setupMultilineChannel(runtime, [
4453
+ { conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
4454
+ { conn: 'c2', nick: 'bob' },
4455
+ ]);
4456
+ runtime.calls.length = 0;
4457
+
4458
+ await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
4459
+ await actor.receiveTextFrame('PRIVMSG #foo :partial\r\n');
4460
+ expect((actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch).toBeDefined();
4461
+
4462
+ actor.dispose();
4463
+
4464
+ expect(
4465
+ (actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch,
4466
+ ).toBeUndefined();
4467
+ // Post-teardown traffic delivers normally — nothing leaks into a stale batch.
4468
+ await actor.receiveTextFrame('PRIVMSG #foo :after\r\n');
4469
+ expect(
4470
+ sentTo(runtime, 'c2').some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :after'),
4471
+ ).toBe(true);
4472
+ });
4473
+
4474
+ it('dispose() is a safe no-op when no batch is open', () => {
4475
+ const { actor } = makeActor({});
4476
+ expect(() => actor.dispose()).not.toThrow();
4477
+ expect(
4478
+ (actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch,
4479
+ ).toBeUndefined();
4480
+ });
4481
+
4482
+ it('nick-target close still rejects an over-budget batch (close-time guard survives the incremental check)', async () => {
4483
+ const { actor, runtime, conn } = makeActor({});
4484
+ conn.caps = new Set<string>(['batch', 'draft/multiline']);
4485
+ (actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
4486
+ ...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
4487
+ multilineMaxBytes: 5,
4488
+ };
4489
+ // The incremental accumulator aborts over-budget pushes before close, so
4490
+ // the close-time guard only fires when the budget shrinks under an open
4491
+ // batch (e.g. a REHASH between BATCH +id and BATCH -id). Inject the
4492
+ // over-budget batch directly and close it through the real route.
4493
+ (actor as unknown as { openMultilineBatch: unknown }).openMultilineBatch = {
4494
+ id: 'm1',
4495
+ target: 'bob',
4496
+ command: 'PRIVMSG',
4497
+ entries: [{ text: 'aaaaaaaaaa' }],
4498
+ };
4499
+ runtime.calls.length = 0;
4500
+
4501
+ await actor.receiveTextFrame('BATCH -m1\r\n');
4502
+
4503
+ expect(
4504
+ sentTo(runtime, 'c1').some(
4505
+ (l) =>
4506
+ l.text ===
4507
+ ':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES bob :Multi-line batch exceeds the 5 byte limit',
4508
+ ),
4509
+ ).toBe(true);
4510
+ });
3974
4511
  });
3975
4512
 
3976
4513
  // ---------------------------------------------------------------------------