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
@@ -29,13 +29,15 @@
29
29
 
30
30
  import { DurableObject } from 'cloudflare:workers';
31
31
  import {
32
- type AccountStore,
32
+ type CertIdentity,
33
33
  type ChanName,
34
34
  type ChannelState,
35
35
  type Clock,
36
36
  type ConnId,
37
37
  type ConnectionState,
38
38
  ConsoleLogger,
39
+ FRAME_FLOOD_CLOSE_CODE,
40
+ FRAME_FLOOD_ERROR_LINE,
39
41
  type IdFactory,
40
42
  InMemoryMessageStore,
41
43
  InMemoryMtlsIdentityProvider,
@@ -46,12 +48,14 @@ import {
46
48
  type MotdProvider,
47
49
  type MtlsIdentityProvider,
48
50
  type NickHistoryStore,
51
+ type ParsedServerConfig,
49
52
  type RawLine,
50
- type SaslAccountCredential,
51
53
  type ServerConfig,
52
54
  SystemClock,
53
55
  UuidIdFactory,
54
56
  type WsFrameMode,
57
+ admitFrameWindow,
58
+ certIdentityFromSubject,
55
59
  createChannel,
56
60
  createConnection,
57
61
  frameToLines,
@@ -66,9 +70,9 @@ import { ConnectionActor, type Transport } from '@serverless-ircd/irc-server';
66
70
  import { makeCfRuntime } from './cf-runtime.js';
67
71
  import type { CfConnectionHandlers } from './cf-runtime.js';
68
72
  import { loadServerConfigFromCfEnv } from './config-loader.js';
69
- import { resolveAccountStore } from './d1-account-store.js';
73
+ import { COUNTER_INSTANCE_NAME } from './counter-do.js';
70
74
  import { type D1ServicesStore, loadD1ServicesStore } from './d1-services-store.js';
71
- import type { Env } from './env.js';
75
+ import type { CounterRpc, Env } from './env.js';
72
76
  import { STATE_STORAGE_KEY, deserialize, serialize } from './serialize.js';
73
77
  import type { PersistedConnectionState } from './serialize.js';
74
78
  import { CfStats } from './stats.js';
@@ -100,9 +104,11 @@ export class ConnectionDO extends DurableObject<Env> {
100
104
  * Cached reducer-facing server config. Parsed once from `this.env`
101
105
  * (bindings are stable for the DO's lifetime) so the schema-validated
102
106
  * `loadServerConfigFromCfEnv` runs a single time rather than on every
103
- * WebSocket frame.
107
+ * WebSocket frame. Held as {@link ParsedServerConfig} so the adapter
108
+ * can read extra fields (e.g. `saslAccounts` for the services seed)
109
+ * the reducer-facing `ServerConfig` view omits.
104
110
  */
105
- private cachedServerConfig: ServerConfig | undefined;
111
+ private cachedParsedConfig: ParsedServerConfig | undefined;
106
112
  /**
107
113
  * Per-instance channel-state cache. Reused across every WebSocket frame
108
114
  * the connection handles (within a single hibernation cycle) so that
@@ -142,43 +148,35 @@ export class ConnectionDO extends DurableObject<Env> {
142
148
  * keyed by nick) is a documented follow-up. Lazily constructed.
143
149
  */
144
150
  private history: NickHistoryStore | undefined;
145
- /**
146
- * SASL account store, resolved once via {@link loadAccountStore} before the
147
- * first frame dispatches. The two-phase load mirrors AWS's
148
- * `loadDynamoAccountStore`: D1 `accounts` table (authoritative when it has
149
- * rows) is queried at boot; when empty/unreachable the `SASL_ACCOUNTS`
150
- * env-var seed provides the fallback `InMemoryAccountStore`. The
151
- * `AccountStore.verify` port is synchronous, so pre-loading MUST complete
152
- * before the first reducer runs.
153
- */
154
- private accounts: AccountStore | undefined;
155
- /** Guards {@link loadAccountStore} so the D1 query runs at most once. */
156
- private accountsResolved = false;
157
151
  /**
158
152
  * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
159
153
  * OperServ + read-marker), hydrated once via {@link loadServicesStore}
160
154
  * before the first frame dispatches. The store mutates the in-memory
161
155
  * cache synchronously and enqueues write-behind ops; {@link flushServices}
162
156
  * drains them into D1 after each frame / at teardown. `undefined` when
163
- * D1 is not configured (services unbound → no NickServ/ChanServ routing).
157
+ * D1 is not configured (services unbound → no NickServ/ChanServ routing,
158
+ * no SASL/PASS credential verify).
164
159
  */
165
160
  private services: D1ServicesStore | undefined;
166
161
  /** Guards {@link loadServicesStore} so the D1 scan runs at most once. */
167
162
  private servicesResolved = false;
168
163
  /**
169
- * Verified client-cert subject captured at WebSocket upgrade time when
164
+ * Verified client-cert identity captured at WebSocket upgrade time when
170
165
  * CF API Shield mTLS is configured. Surfaced to the SASL EXTERNAL reducer
171
166
  * via an {@link MtlsIdentityProvider} so `AUTHENTICATE EXTERNAL` can map
172
- * the cert subject to an account.
167
+ * the certificate to an account (DER SHA-256 fingerprint preferred,
168
+ * canonical DN fallback — see `docs/SASL-EXTERNAL.md`).
173
169
  *
174
170
  * CF API Shield mTLS setup:
175
171
  * 1. Upload the client CA to API Shield (dashboard → Security → API Shield).
176
172
  * 2. Create an mTLS policy on the custom hostname that requires client certs.
177
173
  * 3. The verified subject is injected into `request.cf.tlsClientAuthCertSubject`
178
- * on every request, which the Worker reads at upgrade time.
174
+ * (and `request.cf.tlsClientAuth.certFingerprintSHA256` carries the DER
175
+ * SHA-256 fingerprint) on every request, which the Worker reads at
176
+ * upgrade time.
179
177
  * When unset (no mTLS configured), EXTERNAL auth fails with `904`.
180
178
  */
181
- private mtlsCertSubject: string | undefined;
179
+ private mtlsCert: CertIdentity | undefined;
182
180
  /**
183
181
  * Client source IP captured at WebSocket upgrade time, surfaced onto the
184
182
  * {@link ConnectionState.host} field so WHOIS / hostmasks resolve instead
@@ -190,6 +188,23 @@ export class ConnectionDO extends DurableObject<Env> {
190
188
  * this raw IP with a deterministic cloak at registration time.
191
189
  */
192
190
  private sourceHost: string | undefined;
191
+ /**
192
+ * Timestamps of recently admitted inbound frames (the sliding window
193
+ * backing the adapter-boundary rate limit, see `admitFrameWindow`).
194
+ * Kept in memory only: while frames are flowing the DO stays resident
195
+ * (so an active flood is always counted), and after eviction — which
196
+ * requires idleness far longer than the 5 s default window — every
197
+ * retained timestamp would have decayed out anyway. The gate fires
198
+ * BEFORE the actor / storage write so over-limit frames cost neither.
199
+ */
200
+ private inboundFrameTimes: number[] = [];
201
+ /**
202
+ * Test-only override for the inbound frame window (`maxFrames` /
203
+ * `windowMs`), bypassing the env-sourced `adapter` config so the decay
204
+ * behaviour is testable without real multi-second sleeps. `undefined`
205
+ * in production.
206
+ */
207
+ private frameWindowOverride: { maxFrames: number; windowMs: number } | undefined;
193
208
  private readonly clock: Clock = SystemClock;
194
209
  private readonly ids: IdFactory = new UuidIdFactory();
195
210
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
@@ -218,14 +233,7 @@ export class ConnectionDO extends DurableObject<Env> {
218
233
  const pair = new WebSocketPair();
219
234
  const server = pair[0];
220
235
  const client = pair[1];
221
- // Capture the verified client-cert subject when CF API Shield mTLS is
222
- // active. `request.cf` is a Cloudflare-specific extension; the property
223
- // is present only when a client certificate was verified against the
224
- // uploaded CA pool.
225
- const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
226
- if (cf !== undefined && typeof cf.tlsClientAuthCertSubject === 'string') {
227
- this.mtlsCertSubject = cf.tlsClientAuthCertSubject;
228
- }
236
+ this.captureMtlsSubject(request);
229
237
  // Capture the client source IP for ConnectionState.host. CF-Connecting-IP
230
238
  // is set by the Cloudflare edge on every proxied request; X-Real-IP is
231
239
  // the conventional fallback for non-CF reverse proxies. Stashed on the
@@ -259,6 +267,16 @@ export class ConnectionDO extends DurableObject<Env> {
259
267
  /** Frame → actor → dispatch → persist. */
260
268
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
261
269
  const mode = this.wsFrameMode(ws);
270
+ // Adapter-boundary rate limit: consult the sliding window BEFORE any
271
+ // per-frame work (state load, actor run, storage write, D1 flush).
272
+ // An over-limit frame delivers the canonical ERROR line and closes
273
+ // the socket with RFC 6455 close code 1008 (Policy Violation); the
274
+ // normal close handler runs the QUIT teardown.
275
+ if (!this.admitInboundFrame()) {
276
+ this.sendOutbound(ws, [{ text: FRAME_FLOOD_ERROR_LINE }], mode);
277
+ ws.close(FRAME_FLOOD_CLOSE_CODE, 'flooding');
278
+ return;
279
+ }
262
280
  // text.ircv3.net is a text-only subprotocol: a binary frame on such a
263
281
  // connection is a protocol violation. RFC 6455 close code 1003 (unsupported
264
282
  // data) is the spec-mandated response.
@@ -283,7 +301,6 @@ export class ConnectionDO extends DurableObject<Env> {
283
301
  const state = await this.loadState();
284
302
  const beforeNick = state.nick;
285
303
 
286
- await this.loadAccountStore();
287
304
  await this.loadServicesStore();
288
305
  const actor = this.buildActor(ws, state, mode);
289
306
  try {
@@ -360,6 +377,10 @@ export class ConnectionDO extends DurableObject<Env> {
360
377
  const socket = ws as WebSocket;
361
378
  this.sendOutbound(socket, [{ text: `PING :${token}` }], this.wsFrameMode(socket));
362
379
  }
380
+ // Refresh the admission slot so the counter's lazy TTL reconcile
381
+ // only evicts entries from DOs that stopped heartbeating (crashed or
382
+ // evicted without a close event). No-op for unregistered connections.
383
+ await this.refreshConnectionSlot();
363
384
  await this.ctx.storage.setAlarm(now + this.pingIntervalMs);
364
385
  }
365
386
 
@@ -388,17 +409,39 @@ export class ConnectionDO extends DurableObject<Env> {
388
409
  * socket count without sending anything. Used by ChannelDO.sweep.
389
410
  */
390
411
  async deliver(lines: RawLine[]): Promise<{ delivered: number }> {
412
+ return this.deliverToSockets(this.ctx.getWebSockets() as unknown as WebSocket[], lines);
413
+ }
414
+
415
+ /**
416
+ * Iterates `sockets`, delivering `lines` to each OPEN one and counting
417
+ * the writes. Extracted from {@link deliver} so the false arm of the
418
+ * tryDeliver check is testable with a fake non-OPEN socket — the real
419
+ * `ctx.getWebSockets()` only returns OPEN sockets in the test runtime.
420
+ */
421
+ public deliverToSockets(sockets: WebSocket[], lines: RawLine[]): { delivered: number } {
391
422
  let delivered = 0;
392
- for (const ws of this.ctx.getWebSockets()) {
393
- const socket = ws as WebSocket;
394
- if (socket.readyState !== WebSocket.OPEN) continue;
395
- const mode = this.wsFrameMode(socket);
396
- this.sendOutbound(socket, lines, mode);
397
- delivered++;
423
+ for (const ws of sockets) {
424
+ delivered += this.tryDeliver(ws, lines) ? 1 : 0;
398
425
  }
399
426
  return { delivered };
400
427
  }
401
428
 
429
+ /**
430
+ * Delivers `lines` to a single attached WebSocket. Returns `false`
431
+ * (without writing) when the socket is no longer OPEN, `true` after a
432
+ * successful write. Public so the readyState guard is testable with a
433
+ * fake socket — the real `ctx.getWebSockets()` only returns OPEN
434
+ * sockets in the test runtime, but production may briefly surface
435
+ * CLOSING sockets between the unilateral-close and the close event
436
+ * reaching the DO.
437
+ */
438
+ public tryDeliver(ws: WebSocket, lines: RawLine[]): boolean {
439
+ if (ws.readyState !== WebSocket.OPEN) return false;
440
+ const mode = this.wsFrameMode(ws);
441
+ this.sendOutbound(ws, lines, mode);
442
+ return true;
443
+ }
444
+
402
445
  // -------------------------------------------------------------------------
403
446
  // Internal helpers
404
447
  // -------------------------------------------------------------------------
@@ -441,6 +484,48 @@ export class ConnectionDO extends DurableObject<Env> {
441
484
  ws.send(`${lines.map((l) => l.text).join('\r\n')}\r\n`);
442
485
  }
443
486
 
487
+ /**
488
+ * Test-only seam that exposes {@link sendOutbound} so the readyState
489
+ * early-return is reachable with a fake non-OPEN socket. Production
490
+ * callers always pass an OPEN socket (the {@link deliver} and
491
+ * {@link tryDeliver} guards filter first), so the path cannot be
492
+ * exercised via real RPC.
493
+ */
494
+ public __sendOutboundForTest(ws: WebSocket, lines: RawLine[], mode: WsFrameMode): void {
495
+ this.sendOutbound(ws, lines, mode);
496
+ }
497
+
498
+ /**
499
+ * Applies the per-connection inbound frame window to the frame being
500
+ * received: prunes decayed timestamps, records this frame when it
501
+ * fits, and returns whether it was admitted. The window config comes
502
+ * from the schema-validated `adapter` section (default 50 frames /
503
+ * 5 s) unless the test-only override is installed.
504
+ */
505
+ private admitInboundFrame(): boolean {
506
+ const cfg = this.frameWindowOverride ?? {
507
+ maxFrames: this.parsedConfig().adapter.maxFramesPerWindow,
508
+ windowMs: this.parsedConfig().adapter.frameWindowSeconds * 1000,
509
+ };
510
+ const decision = admitFrameWindow(
511
+ this.inboundFrameTimes,
512
+ this.clock.now(),
513
+ cfg.maxFrames,
514
+ cfg.windowMs,
515
+ );
516
+ this.inboundFrameTimes = decision.times;
517
+ return decision.admitted;
518
+ }
519
+
520
+ /**
521
+ * Test-only seam installing a tight inbound frame window (or clearing
522
+ * it with `undefined`) without redeploying the worker env. See
523
+ * {@link frameWindowOverride}.
524
+ */
525
+ public __setFrameWindowForTest(cfg: { maxFrames: number; windowMs: number } | undefined): void {
526
+ this.frameWindowOverride = cfg;
527
+ }
528
+
444
529
  /** Loads the connection state from storage, caching for the event. */
445
530
  private async loadState(): Promise<ConnectionState> {
446
531
  if (this.cached !== undefined) return this.cached;
@@ -484,10 +569,7 @@ export class ConnectionDO extends DurableObject<Env> {
484
569
  this.sendOutbound(ws, lines, mode);
485
570
  },
486
571
  disconnect: (reason?: string): void => {
487
- if (reason !== undefined) {
488
- this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
489
- }
490
- ws.close();
572
+ this.writeDisconnect(ws, mode, reason);
491
573
  },
492
574
  snapshot: (): ConnectionState | undefined => this.cached,
493
575
  };
@@ -503,11 +585,10 @@ export class ConnectionDO extends DurableObject<Env> {
503
585
  // Bound `connectionId` so every record the actor emits is filterable
504
586
  // per-connection in the Workers dashboard.
505
587
  const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
506
- const accounts = this.accountStore();
507
588
  const services = this.servicesStore();
508
589
  const mtlsIdentity: MtlsIdentityProvider | undefined =
509
- this.mtlsCertSubject !== undefined
510
- ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCertSubject }])
590
+ this.mtlsCert !== undefined
591
+ ? new InMemoryMtlsIdentityProvider([{ connId: state.id, identity: this.mtlsCert }])
511
592
  : undefined;
512
593
  return new ConnectionActor({
513
594
  state,
@@ -529,17 +610,48 @@ export class ConnectionDO extends DurableObject<Env> {
529
610
  // Cloudflare terminates TLS at the edge before the WebSocket reaches
530
611
  // the DO, so every WS connection is secure → user mode `S`.
531
612
  secure: true,
532
- ...(accounts !== undefined ? { accounts } : {}),
533
- ...(services !== undefined ? { services } : {}),
534
- ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
613
+ ...buildOptionalActorFields(services, mtlsIdentity),
535
614
  logger,
536
615
  });
537
616
  }
538
617
 
618
+ /**
619
+ * Writes the optional `ERROR :Closing link` line and closes the socket.
620
+ * Extracted from the {@link buildActor} disconnect callback so the
621
+ * reason-gated branch is testable without driving a full actor flow
622
+ * that emits a self-Disconnect with a reason (rare — OperServ GHOST,
623
+ * KILL, flood, bad server-password).
624
+ */
625
+ private writeDisconnect(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
626
+ if (reason !== undefined) {
627
+ this.sendOutbound(ws, [{ text: `ERROR :Closing link: (${reason})` }], mode);
628
+ }
629
+ ws.close();
630
+ }
631
+
632
+ /**
633
+ * Test-only seam that exposes {@link closeIfStillOpen} so the
634
+ * readyState-guarded `ws.close()` is reachable with a fake OPEN
635
+ * socket. The real QUIT flow always closes the WS via the actor's
636
+ * self-Disconnect handler before tearDown reaches this guard.
637
+ */
638
+ public __closeIfStillOpenForTest(ws: WebSocket): void {
639
+ this.closeIfStillOpen(ws);
640
+ }
641
+
642
+ /**
643
+ * Test-only seam that exposes {@link writeDisconnect} so the
644
+ * reason-gated `ERROR :Closing link` write is reachable without
645
+ * driving a full actor flow that emits a self-Disconnect with a
646
+ * reason (rare — OperServ GHOST, KILL, flood, bad server-password).
647
+ */
648
+ public __writeDisconnectForTest(ws: WebSocket, mode: WsFrameMode, reason?: string): void {
649
+ this.writeDisconnect(ws, mode, reason);
650
+ }
651
+
539
652
  /** Tears down a connection: emit QUIT effects, release nick, close WS. */
540
653
  private async tearDown(ws: WebSocket): Promise<void> {
541
654
  const state = await this.loadState();
542
- await this.loadAccountStore();
543
655
  await this.loadServicesStore();
544
656
  const actor = this.buildActor(ws, state, this.wsFrameMode(ws));
545
657
  // Drive QUIT through the actor so the same effect pipeline handles
@@ -547,9 +659,8 @@ export class ConnectionDO extends DurableObject<Env> {
547
659
  await actor.receiveTextFrame('QUIT\r\n');
548
660
  await this.releaseAndPersist(state);
549
661
  await this.flushServices();
550
- if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
551
- ws.close();
552
- }
662
+ await this.releaseConnectionSlot();
663
+ this.closeIfStillOpen(ws);
553
664
  try {
554
665
  await this.ctx.storage.deleteAlarm();
555
666
  } catch {
@@ -557,6 +668,21 @@ export class ConnectionDO extends DurableObject<Env> {
557
668
  }
558
669
  }
559
670
 
671
+ /**
672
+ * Closes `ws` when it is still in a state where calling `close()` is
673
+ * meaningful (OPEN or CONNECTING). The QUIT flow drives a self-Disconnect
674
+ * that already closes the socket, so by the time {@link tearDown} reaches
675
+ * this guard the WS is usually CLOSING/CLOSED; the helper is a defensive
676
+ * backstop for the rare path where the actor does not emit a self-
677
+ * Disconnect. Extracted as a public seam so both arms are testable with
678
+ * a fake socket — the real QUIT flow always hits the false arm.
679
+ */
680
+ private closeIfStillOpen(ws: WebSocket): void {
681
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
682
+ ws.close();
683
+ }
684
+ }
685
+
560
686
  /** Releases the nick via the registry and persists the (possibly mutated) state. */
561
687
  private async releaseAndPersist(state: ConnectionState): Promise<void> {
562
688
  if (state.nick !== undefined) {
@@ -569,6 +695,32 @@ export class ConnectionDO extends DurableObject<Env> {
569
695
  await this.persistState(state);
570
696
  }
571
697
 
698
+ /**
699
+ * Releases this connection's admission slot in the global counter
700
+ * (`maxClients` cap). The key is this DO's hex id — exactly what the
701
+ * worker edge registered at upgrade time (`idFromName(uuid).toString()`),
702
+ * so no id needs to be passed through the upgrade request. Idempotent
703
+ * (deleting an absent key is a no-op): connections opened directly
704
+ * against the DO (integration harness, cross-DO RPC) never registered
705
+ * a slot and tear down harmlessly.
706
+ */
707
+ private async releaseConnectionSlot(): Promise<void> {
708
+ const stub = this.env.COUNTER_DO.get(this.env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
709
+ await (stub as unknown as CounterRpc).release(this.ctx.id.toString());
710
+ }
711
+
712
+ /**
713
+ * Refreshes the admission slot's `lastSeen` so the counter's lazy TTL
714
+ * reconcile only evicts entries whose ConnectionDO stopped
715
+ * heartbeating (crashed / evicted without delivering a close event).
716
+ * Called from the PING alarm; no-op for connections that never
717
+ * registered a slot.
718
+ */
719
+ private async refreshConnectionSlot(): Promise<void> {
720
+ const stub = this.env.COUNTER_DO.get(this.env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
721
+ await (stub as unknown as CounterRpc).heartbeat(this.ctx.id.toString());
722
+ }
723
+
572
724
  private serverConfig(): ServerConfig {
573
725
  // Single source of truth: delegate to the schema-validated loader so
574
726
  // every reducer-facing field (operCreds, serverPassword, motdLines,
@@ -577,19 +729,24 @@ export class ConnectionDO extends DurableObject<Env> {
577
729
  // silently dropped fields like operCreds, which left OPER unable to
578
730
  // authenticate (491 ERR_NOOPERHOST) even when OPER_USER/OPER_PASSWORD
579
731
  // were bound. See config-loader.ts and config.ts.
580
- if (this.cachedServerConfig === undefined) {
581
- this.cachedServerConfig = loadServerConfigFromCfEnv(this.env);
732
+ return this.parsedConfig();
733
+ }
734
+
735
+ /**
736
+ * Returns the full parsed config (the schema-validated superset of
737
+ * `ServerConfig`). The adapter reads fields the reducer-facing
738
+ * `ServerConfig` omits — `saslAccounts` (services seed), `motdLines`
739
+ * (MOTD provider), `maxClients` (admission), …
740
+ */
741
+ private parsedConfig(): ParsedServerConfig {
742
+ if (this.cachedParsedConfig === undefined) {
743
+ this.cachedParsedConfig = loadServerConfigFromCfEnv(this.env);
582
744
  }
583
- return this.cachedServerConfig;
745
+ return this.cachedParsedConfig;
584
746
  }
585
747
 
586
748
  private motdProvider(): MotdProvider {
587
- const raw = this.env.MOTD_LINES;
588
- if (raw === undefined || raw.length === 0) {
589
- return { lines: () => DEFAULT_MOTD };
590
- }
591
- // MOTD_LINES is a newline-delimited env var.
592
- return { lines: () => raw.split('\n') };
749
+ return chooseMotdProvider(this.env.MOTD_LINES);
593
750
  }
594
751
 
595
752
  /**
@@ -617,41 +774,32 @@ export class ConnectionDO extends DurableObject<Env> {
617
774
  return this.history;
618
775
  }
619
776
 
620
- /**
621
- * Resolves the SASL account store once (D1 table → `SASL_ACCOUNTS` seed),
622
- * caching the result. Safe to call on every frame; the D1 query runs at
623
- * most once per ConnectionDO instance. Must complete before the first
624
- * reducer dispatches so the synchronous `AccountStore.verify` port has its
625
- * data ready.
626
- */
627
- private async loadAccountStore(): Promise<void> {
628
- if (this.accountsResolved) return;
629
- this.accountsResolved = true;
630
- this.accounts = await resolveAccountStore(this.env.ACCOUNTS_DB, this.env.SASL_ACCOUNTS);
631
- }
632
-
633
- /**
634
- * Returns the resolved account store, or `undefined` when no accounts are
635
- * configured (so the actor's `ctx.accounts` stays unset: `AUTHENTICATE
636
- * PLAIN` → `904`). Populated by {@link loadAccountStore}; callers MUST
637
- * `await loadAccountStore()` before reading this.
638
- */
639
- private accountStore(): AccountStore | undefined {
640
- return this.accounts;
641
- }
642
-
643
777
  /**
644
778
  * Resolves the persistent services store once (D1 → write-behind cache),
645
779
  * caching the result. Safe to call on every frame; the D1 scan runs at
646
780
  * most once per ConnectionDO instance. Must complete before the first
647
781
  * reducer dispatches so the synchronous `ServicesStore` port has its
648
782
  * snapshot ready. Leaves `services` `undefined` when D1 is absent so the
649
- * actor's `ctx.services` stays unset (no NickServ/ChanServ routing).
783
+ * actor's `ctx.services` stays unset (no NickServ/ChanServ routing,
784
+ * no SASL/PASS credential verify).
785
+ *
786
+ * After the D1 load, the `SASL_ACCOUNTS` env-var seed is ingested:
787
+ * each `username:password` pair is registered via `registerNick` when
788
+ * the nick is not already present. Idempotent — re-ingesting on every
789
+ * cold start is a no-op for nicks the services table already holds
790
+ * (including NickServ-registered nicks whose password wins). New
791
+ * env-seed accounts are persisted by the write-behind flush.
650
792
  */
651
793
  private async loadServicesStore(): Promise<void> {
652
794
  if (this.servicesResolved) return;
653
795
  this.servicesResolved = true;
654
796
  this.services = await loadD1ServicesStore(this.env.ACCOUNTS_DB, this.clock);
797
+ if (this.services !== undefined) {
798
+ const cfg = this.parsedConfig();
799
+ for (const acct of cfg.saslAccounts) {
800
+ this.services.registerNick(acct.username, acct.password, '');
801
+ }
802
+ }
655
803
  }
656
804
 
657
805
  /**
@@ -674,6 +822,18 @@ export class ConnectionDO extends DurableObject<Env> {
674
822
  await this.services.flush();
675
823
  }
676
824
 
825
+ /**
826
+ * Test-only seam that exposes {@link flushServices} so the
827
+ * "services unbound" early-return is reachable. Production always
828
+ * drives flushServices after `loadServicesStore()` has run (in
829
+ * `webSocketMessage` and `tearDown*`), so the field is populated;
830
+ * the undefined arm fires only on a DO that has never processed a
831
+ * frame.
832
+ */
833
+ public async __flushServicesForTest(): Promise<void> {
834
+ await this.flushServices();
835
+ }
836
+
677
837
  // -------------------------------------------------------------------------
678
838
  // Test hooks (only invoked from `runInDurableObject` in tests)
679
839
  // -------------------------------------------------------------------------
@@ -699,13 +859,12 @@ export class ConnectionDO extends DurableObject<Env> {
699
859
  private async tearDownNoSocket(state: ConnectionState): Promise<void> {
700
860
  // Emit QUIT effects without dispatching transport calls (the socket
701
861
  // is already gone). The cross-DO fanout still runs.
702
- await this.loadAccountStore();
703
862
  await this.loadServicesStore();
704
863
  const services = this.servicesStore();
705
864
  const noOpHandlers: CfConnectionHandlers = {
706
- send: (): void => {},
707
- disconnect: (): void => {},
708
- snapshot: (): ConnectionState | undefined => this.cached,
865
+ send: NO_OP_SEND,
866
+ disconnect: NO_OP_DISCONNECT,
867
+ snapshot: this.peekCachedSync.bind(this),
709
868
  };
710
869
  const registryKey = this.ctx.id.toString();
711
870
  const runtime = makeCfRuntime(this.env, state.id, registryKey, noOpHandlers);
@@ -723,11 +882,12 @@ export class ConnectionDO extends DurableObject<Env> {
723
882
  motd: this.motdProvider(),
724
883
  messages: this.messageStore(),
725
884
  history: this.historyStore(),
726
- ...(services !== undefined ? { services } : {}),
885
+ ...buildOptionalActorFields(services, undefined),
727
886
  });
728
887
  await actor.receiveTextFrame('QUIT\r\n');
729
888
  await this.releaseAndPersist(state);
730
889
  await this.flushServices();
890
+ await this.releaseConnectionSlot();
731
891
  try {
732
892
  await this.ctx.storage.deleteAlarm();
733
893
  } catch {
@@ -735,6 +895,11 @@ export class ConnectionDO extends DurableObject<Env> {
735
895
  }
736
896
  }
737
897
 
898
+ /** Synchronous cached-state peek used by the no-socket teardown handlers. */
899
+ private peekCachedSync(): ConnectionState | undefined {
900
+ return this.cached;
901
+ }
902
+
738
903
  /** Drops the in-memory state cache, simulating wake-from-hibernation. */
739
904
  public __resetCache(): void {
740
905
  this.cached = undefined;
@@ -742,7 +907,7 @@ export class ConnectionDO extends DurableObject<Env> {
742
907
 
743
908
  /** Returns the live cached state (without forcing a storage read). */
744
909
  public async __peekState(): Promise<ConnectionState | undefined> {
745
- return this.cached;
910
+ return this.peekCachedSync();
746
911
  }
747
912
 
748
913
  /** Backdates `lastSeen` to test the alarm-driven disconnect path. */
@@ -761,6 +926,58 @@ export class ConnectionDO extends DurableObject<Env> {
761
926
  this.__suppressCloseTeardown = true;
762
927
  }
763
928
 
929
+ /**
930
+ * Test-only: sets the captured mTLS cert subject, simulating an upgrade
931
+ * request that arrived through CF API Shield mTLS. Used to exercise the
932
+ * `mtlsIdentity` construction branch in {@link buildActor} without
933
+ * having to inject `request.cf` (which is read-only in the workerd
934
+ * test runtime).
935
+ */
936
+ /**
937
+ * Captures the verified mTLS cert identity off an inbound request and
938
+ * stores it on the instance. Extracted so the assignment branch is
939
+ * testable via {@link __captureMtlsSubjectForTest} (the workerd test
940
+ * runtime strips `request.cf` across the DO RPC boundary, so the
941
+ * inline fetch path cannot be exercised end-to-end).
942
+ */
943
+ private captureMtlsSubject(request: Request): void {
944
+ const identity = extractMtlsSubject(request);
945
+ if (identity !== undefined) {
946
+ this.mtlsCert = identity;
947
+ }
948
+ }
949
+
950
+ public __setMtlsCertSubject(subject: string | undefined): void {
951
+ this.mtlsCert = subject === undefined ? undefined : certIdentityFromSubject(subject);
952
+ }
953
+
954
+ /** Test-only: returns the captured mTLS cert subject (or undefined). */
955
+ public async __peekMtlsCertSubject(): Promise<string | undefined> {
956
+ return this.mtlsCert?.subject;
957
+ }
958
+
959
+ /**
960
+ * Test-only seam that exposes {@link captureMtlsSubject} so the
961
+ * if-defined assignment can be exercised with a crafted request.cf.
962
+ */
963
+ public __captureMtlsSubjectForTest(request: Request): void {
964
+ this.captureMtlsSubject(request);
965
+ }
966
+
967
+ /**
968
+ * Test-only: invokes {@link webSocketError} against the first attached
969
+ * WebSocket. The platform normally fires this asynchronously on
970
+ * transport errors; calling it here deterministically exercises the
971
+ * handler body. The WebSocket must have been accepted via `fetch` so
972
+ * `ctx.getWebSockets()` returns it.
973
+ */
974
+ public async __triggerWsError(error: unknown): Promise<void> {
975
+ const sockets = this.ctx.getWebSockets();
976
+ const ws = sockets[0];
977
+ if (ws === undefined) return;
978
+ await this.webSocketError(ws as WebSocket, error);
979
+ }
980
+
764
981
  /** Returns this DO's hex id — the canonical ConnId used in RPC routing. */
765
982
  public __peekHexId(): string {
766
983
  return this.ctx.id.toString();
@@ -813,6 +1030,104 @@ export class ConnectionDO extends DurableObject<Env> {
813
1030
  /** Default MOTD when no `MOTD_LINES` env is supplied. */
814
1031
  const DEFAULT_MOTD: string[] = ['Welcome to the ServerlessIRCd Cloudflare adapter.'];
815
1032
 
1033
+ /**
1034
+ * Reads the verified mTLS client-cert identity off an inbound Request, when
1035
+ * CF API Shield mTLS is configured. Extracted as a pure helper so the
1036
+ * `cf.tlsClientAuthCertSubject` / `cf.tlsClientAuth` branches are
1037
+ * unit-testable directly (the workerd test runtime does not surface
1038
+ * `request.cf` the way the CF edge does, and `Request.cf` is read-only so
1039
+ * tests cannot inject it via the standard constructor).
1040
+ *
1041
+ * Identifier resolution (strongest first):
1042
+ * 1. Subject from `cf.tlsClientAuthCertSubject` (legacy documented field),
1043
+ * falling back to `cf.tlsClientAuth.certSubjectDN` when absent.
1044
+ * 2. DER SHA-256 fingerprint from `cf.tlsClientAuth.certFingerprintSHA256`
1045
+ * when it is a non-empty string (Cloudflare documents it as `""` for
1046
+ * non-mTLS connections).
1047
+ *
1048
+ * Returns the {@link CertIdentity} (raw subject + canonical DN + optional
1049
+ * fingerprint) when a subject was captured, or `undefined` when mTLS is
1050
+ * not configured.
1051
+ */
1052
+ export function extractMtlsSubject(request: Request): CertIdentity | undefined {
1053
+ const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
1054
+ if (cf === undefined) return undefined;
1055
+ const subject = readCertSubject(cf);
1056
+ if (subject === undefined) return undefined;
1057
+ return certIdentityFromSubject(subject, readCertFingerprint(cf));
1058
+ }
1059
+
1060
+ /** Reads the cert subject: legacy `tlsClientAuthCertSubject`, then `tlsClientAuth.certSubjectDN`. */
1061
+ function readCertSubject(cf: Record<string, unknown>): string | undefined {
1062
+ if (typeof cf.tlsClientAuthCertSubject === 'string') {
1063
+ return cf.tlsClientAuthCertSubject;
1064
+ }
1065
+ const tlsClientAuth = cf.tlsClientAuth;
1066
+ if (isRecord(tlsClientAuth) && typeof tlsClientAuth.certSubjectDN === 'string') {
1067
+ return tlsClientAuth.certSubjectDN;
1068
+ }
1069
+ return undefined;
1070
+ }
1071
+
1072
+ /** Reads the DER SHA-256 fingerprint off `tlsClientAuth` (empty string = absent). */
1073
+ function readCertFingerprint(cf: Record<string, unknown>): string | undefined {
1074
+ const tlsClientAuth = cf.tlsClientAuth;
1075
+ if (!isRecord(tlsClientAuth)) return undefined;
1076
+ const fp = tlsClientAuth.certFingerprintSHA256;
1077
+ return typeof fp === 'string' && fp.length > 0 ? fp : undefined;
1078
+ }
1079
+
1080
+ function isRecord(v: unknown): v is Record<string, unknown> {
1081
+ return typeof v === 'object' && v !== null;
1082
+ }
1083
+
1084
+ /**
1085
+ * No-op transport callbacks used by {@link ConnectionDO.tearDownNoSocket}
1086
+ * when the WebSocket has already been dropped. Exported so the function
1087
+ * bodies are exercised by a direct unit test (the actor never invokes
1088
+ * `send`/`disconnect` during the no-socket QUIT path because there is
1089
+ * no peer to deliver to).
1090
+ */
1091
+ export const NO_OP_SEND: (lines: RawLine[]) => void = () => {};
1092
+ export const NO_OP_DISCONNECT: (reason?: string) => void = () => {};
1093
+
1094
+ /**
1095
+ * Builds the optional `services` / `mtlsIdentity` fields for a
1096
+ * {@link ConnectionActor} constructor. Each field is included only when
1097
+ * the source resolved a value; the `: {}` arms of the conditional spreads
1098
+ * are exercised by both the with-everything and with-nothing test paths.
1099
+ *
1100
+ * Extracted as a pure helper so the conditional-spread branches are
1101
+ * testable without driving a full DO event cycle that leaves the
1102
+ * underlying stores unset (the test env binds D1 + services, so production
1103
+ * builds always hit the defined arm for `services`).
1104
+ */
1105
+ export function buildOptionalActorFields(
1106
+ services: D1ServicesStore | undefined,
1107
+ mtlsIdentity: MtlsIdentityProvider | undefined,
1108
+ ): {
1109
+ services?: D1ServicesStore;
1110
+ mtlsIdentity?: MtlsIdentityProvider;
1111
+ } {
1112
+ return {
1113
+ ...(services !== undefined ? { services } : {}),
1114
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
1115
+ };
1116
+ }
1117
+
1118
+ /**
1119
+ * Resolves the {@link MotdProvider} for a raw `MOTD_LINES` env value.
1120
+ * Returns the default single-line MOTD when the env is unset or empty;
1121
+ * otherwise splits the env on newlines. Extracted so both branches are
1122
+ * unit-testable without changing the bound env in the test worker.
1123
+ */
1124
+ export function chooseMotdProvider(raw: string | undefined): MotdProvider {
1125
+ if (raw === undefined || raw.length === 0) {
1126
+ return { lines: () => DEFAULT_MOTD };
1127
+ }
1128
+ return { lines: () => raw.split('\n') };
1129
+ }
1130
+
816
1131
  /**
817
1132
  * `ActorChannelAccess` backed by a per-instance cache that is refreshed
818
1133
  * from the authoritative ChannelDO before each reducer that needs to
@@ -919,31 +1234,6 @@ export class PassthroughChannelAccess {
919
1234
  }
920
1235
  }
921
1236
 
922
- /**
923
- * Parses the `SASL_ACCOUNTS` env var into credential entries.
924
- *
925
- * Format: newline-delimited `username:password` pairs (mirroring the
926
- * `MOTD_LINES` convention). Blank lines and malformed entries (missing
927
- * colon or empty username/password) are skipped so a trailing newline or a
928
- * partial edit does not break boot. Exported so the parsing boundary is
929
- * unit-testable in isolation.
930
- */
931
- export function parseSaslAccountsEnv(raw: string | undefined): SaslAccountCredential[] {
932
- if (raw === undefined || raw.length === 0) return [];
933
- const out: SaslAccountCredential[] = [];
934
- for (const line of raw.split('\n')) {
935
- const trimmed = line.trim();
936
- if (trimmed.length === 0) continue;
937
- const sep = trimmed.indexOf(':');
938
- if (sep <= 0) continue; // missing colon or empty username
939
- const username = trimmed.slice(0, sep);
940
- const password = trimmed.slice(sep + 1);
941
- if (username.length === 0 || password.length === 0) continue;
942
- out.push({ username, password });
943
- }
944
- return out;
945
- }
946
-
947
1237
  /**
948
1238
  * Hibernation-tag prefixes written by {@link ConnectionDO.fetch}. Each tag
949
1239
  * encodes the negotiated {@link WsFrameMode} so it survives DO eviction and