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
@@ -21,6 +21,8 @@
21
21
  import { ChannelDO } from '../../src/channel-do';
22
22
  import { ChannelRegistryDO } from '../../src/channel-registry-do';
23
23
  import { ConnectionDO } from '../../src/connection-do';
24
+ import { CounterDO } from '../../src/counter-do';
25
+ import { RateLimitDO } from '../../src/rate-limit-do';
24
26
  import { RegistryDO } from '../../src/registry-do';
25
27
  import { RecordingChannelDO } from './stubs/channel-stub';
26
28
  import { RecordingRegistryDO } from './stubs/registry-stub';
@@ -29,6 +31,8 @@ export {
29
31
  ChannelDO,
30
32
  ChannelRegistryDO,
31
33
  ConnectionDO,
34
+ CounterDO,
35
+ RateLimitDO,
32
36
  RegistryDO,
33
37
  RecordingRegistryDO,
34
38
  RecordingChannelDO,
@@ -49,6 +49,23 @@ class_name = "ChannelDO"
49
49
  name = "CHANNEL_REGISTRY_DO"
50
50
  class_name = "ChannelRegistryDO"
51
51
 
52
+ # Global live-connection admission counter — single DO instance keyed by
53
+ # the fixed name `global`. The worker edge reserves a slot per upgrade
54
+ # (maxClients cap); ConnectionDO releases it on every teardown path and
55
+ # heartbeats it from the PING alarm so the lazy TTL reap only evicts
56
+ # entries from DOs that stopped heartbeating.
57
+ [[durable_objects.bindings]]
58
+ name = "COUNTER_DO"
59
+ class_name = "CounterDO"
60
+
61
+ # Per-IP upgrade rate limiter — single DO instance keyed by the fixed
62
+ # name `global`, maintaining one sliding-window admission-timestamp list
63
+ # per CF-Connecting-IP. The worker edge checks the budget before
64
+ # forwarding an upgrade and rejects over-budget IPs with 429.
65
+ [[durable_objects.bindings]]
66
+ name = "RATE_LIMIT_DO"
67
+ class_name = "RateLimitDO"
68
+
52
69
  # Recording stubs (kept under separate names for any test that wants to
53
70
  # assert "this exact RPC was emitted" rather than the end-to-end effect).
54
71
  [[durable_objects.bindings]]
@@ -71,4 +88,4 @@ class_name = "ChannelDO"
71
88
 
72
89
  [[migrations]]
73
90
  tag = "v1"
74
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
91
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO", "RecordingRegistryDO", "RecordingChannelDO"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -25,6 +25,7 @@ import {
25
25
  type ConnSnapshot,
26
26
  type ConnectionState,
27
27
  type Nick,
28
+ OperFailureStats,
28
29
  type RawLine,
29
30
  type ServerConfig,
30
31
  applyChannelDelta as applyDelta,
@@ -84,10 +85,20 @@ export class InMemoryRuntime implements IrcRuntime {
84
85
  private readonly admissionStats: AdmissionStats | undefined;
85
86
  private readonly admissionConfig: AdmissionConfig | undefined;
86
87
  private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
88
+ /**
89
+ * Per-IP failed-`OPER` counter shared across every connection's actor
90
+ * (see `ctx.operFailures`). Bound to the runtime's clock so the OPER
91
+ * reducer's lockout window advances with the same time source the
92
+ * actors observe. Adapters hand `runtime.operFailures` to each
93
+ * `ConnectionActor`'s options; the counter is then per-IP across the
94
+ * whole process, not per-connection.
95
+ */
96
+ readonly operFailures: OperFailureStats;
87
97
 
88
98
  constructor(opts: InMemoryRuntimeOptions) {
89
99
  this.clock = opts.clock;
90
100
  this.configLoader = opts.configLoader;
101
+ this.operFailures = new OperFailureStats(opts.clock);
91
102
  if (opts.admission !== undefined) {
92
103
  this.admissionConfig = opts.admission;
93
104
  this.admissionStats = new AdmissionStats(this.clock);
@@ -270,6 +281,20 @@ export class InMemoryRuntime implements IrcRuntime {
270
281
  }
271
282
  }
272
283
 
284
+ /**
285
+ * Oper-only notice fanout — the recipient gate for the OPER reducer's
286
+ * per-IP lockout notice (`:<server> NOTICE * :OPER lockout triggered
287
+ * for <host>`). Identical enumeration to {@link broadcastWallops} but
288
+ * filtered on user mode `+o` instead of `+w`.
289
+ */
290
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
291
+ for (const [connId, tracked] of this.connections) {
292
+ if (connId === except) continue;
293
+ if (!tracked.state.userModes.oper) continue;
294
+ tracked.handlers.send(lines);
295
+ }
296
+ }
297
+
273
298
  // ------------------------------------------------------------------
274
299
  // IrcRuntime — nick registry
275
300
  // ------------------------------------------------------------------
@@ -893,3 +893,77 @@ describe('InMemoryRuntime — broadcastWallops', () => {
893
893
  expect(c1Received).toBe(0);
894
894
  });
895
895
  });
