serverless-ircd 0.9.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -1,7 +1,7 @@
1
1
  import {
2
- type AccountStore,
3
2
  type AwayStore,
4
3
  CapturingLogger,
4
+ type CertIdentity,
5
5
  type ChanName,
6
6
  type ChanSnapshot,
7
7
  type ChannelDelta,
@@ -19,15 +19,15 @@ import {
19
19
  InMemoryServicesStore,
20
20
  LogLevel,
21
21
  type Logger,
22
+ MAX_TAGS,
22
23
  type MessageStore,
23
24
  type MonitorStore,
24
25
  type MtlsIdentityProvider,
25
26
  type Nick,
26
27
  type NickHistoryStore,
27
28
  Numerics,
29
+ OperFailureStats,
28
30
  type RawLine,
29
- type SaslPayload,
30
- type SaslResult,
31
31
  SequentialIdFactory,
32
32
  type ServerConfig,
33
33
  type ServerStats,
@@ -35,6 +35,7 @@ import {
35
35
  type ServicesStore,
36
36
  type StoredMessage,
37
37
  applyChannelDelta as applyDelta,
38
+ certIdentityFromSubject,
38
39
  createChannel,
39
40
  createConnection,
40
41
  encodeBase64,
@@ -116,6 +117,9 @@ class FakeRuntime implements IrcRuntime {
116
117
  async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
117
118
  this.calls.push({ method: 'broadcastWallops', args: [lines, except] });
118
119
  }
120
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
121
+ this.calls.push({ method: 'broadcastOperNotice', args: [lines, except] });
122
+ }
119
123
  async disconnect(conn: ConnId, reason?: string): Promise<void> {
120
124
  this.calls.push({ method: 'disconnect', args: [conn, reason] });
121
125
  }
@@ -219,7 +223,6 @@ function makeActor(opts: {
219
223
  logger?: Logger;
220
224
  traceId?: string;
221
225
  messages?: MessageStore;
222
- accounts?: AccountStore;
223
226
  mtlsIdentity?: MtlsIdentityProvider;
224
227
  history?: NickHistoryStore;
225
228
  stats?: ServerStats;
@@ -230,6 +233,8 @@ function makeActor(opts: {
230
233
  monitor?: MonitorStore;
231
234
  away?: AwayStore;
232
235
  services?: ServicesStore;
236
+ operFailures?: OperFailureStats;
237
+ sasl?: ServerConfig['sasl'];
233
238
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
234
239
  const clock = opts.clock ?? new FakeClock(1_000);
235
240
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -256,6 +261,7 @@ function makeActor(opts: {
256
261
  quitMessage: 'Client Quit',
257
262
  ...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
258
263
  ...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
264
+ ...(opts.sasl !== undefined ? { sasl: opts.sasl } : {}),
259
265
  };
260
266
  const ids = new SequentialIdFactory();
261
267
  type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
@@ -280,9 +286,6 @@ function makeActor(opts: {
280
286
  if (opts.messages !== undefined) {
281
287
  finalOpts.messages = opts.messages;
282
288
  }
283
- if (opts.accounts !== undefined) {
284
- finalOpts.accounts = opts.accounts;
285
- }
286
289
  if (opts.mtlsIdentity !== undefined) {
287
290
  finalOpts.mtlsIdentity = opts.mtlsIdentity;
288
291
  }
@@ -310,6 +313,9 @@ function makeActor(opts: {
310
313
  if (opts.services !== undefined) {
311
314
  finalOpts.services = opts.services;
312
315
  }
316
+ if (opts.operFailures !== undefined) {
317
+ finalOpts.operFailures = opts.operFailures;
318
+ }
313
319
  const actor = new ConnectionActor(finalOpts);
314
320
  return { actor, runtime, conn };
315
321
  }
@@ -588,6 +594,27 @@ describe('ConnectionActor — malformed input', () => {
588
594
  expect(firstLines[0]?.text).toContain('BOGUS');
589
595
  });
590
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
+
591
618
  it('does not crash when the frame is an empty string', async () => {
592
619
  const { actor, runtime } = makeActor({});
593
620
  await expect(actor.receiveTextFrame('')).resolves.toBeUndefined();
@@ -650,6 +677,147 @@ describe('ConnectionActor — routing', () => {
650
677
  });
651
678
  });
652
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
+
653
821
  describe('ConnectionActor — secure transport (user mode `S`)', () => {
654
822
  it('marks the connection state secure when constructed with secure: true', () => {
655
823
  const { conn } = makeActor({ secure: true });
@@ -1283,6 +1451,53 @@ describe('ConnectionActor — additional routing', () => {
1283
1451
  }
1284
1452
  });
1285
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
+
1286
1501
  it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
1287
1502
  const { actor, runtime } = makeActor({});
1288
1503
  await actor.receiveTextFrame('TOPIC\r\n');
@@ -1386,6 +1601,56 @@ describe('ConnectionActor — additional routing', () => {
1386
1601
  }
1387
1602
  });
1388
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
+
1389
1654
  it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
1390
1655
  const { actor, runtime, conn } = makeActor({
1391
1656
  conn: {
@@ -1899,7 +2164,7 @@ describe('ConnectionActor — observability', () => {
1899
2164
  }
1900
2165
  });
1901
2166
 
1902
- 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 () => {
1903
2168
  const logger = new CapturingLogger();
1904
2169
  const { actor } = makeActor({ logger, traceId: 't1' });
1905
2170
 
@@ -1910,7 +2175,53 @@ describe('ConnectionActor — observability', () => {
1910
2175
  expect(errs).toHaveLength(1);
1911
2176
  expect(errs[0]?.level).toBe(LogLevel.Warn);
1912
2177
  expect(errs[0]?.traceId).toBe('t1');
1913
- 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);
1914
2225
  });
1915
2226
 
1916
2227
  it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
@@ -2129,17 +2440,7 @@ describe('ConnectionActor — CHATHISTORY', () => {
2129
2440
  // SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
2130
2441
  // ===========================================================================
2131
2442
 
2132
- describe('ConnectionActor — SASL AccountStore plumbing', () => {
2133
- /** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
2134
- class FakeAccountStore implements AccountStore {
2135
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
2136
- constructor(private readonly result: SaslResult) {}
2137
- verify(mech: string, payload: SaslPayload): SaslResult {
2138
- this.calls.push({ mech, payload });
2139
- return this.result;
2140
- }
2141
- }
2142
-
2443
+ describe('ConnectionActor — SASL ServicesStore plumbing', () => {
2143
2444
  /** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
2144
2445
  function plainPayload(authcid: string, password: string): string {
2145
2446
  return encodeBase64(`\0${authcid}\0${password}`);
@@ -2154,11 +2455,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2154
2455
  };
2155
2456
  }
2156
2457
 
2157
- it('completes SASL PLAIN with 900/903 when a bound AccountStore accepts creds', async () => {
2158
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2458
+ it('completes SASL PLAIN with 900/903 when a bound ServicesStore accepts creds', async () => {
2459
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2460
+ services.registerNick('alice', 'secret', 'alice@example.com');
2159
2461
  const { actor, runtime, conn } = makeActor({
2160
2462
  conn: saslReadyConn(),
2161
- accounts,
2463
+ services,
2162
2464
  });
2163
2465
  conn.nick = 'alice';
2164
2466
  runtime.calls.length = 0;
@@ -2173,18 +2475,14 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2173
2475
  expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
2174
2476
  // Critical: never 904 for valid creds.
2175
2477
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2176
- // The AccountStore was consulted with the parsed PLAIN credentials.
2177
- expect(accounts.calls).toEqual([
2178
- { mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
2179
- ]);
2180
2478
  // Account name recorded on the connection for extended-join/account-tag.
2181
2479
  expect(conn.account).toBe('alice');
2182
2480
  });
2183
2481
 
2184
- it('emits 904 ERR_SASLFAIL when no AccountStore is bound (regression guard)', async () => {
2482
+ it('emits 904 ERR_SASLFAIL when no ServicesStore is bound (regression guard)', async () => {
2185
2483
  const { actor, runtime, conn } = makeActor({
2186
2484
  conn: saslReadyConn(),
2187
- // No `accounts` option → ctx.accounts is undefined.
2485
+ // No `services` option → ctx.services is undefined.
2188
2486
  });
2189
2487
  conn.nick = 'alice';
2190
2488
  runtime.calls.length = 0;
@@ -2202,11 +2500,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2202
2500
  expect(conn.account).toBeUndefined();
2203
2501
  });
2204
2502
 
2205
- it('emits 904 when a bound AccountStore rejects the credentials', async () => {
2206
- const accounts = new FakeAccountStore({ ok: false, reason: 'invalid credentials' });
2503
+ it('emits 904 when a bound ServicesStore rejects the credentials', async () => {
2504
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2505
+ services.registerNick('alice', 'secret', 'alice@example.com');
2207
2506
  const { actor, runtime, conn } = makeActor({
2208
2507
  conn: saslReadyConn(),
2209
- accounts,
2508
+ services,
2210
2509
  });
2211
2510
  conn.nick = 'alice';
2212
2511
  runtime.calls.length = 0;
@@ -2223,20 +2522,10 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
2223
2522
  });
2224
2523
 
2225
2524
  describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2226
- /** Fake AccountStore that accepts EXTERNAL with a known cert subject. */
2227
- class FakeAccountStore implements AccountStore {
2228
- readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
2229
- constructor(private readonly result: SaslResult) {}
2230
- verify(mech: string, payload: SaslPayload): SaslResult {
2231
- this.calls.push({ mech, payload });
2232
- return this.result;
2233
- }
2234
- }
2235
-
2236
- /** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
2525
+ /** Fake MtlsIdentityProvider returning a canned cert identity for 'c1'. */
2237
2526
  class FakeMtlsProvider implements MtlsIdentityProvider {
2238
- constructor(private readonly identity: string | undefined) {}
2239
- getIdentity(connId: string): string | undefined {
2527
+ constructor(private readonly identity: CertIdentity | undefined) {}
2528
+ getIdentity(connId: string): CertIdentity | undefined {
2240
2529
  return connId === 'c1' ? this.identity : undefined;
2241
2530
  }
2242
2531
  }
@@ -2249,13 +2538,19 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2249
2538
  };
2250
2539
  }
2251
2540
 
2252
- it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
2253
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2254
- const mtlsIdentity = new FakeMtlsProvider('CN=alice');
2541
+ it('completes SASL EXTERNAL with 900/903 when mTLS provider, operator opt-in, secure transport, and store accept', async () => {
2542
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2543
+ services.registerNick('alice', 'secret', 'alice@example.com');
2544
+ services.addCertFP('alice', 'CN=alice');
2545
+ const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
2255
2546
  const { actor, runtime, conn } = makeActor({
2256
2547
  conn: saslReadyConn(),
2257
- accounts,
2548
+ services,
2258
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,
2259
2554
  });
2260
2555
  conn.nick = 'alice';
2261
2556
  runtime.calls.length = 0;
@@ -2268,17 +2563,16 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2268
2563
  expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
2269
2564
  expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
2270
2565
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2271
- expect(accounts.calls).toEqual([
2272
- { mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
2273
- ]);
2274
2566
  expect(conn.account).toBe('alice');
2275
2567
  });
2276
2568
 
2277
2569
  it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
2278
- const accounts = new FakeAccountStore({ ok: true, account: 'alice' });
2570
+ const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
2571
+ services.registerNick('alice', 'secret', 'alice@example.com');
2572
+ services.addCertFP('alice', 'CN=alice');
2279
2573
  const { actor, runtime, conn } = makeActor({
2280
2574
  conn: saslReadyConn(),
2281
- accounts,
2575
+ services,
2282
2576
  // No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
2283
2577
  });
2284
2578
  conn.nick = 'alice';
@@ -2292,6 +2586,31 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
2292
2586
  expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
2293
2587
  expect(conn.account).toBeUndefined();
2294
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
+ });
2295
2614
  });
2296
2615
 
2297
2616
  describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
@@ -3999,6 +4318,196 @@ describe('ConnectionActor — draft/multiline BATCH', () => {
3999
4318
  ).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
4000
4319
  expect(result).toEqual([]);
4001
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
+ });
4002
4511
  });
4003
4512
 
4004
4513
  // ---------------------------------------------------------------------------