896
+
897
+ describe('InMemoryRuntime — broadcastOperNotice', () => {
898
+ it('delivers lines to every connection with the +o user mode set', async () => {
899
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
900
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
901
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
902
+ // c1 has +w but NOT +o; c2 and c3 are opers.
903
+ const c1 = makeConn('c1', 'alice');
904
+ c1.userModes.wallops = true;
905
+ rt.registerConnection(c1, { send: push('c1'), disconnect: () => {} });
906
+ const c2 = makeConn('c2', 'bob');
907
+ c2.userModes.oper = true;
908
+ rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
909
+ const c3 = makeConn('c3', 'carol');
910
+ c3.userModes.oper = true;
911
+ rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
912
+
913
+ await rt.broadcastOperNotice([L(':srv NOTICE * :OPER lockout triggered for 10.0.0.1')]);
914
+
915
+ // +w without +o is NOT enough — the oper notice is oper-only.
916
+ expect(received.c1).toHaveLength(0);
917
+ expect(received.c2).toHaveLength(1);
918
+ expect(received.c3).toHaveLength(1);
919
+ });
920
+
921
+ it('skips the except connection even when it is an oper', async () => {
922
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
923
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
924
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
925
+ const oper = makeConn('c1', 'alice');
926
+ oper.userModes.oper = true;
927
+ rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
928
+ const peer = makeConn('c2', 'bob');
929
+ peer.userModes.oper = true;
930
+ rt.registerConnection(peer, { send: push('c2'), disconnect: () => {} });
931
+
932
+ await rt.broadcastOperNotice([L(':srv NOTICE * :hi')], 'c1');
933
+
934
+ expect(received.c1).toHaveLength(0);
935
+ expect(received.c2).toHaveLength(1);
936
+ });
937
+
938
+ it('is a no-op when no connection has +o', async () => {
939
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
940
+ let c1Received = 0;
941
+ rt.registerConnection(makeConn('c1', 'alice'), {
942
+ send: () => {
943
+ c1Received++;
944
+ },
945
+ disconnect: () => {},
946
+ });
947
+
948
+ await rt.broadcastOperNotice([L(':srv NOTICE * :hi')]);
949
+ expect(c1Received).toBe(0);
950
+ });
951
+ });
952
+
953
+ describe('InMemoryRuntime — operFailures tracker', () => {
954
+ it('exposes one shared OperFailureStats instance bound to the runtime clock', async () => {
955
+ const clock = new FakeClock(1_000);
956
+ const rt = new InMemoryRuntime({ clock });
957
+
958
+ const tracker = rt.operFailures;
959
+ expect(tracker).toBeDefined();
960
+ // Same instance every read: adapters hand it to every connection's
961
+ // actor so the per-IP budget is shared fleet-wide.
962
+ expect(rt.operFailures).toBe(tracker);
963
+
964
+ tracker.recordFailure('10.0.0.1');
965
+ clock.advance(1_000);
966
+ tracker.recordFailure('10.0.0.1');
967
+ expect(tracker.recentFailures('10.0.0.1', 300_000)).toBe(2);
968
+ });
969
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure helpers for the one-shot `accounts` → `nickserv_accounts` /
3
+ * `Services` migration (`tools/migrate-accounts-to-services.ts`).
4
+ *
5
+ * Lives in `irc-core` so both adapter test suites can import the helpers
6
+ * without a cross-adapter dependency. The CLI entry point (wrangler /
7
+ * DynamoDB IO) lives in the tools script; these helpers are side-effect-
8
+ * free and fully unit-testable.
9
+ */
10
+
11
+ import { caseFold } from './case-fold.js';
12
+ import { hashAccountCredential } from './credential-hashing.js';
13
+
14
+ /**
15
+ * DDL for the `nickserv_accounts` table (the migration target). Inlined
16
+ * rather than imported from `@serverless-ircd/cf-adapter` so the helpers
17
+ * are testable from any package. Kept in sync with
18
+ * `cf-adapter/src/d1-services-store.ts`.
19
+ */
20
+ export const MIGRATION_NICKSERV_ACCOUNTS_SQL =
21
+ "CREATE TABLE IF NOT EXISTS nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
22
+
23
+ /** Parsed `SASL_ACCOUNTS` env var entry. */
24
+ export interface ParsedSaslAccount {
25
+ username: string;
26
+ password: string;
27
+ }
28
+
29
+ /** A raw legacy `accounts` row read from D1 or DynamoDB. */
30
+ export interface LegacyAccountRow {
31
+ account: string;
32
+ algorithm: string;
33
+ salt: string;
34
+ hash: string;
35
+ }
36
+
37
+ /** Parses the `SASL_ACCOUNTS` env var into credential entries. */
38
+ export function parseSaslAccountsForMigration(raw: string | undefined): ParsedSaslAccount[] {
39
+ if (raw === undefined || raw.length === 0) return [];
40
+ const out: ParsedSaslAccount[] = [];
41
+ for (const line of raw.split('\n')) {
42
+ const trimmed = line.trim();
43
+ if (trimmed.length === 0) continue;
44
+ const sep = trimmed.indexOf(':');
45
+ if (sep <= 0) continue;
46
+ const username = trimmed.slice(0, sep);
47
+ const password = trimmed.slice(sep + 1);
48
+ if (username.length === 0 || password.length === 0) continue;
49
+ out.push({ username, password });
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Builds the set of `INSERT OR IGNORE` SQL statements that backfill the
56
+ * legacy `accounts` rows plus the `SASL_ACCOUNTS` env-seed entries into
57
+ * `nickserv_accounts`. Pure (no IO).
58
+ *
59
+ * - Legacy rows are copied verbatim (the pre-hashed scrypt credential is
60
+ * preserved; the plaintext is NOT re-hashed). The services row carries
61
+ * `cert_subjects = '[]'`, email `''`, enforce `'none'`, `created_at 0`.
62
+ * - `SASL_ACCOUNTS` env-seed entries are hashed fresh (the env var carries
63
+ * plaintext passwords). Only entries whose username is NOT already
64
+ * present in the legacy rows are emitted (legacy rows win over the env
65
+ * seed).
66
+ * - `INSERT OR IGNORE` guarantees idempotency: an existing
67
+ * `nickserv_accounts` row (registered via NickServ or a prior seed) is
68
+ * never clobbered.
69
+ *
70
+ * @returns The array of executable SQL statements (CREATE TABLE + INSERTs).
71
+ */
72
+ export function buildMigrationSql(
73
+ legacyRows: ReadonlyArray<LegacyAccountRow>,
74
+ saslAccountsRaw: string | undefined,
75
+ ): string[] {
76
+ const statements: string[] = [MIGRATION_NICKSERV_ACCOUNTS_SQL];
77
+
78
+ const seen = new Set<string>();
79
+
80
+ for (const row of legacyRows) {
81
+ const nickKey = caseFold('rfc1459', row.account);
82
+ seen.add(nickKey);
83
+ statements.push(buildInsertOrIgnore(row.account, row));
84
+ }
85
+
86
+ for (const acct of parseSaslAccountsForMigration(saslAccountsRaw)) {
87
+ const nickKey = caseFold('rfc1459', acct.username);
88
+ if (seen.has(nickKey)) continue;
89
+ seen.add(nickKey);
90
+ const cred = hashAccountCredential(acct.username, acct.password);
91
+ statements.push(buildInsertOrIgnore(acct.username, cred));
92
+ }
93
+
94
+ return statements;
95
+ }
96
+
97
+ function buildInsertOrIgnore(
98
+ username: string,
99
+ cred: { algorithm: string; salt: string; hash: string },
100
+ ): string {
101
+ const nickKey = caseFold('rfc1459', username);
102
+ return `INSERT OR IGNORE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES ('${sqlEscape(nickKey)}', '${sqlEscape(username)}', '${sqlEscape(username)}', '', 0, 'none', '[]', '${sqlEscape(cred.algorithm)}', '${sqlEscape(cred.salt)}', '${sqlEscape(cred.hash)}')`;
103
+ }
104
+
105
+ /** Doubles single quotes for safe embedding in a D1 SQL string literal. */
106
+ function sqlEscape(value: string): string {
107
+ return value.replace(/'/g, "''");
108
+ }
109
+
110
+ /**
111
+ * Builds the DynamoDB `PutItem` input for a legacy row, targeting the
112
+ * `Services` table's `NICK:<fold>` / `#` item. Pure.
113
+ *
114
+ * The caller wraps this in a conditional PutItem
115
+ * (`ConditionExpression: attribute_not_exists(pk)`) so an existing nick
116
+ * registration is never clobbered.
117
+ */
118
+ export function buildServicesPutItem(
119
+ row: { account: string; algorithm: string; salt: string; hash: string },
120
+ tableName: string,
121
+ ): { TableName: string; Item: Record<string, unknown> } {
122
+ const fold = caseFold('rfc1459', row.account);
123
+ return {
124
+ TableName: tableName,
125
+ Item: {
126
+ pk: `NICK:${fold}`,
127
+ sk: '#',
128
+ type: 'nick',
129
+ nick: row.account,
130
+ account: row.account,
131
+ email: '',
132
+ createdAt: 0,
133
+ enforce: 'none',
134
+ certSubjects: [],
135
+ algorithm: row.algorithm,
136
+ salt: row.salt,
137
+ hash: row.hash,
138
+ },
139
+ };
140
+ }
@@ -16,7 +16,8 @@ export interface Capability {
16
16
  /**
17
17
  * Optional value parameter. For `sasl` this is the base mechanism list
18
18
  * (`PLAIN`); the {@link saslCapValue} helper upgrades it to include
19
- * `EXTERNAL` when mTLS is configured for a connection.
19
+ * `EXTERNAL` when the mechanism is available for the connection (mTLS
20
+ * source + operator opt-in + secure transport).
20
21
  */
21
22
  value?: string;
22
23
  }
@@ -40,6 +41,14 @@ export const PRE_AWAY_CAP_NAME = 'draft/pre-away';
40
41
  */
41
42
  export const DEFAULT_MULTILINE_MAX_BYTES = 4096;
42
43
 
44
+ /**
45
+ * Default per-batch entry ceiling enforced incrementally while a
46
+ * `draft/multiline` batch is open. Defense-in-depth against a client that
47
+ * streams many tiny lines (each under the byte budget) and never closes the
48
+ * batch; deployments override via `ServerConfig.multilineMaxEntries`.
49
+ */
50
+ export const DEFAULT_MULTILINE_MAX_ENTRIES = 100;
51
+
43
52
  /**
44
53
  * Builds the `draft/multiline` cap *value* (the substring after `=`) for the
45
54
  * advertised byte budget. The {@link CapReducer} and the multi-line reducer
@@ -79,17 +88,18 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
79
88
  ]);
80
89
 
81
90
  /**
82
- * Returns the `sasl` cap value to advertise given whether an mTLS identity
83
- * provider is bound for the connection.
91
+ * Returns the `sasl` cap value to advertise given whether the SASL
92
+ * EXTERNAL mechanism is available for the connection.
84
93
  *
85
- * PLAIN is always available. EXTERNAL is advertised only when mTLS is
86
- * configured (an `MtlsIdentityProvider` is bound) so clients on
87
- * non-mTLS connections do not attempt a mechanism that cannot succeed.
88
- * Operators enable EXTERNAL by configuring mTLS at the edge (CF API Shield
89
- * mTLS, AWS custom-domain mTLS) no code change required.
94
+ * PLAIN is always available. EXTERNAL is advertised only when the
95
+ * caller's three-way gate holds (mTLS identity source bound +
96
+ * `sasl.externalEnabled` operator opt-in + secure transport see
97
+ * `commands/sasl.ts`), so clients on connections where the mechanism
98
+ * cannot succeed do not attempt an exchange the server would refuse
99
+ * with `908 ERR_SASLMECHS`.
90
100
  */
91
- export function saslCapValue(hasMtls: boolean): string {
92
- return hasMtls ? 'PLAIN,EXTERNAL' : 'PLAIN';
101
+ export function saslCapValue(externalAvailable: boolean): string {
102
+ return externalAvailable ? 'PLAIN,EXTERNAL' : 'PLAIN';
93
103
  }
94
104
 
95
105
  /** Set form for fast membership checks during `CAP REQ`. */
@@ -0,0 +1,178 @@
1
+ /**
2
+ * CertFP — canonical client-certificate identity for SASL EXTERNAL.
3
+ *
4
+ * The edge platforms terminate TLS and surface the verified client
5
+ * certificate to the adapter, but neither Cloudflare Workers nor AWS API
6
+ * Gateway hands over the certificate DER itself:
7
+ *
8
+ * - Cloudflare surfaces the SHA-256 fingerprint of the DER on
9
+ * `request.cf.tlsClientAuth.certFingerprintSHA256` (lowercase hex) — the
10
+ * strongest identifier available and the preferred account binding.
11
+ * - AWS API Gateway surfaces only `requestContext.identity.clientCertSubjectDN`
12
+ * (no fingerprint, no DER) — the subject DN is the only identifier.
13
+ *
14
+ * To make subject-DN bindings robust across those platforms (and across
15
+ * cert re-issuances that reorder or re-space the same DN), the DN is
16
+ * canonicalised before comparison:
17
+ *
18
+ * 1. Attribute **types** are lowercased (`CN=` → `cn=`); attribute
19
+ * **values** keep their case (values are compared case-sensitively).
20
+ * 2. Whitespace around types/values is trimmed and runs of whitespace
21
+ * inside a value collapse to a single space (escaped whitespace, e.g.
22
+ * a trailing `\ `, is preserved verbatim).
23
+ * 3. RDNs (`a,b`) and the AVAs of a multi-valued RDN (`a+b`) are sorted,
24
+ * so two semantically-equal DNs written in different orders collapse
25
+ * to one canonical spelling.
26
+ * 4. RFC 4514 escapes (`\,`, `\+`, `\=` …) are kept intact — the
27
+ * canonicaliser never splits on an escaped separator.
28
+ *
29
+ * Accounts bind to either a DER fingerprint (`fp:<hex>` entries in
30
+ * `RegisteredNick.certSubjects`, preferred) or a canonical DN. Legacy
31
+ * verbatim subject bindings still verify — `ServicesStore.verifyCertFP`
32
+ * canonicalises stored entries at compare time and rewrites them to the
33
+ * canonical spelling on first use.
34
+ */
35
+
36
+ /**
37
+ * Marker prefix for fingerprint bindings stored in
38
+ * `RegisteredNick.certSubjects` (vs canonical-DN bindings).
39
+ */
40
+ export const CERT_FP_PREFIX = 'fp:';
41
+
42
+ /**
43
+ * The verified client-certificate identity of one connection, as resolved
44
+ * by an {@link MtlsIdentityProvider}.
45
+ *
46
+ * - `subject` — the raw platform-surfaced subject string, verbatim.
47
+ * - `canonicalDn` — {@link canonicalizeCertSubject} of `subject`.
48
+ * - `fingerprint` — the SHA-256 fingerprint of the certificate DER
49
+ * (lowercase hex) when the platform surfaces one; absent otherwise
50
+ * (API Gateway surfaces no fingerprint today).
51
+ */
52
+ export interface CertIdentity {
53
+ readonly subject: string;
54
+ readonly canonicalDn: string;
55
+ readonly fingerprint?: string;
56
+ }
57
+
58
+ /**
59
+ * Splits `s` on unescaped `sep` characters, keeping RFC 4514 `\x` escape
60
+ * pairs intact within each part.
61
+ */
62
+ function splitUnescaped(s: string, sep: string): string[] {
63
+ const parts: string[] = [];
64
+ let current = '';
65
+ for (let i = 0; i < s.length; i += 1) {
66
+ const ch = s.charAt(i);
67
+ const next = s.charAt(i + 1);
68
+ if (ch === '\\' && next !== '') {
69
+ current += ch + next;
70
+ i += 1;
71
+ } else if (ch === sep) {
72
+ parts.push(current);
73
+ current = '';
74
+ } else {
75
+ current += ch;
76
+ }
77
+ }
78
+ parts.push(current);
79
+ return parts;
80
+ }
81
+
82
+ /**
83
+ * Index of the first unescaped occurrence of `ch`, or `-1`. Used to split
84
+ * an AVA into type and value without splitting on `\=`.
85
+ */
86
+ function indexUnescaped(s: string, ch: string): number {
87
+ for (let i = 0; i < s.length; i += 1) {
88
+ if (s.charAt(i) === '\\') {
89
+ i += 1;
90
+ continue;
91
+ }
92
+ if (s.charAt(i) === ch) return i;
93
+ }
94
+ return -1;
95
+ }
96
+
97
+ /**
98
+ * Collapses runs of unescaped whitespace to single spaces and trims
99
+ * leading/trailing unescaped whitespace. Escape pairs (`\x`) pass through
100
+ * verbatim and terminate any pending whitespace run.
101
+ */
102
+ function collapseWhitespace(s: string): string {
103
+ let out = '';
104
+ let pendingSpace = false;
105
+ let started = false;
106
+ for (let i = 0; i < s.length; i += 1) {
107
+ const ch = s.charAt(i);
108
+ const next = s.charAt(i + 1);
109
+ if (ch === '\\' && next !== '') {
110
+ if (pendingSpace) {
111
+ out += ' ';
112
+ pendingSpace = false;
113
+ }
114
+ out += ch + next;
115
+ i += 1;
116
+ started = true;
117
+ continue;
118
+ }
119
+ if (/\s/.test(ch)) {
120
+ if (started) pendingSpace = true;
121
+ continue;
122
+ }
123
+ if (pendingSpace) {
124
+ out += ' ';
125
+ pendingSpace = false;
126
+ }
127
+ out += ch;
128
+ started = true;
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Canonicalises one attribute-value assertion (`type=value`). */
134
+ function canonicalAva(ava: string): string {
135
+ const eq = indexUnescaped(ava, '=');
136
+ if (eq === -1) return collapseWhitespace(ava);
137
+ const type = collapseWhitespace(ava.slice(0, eq)).toLowerCase();
138
+ const value = collapseWhitespace(ava.slice(eq + 1));
139
+ return `${type}=${value}`;
140
+ }
141
+
142
+ /**
143
+ * Canonicalises an RFC 4514-style DN for byte-stable comparison:
144
+ * lowercase attribute types (values keep their case), collapsed/trimmed
145
+ * whitespace, sorted RDNs, and sorted AVAs inside multi-valued RDNs.
146
+ * Escape sequences pass through unchanged.
147
+ */
148
+ export function canonicalizeCertSubject(dn: string): string {
149
+ return splitUnescaped(dn, ',')
150
+ .map((rdn) =>
151
+ splitUnescaped(rdn, '+')
152
+ .map((ava) => canonicalAva(ava))
153
+ .sort()
154
+ .join('+'),
155
+ )
156
+ .sort()
157
+ .join(',');
158
+ }
159
+
160
+ /**
161
+ * Normalises a DER SHA-256 fingerprint into the stored binding form:
162
+ * `fp:` + trimmed, lowercased, colon-free hex.
163
+ */
164
+ export function certFingerprintId(fingerprint: string): string {
165
+ return `${CERT_FP_PREFIX}${fingerprint.trim().toLowerCase().replace(/:/g, '')}`;
166
+ }
167
+
168
+ /**
169
+ * Builds the {@link CertIdentity} for a platform-surfaced subject (and the
170
+ * platform fingerprint, when one was provided). Pure; adapters call this
171
+ * at admission time.
172
+ */
173
+ export function certIdentityFromSubject(subject: string, fingerprint?: string): CertIdentity {
174
+ const canonicalDn = canonicalizeCertSubject(subject);
175
+ return fingerprint === undefined
176
+ ? { subject, canonicalDn }
177
+ : { subject, canonicalDn, fingerprint };
178
+ }
@@ -13,8 +13,10 @@
13
13
  *
14
14
  * {@link passBasedAccountAuth} is the legacy IRC convention of carrying
15
15
  * NickServ credentials in `PASS <nick>:<password>` at registration; it
16
- * resolves at registration completion via the injected
17
- * {@link AccountStore} and, on success, delegates to
16
+ * resolves at registration completion via the bound
17
+ * {@link ServicesStore} (`services.verifyNick` the same single
18
+ * credential home NickServ `REGISTER`, SASL PLAIN, and NickServ
19
+ * `IDENTIFY` consult) and, on success, delegates to
18
20
  * {@link applyAccountSuccess}.
19
21
  */
20
22
 
@@ -129,17 +131,20 @@ export function passBasedAccountAuth(state: ConnectionState, ctx: Ctx): EffectTy
129
131
  * in `passReducer` (reading the `PASS` param directly), so the two entry
130
132
  * points share one verify+success path and never diverge.
131
133
  *
132
- * Returns the account-login success effects (`900`/`903`/`+r`/read-marker
134
+ * Credentials are verified against the bound {@link ServicesStore} via
135
+ * `services.verifyNick` — the same single credential home NickServ
136
+ * `REGISTER`, SASL PLAIN, and NickServ `IDENTIFY` consult. Returns the
137
+ * account-login success effects (`900`/`903`/`+r`/read-marker
133
138
  * seeding/away replay/memo delivery/`account-notify`), or `[]` for every
134
139
  * other outcome:
135
140
  * - already identified (`state.account` set, e.g. via SASL) → no-op;
136
141
  * - no `<nick>:<password>` form (bare value or undefined) → no-op;
137
142
  * - payload nick does not match `state.nick` (when set) → no-op;
138
- * - no {@link AccountStore} bound → no-op;
143
+ * - no {@link ServicesStore} bound → no-op;
139
144
  * - verify failure (unknown nick or wrong password) → no-op.
140
145
  *
141
146
  * A failed verify is followed by a verify against a fixed dummy entry so
142
- * the failure path performs the same AccountStore work whether the payload
147
+ * the failure path performs the same scrypt work whether the payload
143
148
  * nick was unknown or the password was wrong — the observable outcome
144
149
  * (no numerics, no disconnect, no state change) is identical for both
145
150
  * cases, giving no information to a remote attacker.
@@ -153,24 +158,16 @@ export function attemptPassAccountAuth(
153
158
  const parsed = parsePassAccountAttempt(attempt);
154
159
  if (parsed === null) return [];
155
160
  if (state.nick !== undefined && state.nick !== parsed.nick) return [];
156
- const store = ctx.accounts;
157
- if (store === undefined) return [];
158
- const result = store.verify('PLAIN', {
159
- kind: 'PLAIN',
160
- username: parsed.nick,
161
- password: parsed.password,
162
- });
161
+ const services = ctx.services;
162
+ if (services === undefined) return [];
163
+ const result = services.verifyNick(parsed.nick, parsed.password);
163
164
  if (result.ok) {
164
165
  return applyAccountSuccess(state, ctx, result.account);
165
166
  }
166
167
  // Equalise timing: perform a verify against a fixed dummy entry so a
167
- // failed PASS-auth always costs one extra AccountStore call regardless
168
- // of whether the payload nick was unknown or the password was wrong.
169
- store.verify('PLAIN', {
170
- kind: 'PLAIN',
171
- username: PASS_AUTH_DUMMY_NICK,
172
- password: PASS_AUTH_DUMMY_PASSWORD,
173
- });
168
+ // failed PASS-auth always costs one extra scrypt verify regardless of
169
+ // whether the payload nick was unknown or the password was wrong.
170
+ services.verifyNick(PASS_AUTH_DUMMY_NICK, PASS_AUTH_DUMMY_PASSWORD);
174
171
  return [];
175
172
  }
176
173
 
@@ -94,11 +94,19 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
94
94
  state.capNegotiating = true;
95
95
 
96
96
  const target = state.nick ?? '*';
97
- const hasMtls = ctx.mtlsIdentity !== undefined;
97
+ // Same gate as the AUTHENTICATE reducer (no transport-level override +
98
+ // mTLS source + operator opt-in + secure transport), so the `sasl` cap
99
+ // never advertises a mechanism the reducer would refuse — with 908, or
100
+ // with the transport-specific 904 under the override.
101
+ const external =
102
+ ctx.serverConfig.sasl?.externalUnsupportedMessage === undefined &&
103
+ ctx.mtlsIdentity !== undefined &&
104
+ (ctx.serverConfig.sasl?.externalEnabled ?? false) &&
105
+ ctx.connection.secure;
98
106
  const multilineMax = ctx.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
99
107
  const tokens = SUPPORTED_CAPABILITIES.map((cap) => {
100
108
  if (cap.name === SASL_CAP_NAME) {
101
- return capToLsString({ ...cap, value: saslCapValue(hasMtls) });
109
+ return capToLsString({ ...cap, value: saslCapValue(external) });
102
110
  }
103
111
  if (cap.name === MULTILINE_CAP_NAME) {
104
112
  return capToLsString({ ...cap, value: multilineCapValue(multilineMax) });